Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce9e9f3e0d | ||
|
|
da4cf04594 | ||
|
|
0677b3bd7e | ||
|
|
eed233a793 | ||
|
|
2ad0802b65 | ||
|
|
0df8aa9a5d | ||
|
|
d3f71f9d0a | ||
|
|
8187b49599 | ||
|
|
2188643387 | ||
|
|
344031b575 | ||
|
|
a320093cb8 | ||
|
|
7fb7ba2fa5 | ||
|
|
3902599c52 | ||
|
|
4972407145 | ||
|
|
d714af43c9 | ||
|
|
29c2fc5472 | ||
|
|
1c9766b8fd | ||
|
|
68351230f3 | ||
|
|
0ad85262c1 | ||
|
|
1552d8103e | ||
|
|
c3a2331b59 | ||
|
|
5cf92c55c6 | ||
|
|
e56eb0c178 | ||
|
|
44bc13eb2c | ||
|
|
a77f89d302 | ||
|
|
b1bbbf0103 | ||
|
|
c2f31b9c9f | ||
|
|
198e2cfd90 | ||
|
|
936e95fd9e | ||
|
|
c56814e7da | ||
|
|
631ade5430 | ||
|
|
e61a0c2f78 | ||
|
|
89957e7058 | ||
|
|
26dde0f286 | ||
|
|
7d9f687768 | ||
|
|
0a0fea1c2f | ||
|
|
cb4970be59 | ||
|
|
460162737a | ||
|
|
393f95aeac | ||
|
|
03a4e3e8e9 | ||
|
|
243d549975 | ||
|
|
e309775ac1 | ||
|
|
f388fd9c90 | ||
|
|
b908f07355 | ||
|
|
1287594505 | ||
|
|
86b20e8ccd | ||
|
|
2181227a6e | ||
|
|
aab705f4a4 | ||
|
|
8af9a97518 | ||
|
|
9fac75b831 | ||
|
|
c83b5c6e73 | ||
|
|
a75326ff69 | ||
|
|
df6611e8de | ||
|
|
7e817f408c | ||
|
|
4ceb058a40 | ||
|
|
4710812c24 | ||
|
|
eb37c47ff5 | ||
|
|
e80c2c1a57 | ||
|
|
75f8607d75 | ||
|
|
828a286809 | ||
|
|
9b0e7eedb2 | ||
|
|
df4585af6b | ||
|
|
91d40dcc91 | ||
|
|
2b6363474e | ||
|
|
707c58a120 | ||
|
|
846ee0fb2d | ||
|
|
cdc9c0d62c | ||
|
|
b079cb99a4 | ||
|
|
a75f74b471 | ||
|
|
e50779664d | ||
|
|
51583f5d28 | ||
|
|
c3170e1eb6 | ||
|
|
bc16ef8480 | ||
|
|
6a7b20e4e3 | ||
|
|
034c81288c | ||
|
|
762333c28f | ||
|
|
38b28f9bf4 | ||
|
|
aa94410308 | ||
|
|
c76e55a1c8 | ||
|
|
f6b9ebb693 | ||
|
|
68a324c206 | ||
|
|
0b0595384e | ||
|
|
289a6fdd0f | ||
|
|
e8cb9e7fde | ||
|
|
b5183612be | ||
|
|
44a9509cd6 | ||
|
|
cefe276ce5 | ||
|
|
e7c19a2bad | ||
|
|
c45a28e6af | ||
|
|
70aefc2e48 | ||
|
|
014b561b29 | ||
|
|
f397456703 | ||
|
|
9171e94e5a | ||
|
|
5eca20469f | ||
|
|
5ea2751423 | ||
|
|
814690e66b | ||
|
|
9b2ddabca9 | ||
|
|
8f7b61291f | ||
|
|
523e037900 | ||
|
|
88586c8f86 | ||
|
|
24eb27d41c | ||
|
|
7a7e7ca359 | ||
|
|
41c1189fee | ||
|
|
2e40b895a7 | ||
|
|
76d102f964 | ||
|
|
807d2b0d9d | ||
|
|
b4f71ce01a | ||
|
|
722c579db0 | ||
|
|
2930c4ff62 | ||
|
|
38788bb2e9 | ||
|
|
75bef92417 | ||
|
|
eb5b86ffe2 | ||
|
|
09515f26df | ||
|
|
8a3ee987a8 | ||
|
|
47b491b6e2 | ||
|
|
91ad69dd00 | ||
|
|
521aad7db5 | ||
|
|
fe2988bb38 | ||
|
|
65a53c1100 | ||
|
|
a53f18ca7d | ||
|
|
595ea87465 | ||
|
|
7b37141e07 | ||
|
|
1fd327325f | ||
|
|
96ad49f675 | ||
|
|
35b2ca51d8 | ||
|
|
76a28b4e8b | ||
|
|
9752bd7c30 | ||
|
|
46c0039a16 | ||
|
|
d5497908bb | ||
|
|
dac88391c1 | ||
|
|
a46a520bca | ||
|
|
04719f8dee | ||
|
|
113053a9e3 | ||
|
|
7cfe909644 | ||
|
|
01a1d981cf | ||
|
|
e7f8fc93e4 | ||
|
|
b45ca6f2c0 | ||
|
|
be17294dc2 | ||
|
|
7eaa92cb20 | ||
|
|
3001e57bcc | ||
|
|
43a2acb756 | ||
|
|
bcc424f2aa | ||
|
|
ec7e58a6a2 | ||
|
|
9a0f1f22b8 | ||
|
|
d6762276f5 | ||
|
|
41514cd557 | ||
|
|
63a29380a9 | ||
|
|
eeb378cfdc | ||
|
|
7a23779d07 | ||
|
|
29628a66a6 | ||
|
|
020c058805 | ||
|
|
8a625ef786 | ||
|
|
3bc8ff0104 | ||
|
|
11b5ac49c0 | ||
|
|
f553cdb282 | ||
|
|
6b46eb46e2 | ||
|
|
7280ae1ebc | ||
|
|
873c57b3d8 |
1
.gitignore
vendored
@@ -3,4 +3,5 @@ build/
|
|||||||
.idea/
|
.idea/
|
||||||
server/docs/
|
server/docs/
|
||||||
tools/fbsend/fbsend
|
tools/fbsend/fbsend
|
||||||
|
playground/
|
||||||
*.iml
|
*.iml
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod download
|
- go mod download
|
||||||
|
- go mod tidy
|
||||||
builds:
|
builds:
|
||||||
-
|
-
|
||||||
id: ntfy
|
id: ntfy
|
||||||
@@ -12,6 +13,9 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [amd64]
|
goarch: [amd64]
|
||||||
|
hooks:
|
||||||
|
post:
|
||||||
|
- upx "{{ .Path }}" # apt install upx
|
||||||
-
|
-
|
||||||
id: ntfy_armv7
|
id: ntfy_armv7
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
@@ -24,6 +28,9 @@ builds:
|
|||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm]
|
goarch: [arm]
|
||||||
goarm: [7]
|
goarm: [7]
|
||||||
|
hooks:
|
||||||
|
post:
|
||||||
|
- upx "{{ .Path }}" # apt install upx
|
||||||
-
|
-
|
||||||
id: ntfy_arm64
|
id: ntfy_arm64
|
||||||
binary: ntfy
|
binary: ntfy
|
||||||
@@ -35,6 +42,9 @@ builds:
|
|||||||
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: [arm64]
|
goarch: [arm64]
|
||||||
|
hooks:
|
||||||
|
post:
|
||||||
|
- upx "{{ .Path }}" # apt install upx
|
||||||
nfpms:
|
nfpms:
|
||||||
-
|
-
|
||||||
package_name: ntfy
|
package_name: ntfy
|
||||||
@@ -59,6 +69,10 @@ nfpms:
|
|||||||
dst: /lib/systemd/system/ntfy-client.service
|
dst: /lib/systemd/system/ntfy-client.service
|
||||||
- dst: /var/cache/ntfy
|
- dst: /var/cache/ntfy
|
||||||
type: dir
|
type: dir
|
||||||
|
- dst: /var/cache/ntfy/attachments
|
||||||
|
type: dir
|
||||||
|
- dst: /var/lib/ntfy
|
||||||
|
type: dir
|
||||||
- dst: /usr/share/ntfy/logo.png
|
- dst: /usr/share/ntfy/logo.png
|
||||||
src: server/static/img/ntfy.png
|
src: server/static/img/ntfy.png
|
||||||
scripts:
|
scripts:
|
||||||
@@ -101,6 +115,7 @@ dockers:
|
|||||||
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
|
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
|
||||||
use: buildx
|
use: buildx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
goarch: arm64
|
||||||
build_flag_templates:
|
build_flag_templates:
|
||||||
- "--platform=linux/arm64/v8"
|
- "--platform=linux/arm64/v8"
|
||||||
- image_templates:
|
- image_templates:
|
||||||
|
|||||||
5
Makefile
@@ -80,7 +80,7 @@ vet:
|
|||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
lint:
|
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
|
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
|
||||||
|
|
||||||
staticcheck: .PHONY
|
staticcheck: .PHONY
|
||||||
@@ -105,7 +105,8 @@ build-snapshot: build-deps
|
|||||||
goreleaser build --snapshot --rm-dist --debug
|
goreleaser build --snapshot --rm-dist --debug
|
||||||
|
|
||||||
build-simple: clean
|
build-simple: clean
|
||||||
mkdir -p dist/ntfy_linux_amd64
|
mkdir -p dist/ntfy_linux_amd64 server/docs
|
||||||
|
touch server/docs/dummy
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
go build \
|
go build \
|
||||||
-o dist/ntfy_linux_amd64/ntfy \
|
-o dist/ntfy_linux_amd64/ntfy \
|
||||||
|
|||||||
10
README.md
@@ -6,7 +6,8 @@
|
|||||||
[](https://github.com/binwiederhier/ntfy/actions)
|
[](https://github.com/binwiederhier/ntfy/actions)
|
||||||
[](https://goreportcard.com/report/github.com/binwiederhier/ntfy)
|
[](https://goreportcard.com/report/github.com/binwiederhier/ntfy)
|
||||||
[](https://codecov.io/gh/binwiederhier/ntfy)
|
[](https://codecov.io/gh/binwiederhier/ntfy)
|
||||||
[](https://discord.gg/cT7ECsZj9w)
|
[](https://discord.gg/cT7ECsZj9w)
|
||||||
|
[](https://matrix.to/#/#ntfy:matrix.org)
|
||||||
[](https://ntfy.statuspage.io/)
|
[](https://ntfy.statuspage.io/)
|
||||||
|
|
||||||
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
|
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
|
||||||
@@ -36,8 +37,9 @@ too.
|
|||||||
I welcome any and all contributions. Just create a PR or an issue.
|
I welcome any and all contributions. Just create a PR or an issue.
|
||||||
|
|
||||||
## Contact me
|
## Contact me
|
||||||
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)**, or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues),
|
You can directly contact me **[on Discord](https://discord.gg/cT7ECsZj9w)** or [on Matrix](https://matrix.to/#/#ntfy:matrix.org)
|
||||||
or find more contact information [on my website](https://heckel.io/about).
|
(bridged from Discord), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues), or find more contact information
|
||||||
|
[on my website](https://heckel.io/about).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
|
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
|
||||||
@@ -48,6 +50,8 @@ Third party libraries and resources:
|
|||||||
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
|
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
|
||||||
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
|
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
|
||||||
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
||||||
|
* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails
|
||||||
|
* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests
|
||||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
||||||
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
||||||
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||||
|
|||||||
122
auth/auth.go
Normal 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
@@ -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 = 10
|
||||||
|
intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // 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
|
||||||
|
}
|
||||||
243
auth/auth_sqlite_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const minBcryptTimingMillis = int64(50) // Ideally should be >100ms, but this should also run on a Raspberry Pi without massive resources
|
||||||
|
|
||||||
|
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$10$"))
|
||||||
|
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$10$"))
|
||||||
|
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, minBcryptTimingMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
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, minBcryptTimingMillis)
|
||||||
|
|
||||||
|
// 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, minBcryptTimingMillis)
|
||||||
|
|
||||||
|
// 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, minBcryptTimingMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
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$10$"))
|
||||||
|
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$10$"))
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
@@ -17,9 +18,10 @@ import (
|
|||||||
|
|
||||||
// Event type constants
|
// Event type constants
|
||||||
const (
|
const (
|
||||||
MessageEvent = "message"
|
MessageEvent = "message"
|
||||||
KeepaliveEvent = "keepalive"
|
KeepaliveEvent = "keepalive"
|
||||||
OpenEvent = "open"
|
OpenEvent = "open"
|
||||||
|
PollRequestEvent = "poll_request"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -36,14 +38,16 @@ type Client struct {
|
|||||||
|
|
||||||
// Message is a struct that represents a ntfy message
|
// Message is a struct that represents a ntfy message
|
||||||
type Message struct { // TODO combine with server.message
|
type Message struct { // TODO combine with server.message
|
||||||
ID string
|
ID string
|
||||||
Event string
|
Event string
|
||||||
Time int64
|
Time int64
|
||||||
Topic string
|
Topic string
|
||||||
Message string
|
Message string
|
||||||
Title string
|
Title string
|
||||||
Priority int
|
Priority int
|
||||||
Tags []string
|
Tags []string
|
||||||
|
Click string
|
||||||
|
Attachment *Attachment
|
||||||
|
|
||||||
// Additional fields
|
// Additional fields
|
||||||
TopicURL string
|
TopicURL string
|
||||||
@@ -51,6 +55,16 @@ type Message struct { // TODO combine with server.message
|
|||||||
Raw string
|
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 {
|
type subscription struct {
|
||||||
ID string
|
ID string
|
||||||
topicURL string
|
topicURL string
|
||||||
@@ -67,6 +81,12 @@ func New(config *Config) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Publish sends a message to a specific topic, optionally using options.
|
// Publish sends a message to a specific topic, optionally using options.
|
||||||
|
// See PublishReader for details.
|
||||||
|
func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) {
|
||||||
|
return c.PublishReader(topic, strings.NewReader(message), options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishReader sends a message to a specific topic, optionally using options.
|
||||||
//
|
//
|
||||||
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
|
// A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https://
|
||||||
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
|
// (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the
|
||||||
@@ -74,9 +94,9 @@ func New(config *Config) *Client {
|
|||||||
//
|
//
|
||||||
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
|
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
|
||||||
// WithNoFirebase, and the generic WithHeader.
|
// WithNoFirebase, and the generic WithHeader.
|
||||||
func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) {
|
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
|
||||||
topicURL := c.expandTopicURL(topic)
|
topicURL := c.expandTopicURL(topic)
|
||||||
req, _ := http.NewRequest("POST", topicURL, strings.NewReader(message))
|
req, _ := http.NewRequest("POST", topicURL, body)
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
if err := option(req); err != nil {
|
if err := option(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -87,13 +107,13 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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))
|
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, errors.New(strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
m, err := toMessage(string(b), topicURL, "")
|
m, err := toMessage(string(b), topicURL, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -232,6 +252,13 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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)
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
|
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ func TestClient_Publish_Subscribe(t *testing.T) {
|
|||||||
require.Equal(t, "some delayed message", msg.Message)
|
require.Equal(t, "some delayed message", msg.Message)
|
||||||
require.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time)
|
require.True(t, time.Now().Add(24*time.Hour).Unix() < msg.Time)
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
msg = nextMessage(c)
|
msg = nextMessage(c)
|
||||||
require.NotNil(t, msg)
|
require.NotNil(t, msg)
|
||||||
require.Equal(t, "some message", msg.Message)
|
require.Equal(t, "some message", msg.Message)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,6 +17,11 @@ type PublishOption = RequestOption
|
|||||||
// SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call
|
// SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call
|
||||||
type SubscribeOption = RequestOption
|
type SubscribeOption = RequestOption
|
||||||
|
|
||||||
|
// WithMessage sets the notification message. This is an alternative way to passing the message body.
|
||||||
|
func WithMessage(message string) PublishOption {
|
||||||
|
return WithHeader("X-Message", message)
|
||||||
|
}
|
||||||
|
|
||||||
// WithTitle adds a title to a message
|
// WithTitle adds a title to a message
|
||||||
func WithTitle(title string) PublishOption {
|
func WithTitle(title string) PublishOption {
|
||||||
return WithHeader("X-Title", title)
|
return WithHeader("X-Title", title)
|
||||||
@@ -45,6 +51,31 @@ func WithDelay(delay string) PublishOption {
|
|||||||
return WithHeader("X-Delay", delay)
|
return WithHeader("X-Delay", delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithClick makes the notification action open the given URL as opposed to entering the detail view
|
||||||
|
func WithClick(url string) PublishOption {
|
||||||
|
return WithHeader("X-Click", url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAttach sets a URL that will be used by the client to download an attachment
|
||||||
|
func WithAttach(attach string) PublishOption {
|
||||||
|
return WithHeader("X-Attach", attach)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment
|
||||||
|
func WithFilename(filename string) PublishOption {
|
||||||
|
return WithHeader("X-Filename", filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEmail instructs the server to also send the message to the given e-mail address
|
||||||
|
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
|
// WithNoCache instructs the server not to cache the message server-side
|
||||||
func WithNoCache() PublishOption {
|
func WithNoCache() PublishOption {
|
||||||
return WithHeader("X-Cache", "no")
|
return WithHeader("X-Cache", "no")
|
||||||
|
|||||||
212
cmd/access.go
Normal 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
@@ -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...))
|
||||||
|
}
|
||||||
15
cmd/app.go
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -15,6 +14,11 @@ var (
|
|||||||
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
|
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
categoryClient = "Client commands"
|
||||||
|
categoryServer = "Server commands"
|
||||||
|
)
|
||||||
|
|
||||||
// New creates a new CLI application
|
// New creates a new CLI application
|
||||||
func New() *cli.App {
|
func New() *cli.App {
|
||||||
return &cli.App{
|
return &cli.App{
|
||||||
@@ -30,7 +34,12 @@ func New() *cli.App {
|
|||||||
Before: initConfigFileInputSource("config", flagsServe), // DEPRECATED, see deprecation notice
|
Before: initConfigFileInputSource("config", flagsServe), // DEPRECATED, see deprecation notice
|
||||||
Flags: flagsServe, // DEPRECATED, see deprecation notice
|
Flags: flagsServe, // DEPRECATED, see deprecation notice
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
|
// Server commands
|
||||||
cmdServe,
|
cmdServe,
|
||||||
|
cmdUser,
|
||||||
|
cmdAccess,
|
||||||
|
|
||||||
|
// Client commands
|
||||||
cmdPublish,
|
cmdPublish,
|
||||||
cmdSubscribe,
|
cmdSubscribe,
|
||||||
},
|
},
|
||||||
@@ -60,7 +69,3 @@ func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFu
|
|||||||
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
return altsrc.ApplyInputSourceValues(context, inputSource, flags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collapseTopicURL(s string) string {
|
|
||||||
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,17 +2,18 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"io"
|
"heckel.io/ntfy/client"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// This only contains helpers so far
|
// This only contains helpers so far
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
log.SetOutput(io.Discard)
|
// log.SetOutput(io.Discard)
|
||||||
os.Exit(m.Run())
|
os.Exit(m.Run())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,3 +25,11 @@ func newTestApp() (*cli.App, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
|
|||||||
app.ErrWriter = &stderr
|
app.ErrWriter = &stderr
|
||||||
return app, &stdin, &stdout, &stderr
|
return app, &stdin, &stdout, &stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toMessage(t *testing.T, s string) *client.Message {
|
||||||
|
var m *client.Message
|
||||||
|
if err := json.NewDecoder(strings.NewReader(s)).Decode(&m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|||||||
121
cmd/publish.go
@@ -5,6 +5,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/client"
|
"heckel.io/ntfy/client"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,17 +16,25 @@ var cmdPublish = &cli.Command{
|
|||||||
Name: "publish",
|
Name: "publish",
|
||||||
Aliases: []string{"pub", "send", "trigger"},
|
Aliases: []string{"pub", "send", "trigger"},
|
||||||
Usage: "Send message via a ntfy server",
|
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,
|
Action: execPublish,
|
||||||
|
Category: categoryClient,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, Usage: "message title"},
|
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, 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: "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"}, Usage: "comma separated list of tags and emojis"},
|
&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"}, Usage: "delay/schedule message"},
|
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
|
||||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},
|
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
|
||||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"},
|
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
|
||||||
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, Usage: "do print message"},
|
&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.
|
Description: `Publish a message to a ntfy server.
|
||||||
|
|
||||||
@@ -33,8 +45,16 @@ Examples:
|
|||||||
ntfy pub --tags=warning,skull backups "Backups failed" # Add tags/emojis to message
|
ntfy pub --tags=warning,skull backups "Backups failed" # Add tags/emojis to message
|
||||||
ntfy pub --delay=10s delayed_topic Laterzz # Delay message by 10s
|
ntfy pub --delay=10s delayed_topic Laterzz # Delay message by 10s
|
||||||
ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am
|
ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am
|
||||||
|
ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com
|
||||||
|
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
|
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,
|
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/.
|
it has incredibly useful information: https://ntfy.sh/docs/publish/.
|
||||||
|
|
||||||
@@ -43,9 +63,6 @@ or ~/.config/ntfy/client.yml for all other users.`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func execPublish(c *cli.Context) error {
|
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)
|
conf, err := loadConfig(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -54,13 +71,30 @@ func execPublish(c *cli.Context) error {
|
|||||||
priority := c.String("priority")
|
priority := c.String("priority")
|
||||||
tags := c.String("tags")
|
tags := c.String("tags")
|
||||||
delay := c.String("delay")
|
delay := c.String("delay")
|
||||||
|
click := c.String("click")
|
||||||
|
attach := c.String("attach")
|
||||||
|
filename := c.String("filename")
|
||||||
|
file := c.String("file")
|
||||||
|
email := c.String("email")
|
||||||
|
user := c.String("user")
|
||||||
noCache := c.Bool("no-cache")
|
noCache := c.Bool("no-cache")
|
||||||
noFirebase := c.Bool("no-firebase")
|
noFirebase := c.Bool("no-firebase")
|
||||||
|
envTopic := c.Bool("env-topic")
|
||||||
quiet := c.Bool("quiet")
|
quiet := c.Bool("quiet")
|
||||||
topic := c.Args().Get(0)
|
var topic, message string
|
||||||
message := ""
|
if envTopic {
|
||||||
if c.NArg() > 1 {
|
topic = os.Getenv("NTFY_TOPIC")
|
||||||
message = strings.Join(c.Args().Slice()[1:], " ")
|
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
|
var options []client.PublishOption
|
||||||
if title != "" {
|
if title != "" {
|
||||||
@@ -75,14 +109,65 @@ func execPublish(c *cli.Context) error {
|
|||||||
if delay != "" {
|
if delay != "" {
|
||||||
options = append(options, client.WithDelay(delay))
|
options = append(options, client.WithDelay(delay))
|
||||||
}
|
}
|
||||||
|
if click != "" {
|
||||||
|
options = append(options, client.WithClick(click))
|
||||||
|
}
|
||||||
|
if attach != "" {
|
||||||
|
options = append(options, client.WithAttach(attach))
|
||||||
|
}
|
||||||
|
if filename != "" {
|
||||||
|
options = append(options, client.WithFilename(filename))
|
||||||
|
}
|
||||||
|
if email != "" {
|
||||||
|
options = append(options, client.WithEmail(email))
|
||||||
|
}
|
||||||
if noCache {
|
if noCache {
|
||||||
options = append(options, client.WithNoCache())
|
options = append(options, client.WithNoCache())
|
||||||
}
|
}
|
||||||
if noFirebase {
|
if noFirebase {
|
||||||
options = append(options, client.WithNoFirebase())
|
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)
|
||||||
|
} else {
|
||||||
|
if message != "" {
|
||||||
|
options = append(options, client.WithMessage(message))
|
||||||
|
}
|
||||||
|
if file == "-" {
|
||||||
|
if filename == "" {
|
||||||
|
options = append(options, client.WithFilename("stdin"))
|
||||||
|
}
|
||||||
|
body = c.App.Reader
|
||||||
|
} else {
|
||||||
|
if filename == "" {
|
||||||
|
options = append(options, client.WithFilename(filepath.Base(file)))
|
||||||
|
}
|
||||||
|
body, err = os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
cl := client.New(conf)
|
cl := client.New(conf)
|
||||||
m, err := cl.Publish(topic, message, options...)
|
m, err := cl.PublishReader(topic, body, options...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/test"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -16,3 +18,55 @@ func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
|||||||
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}))
|
||||||
require.Contains(t, stdout.String(), testMessage)
|
require.Contains(t, stdout.String(), testMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCLI_Publish_Subscribe_Poll(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", topic, "some message"}))
|
||||||
|
m := toMessage(t, stdout.String())
|
||||||
|
require.Equal(t, "some message", m.Message)
|
||||||
|
|
||||||
|
app2, _, stdout, _ := newTestApp()
|
||||||
|
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", topic}))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
142
cmd/serve.go
@@ -1,31 +1,54 @@
|
|||||||
// Package cmd provides the ntfy CLI application
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"github.com/urfave/cli/v2/altsrc"
|
"github.com/urfave/cli/v2/altsrc"
|
||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var flagsServe = []cli.Flag{
|
var flagsServe = []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"},
|
&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: "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-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-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: "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: "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: "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.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.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)"}),
|
||||||
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"V"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"B"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"R"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
|
||||||
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||||
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}),
|
||||||
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
|
||||||
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
|
||||||
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
||||||
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
|
||||||
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +57,7 @@ var cmdServe = &cli.Command{
|
|||||||
Usage: "Run the ntfy server",
|
Usage: "Run the ntfy server",
|
||||||
UsageText: "ntfy serve [OPTIONS..]",
|
UsageText: "ntfy serve [OPTIONS..]",
|
||||||
Action: execServe,
|
Action: execServe,
|
||||||
|
Category: categoryServer,
|
||||||
Flags: flagsServe,
|
Flags: flagsServe,
|
||||||
Before: initConfigFileInputSource("config", flagsServe),
|
Before: initConfigFileInputSource("config", flagsServe),
|
||||||
Description: `Run the ntfy server and listen for incoming requests
|
Description: `Run the ntfy server and listen for incoming requests
|
||||||
@@ -52,19 +76,39 @@ func execServe(c *cli.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read all the options
|
// Read all the options
|
||||||
|
baseURL := c.String("base-url")
|
||||||
listenHTTP := c.String("listen-http")
|
listenHTTP := c.String("listen-http")
|
||||||
listenHTTPS := c.String("listen-https")
|
listenHTTPS := c.String("listen-https")
|
||||||
|
listenUnix := c.String("listen-unix")
|
||||||
keyFile := c.String("key-file")
|
keyFile := c.String("key-file")
|
||||||
certFile := c.String("cert-file")
|
certFile := c.String("cert-file")
|
||||||
firebaseKeyFile := c.String("firebase-key-file")
|
firebaseKeyFile := c.String("firebase-key-file")
|
||||||
cacheFile := c.String("cache-file")
|
cacheFile := c.String("cache-file")
|
||||||
cacheDuration := c.Duration("cache-duration")
|
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")
|
||||||
|
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveInterval := c.Duration("keepalive-interval")
|
||||||
managerInterval := c.Duration("manager-interval")
|
managerInterval := c.Duration("manager-interval")
|
||||||
globalTopicLimit := c.Int("global-topic-limit")
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
|
smtpSenderPass := c.String("smtp-sender-pass")
|
||||||
|
smtpSenderFrom := c.String("smtp-sender-from")
|
||||||
|
smtpServerListen := c.String("smtp-server-listen")
|
||||||
|
smtpServerDomain := c.String("smtp-server-domain")
|
||||||
|
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
|
||||||
|
totalTopicLimit := c.Int("global-topic-limit")
|
||||||
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
visitorSubscriptionLimit := c.Int("visitor-subscription-limit")
|
||||||
|
visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit")
|
||||||
|
visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit")
|
||||||
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
visitorRequestLimitBurst := c.Int("visitor-request-limit-burst")
|
||||||
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish")
|
||||||
|
visitorRequestLimitExemptHosts := util.SplitNoEmpty(c.String("visitor-request-limit-exempt-hosts"), ",")
|
||||||
|
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||||
|
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
||||||
behindProxy := c.Bool("behind-proxy")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
|
|
||||||
// Check values
|
// Check values
|
||||||
@@ -82,23 +126,96 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("if set, certificate file must exist")
|
return errors.New("if set, certificate file must exist")
|
||||||
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
} else if listenHTTPS != "" && (keyFile == "" || certFile == "") {
|
||||||
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
return errors.New("if listen-https is set, both key-file and cert-file must be set")
|
||||||
|
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderUser == "" || smtpSenderPass == "" || smtpSenderFrom == "") {
|
||||||
|
return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from must also be set")
|
||||||
|
} else if smtpServerListen != "" && smtpServerDomain == "" {
|
||||||
|
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
|
||||||
|
attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if visitorAttachmentDailyBandwidthLimit > math.MaxInt {
|
||||||
|
return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve hosts
|
||||||
|
visitorRequestLimitExemptIPs := make([]string, 0)
|
||||||
|
for _, host := range visitorRequestLimitExemptHosts {
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cannot resolve host %s: %s, ignoring visitor request exemption", host, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ip.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run server
|
// Run server
|
||||||
conf := server.NewConfig()
|
conf := server.NewConfig()
|
||||||
|
conf.BaseURL = baseURL
|
||||||
conf.ListenHTTP = listenHTTP
|
conf.ListenHTTP = listenHTTP
|
||||||
conf.ListenHTTPS = listenHTTPS
|
conf.ListenHTTPS = listenHTTPS
|
||||||
|
conf.ListenUnix = listenUnix
|
||||||
conf.KeyFile = keyFile
|
conf.KeyFile = keyFile
|
||||||
conf.CertFile = certFile
|
conf.CertFile = certFile
|
||||||
conf.FirebaseKeyFile = firebaseKeyFile
|
conf.FirebaseKeyFile = firebaseKeyFile
|
||||||
conf.CacheFile = cacheFile
|
conf.CacheFile = cacheFile
|
||||||
conf.CacheDuration = cacheDuration
|
conf.CacheDuration = cacheDuration
|
||||||
|
conf.AuthFile = authFile
|
||||||
|
conf.AuthDefaultRead = authDefaultRead
|
||||||
|
conf.AuthDefaultWrite = authDefaultWrite
|
||||||
|
conf.AttachmentCacheDir = attachmentCacheDir
|
||||||
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
|
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||||
conf.KeepaliveInterval = keepaliveInterval
|
conf.KeepaliveInterval = keepaliveInterval
|
||||||
conf.ManagerInterval = managerInterval
|
conf.ManagerInterval = managerInterval
|
||||||
conf.GlobalTopicLimit = globalTopicLimit
|
conf.SMTPSenderAddr = smtpSenderAddr
|
||||||
|
conf.SMTPSenderUser = smtpSenderUser
|
||||||
|
conf.SMTPSenderPass = smtpSenderPass
|
||||||
|
conf.SMTPSenderFrom = smtpSenderFrom
|
||||||
|
conf.SMTPServerListen = smtpServerListen
|
||||||
|
conf.SMTPServerDomain = smtpServerDomain
|
||||||
|
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
|
||||||
|
conf.TotalTopicLimit = totalTopicLimit
|
||||||
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
conf.VisitorSubscriptionLimit = visitorSubscriptionLimit
|
||||||
|
conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit
|
||||||
|
conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit)
|
||||||
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
conf.VisitorRequestLimitBurst = visitorRequestLimitBurst
|
||||||
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish
|
||||||
|
conf.VisitorRequestExemptIPAddrs = visitorRequestLimitExemptIPs
|
||||||
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
s, err := server.New(conf)
|
s, err := server.New(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,3 +227,14 @@ func execServe(c *cli.Context) error {
|
|||||||
log.Printf("Exiting.")
|
log.Printf("Exiting.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseSize(s string, defaultValue int64) (v int64, err error) {
|
||||||
|
if s == "" {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
v, err = util.ParseSize(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|||||||
77
cmd/serve_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ var cmdSubscribe = &cli.Command{
|
|||||||
Usage: "Subscribe to one or more topics on a ntfy server",
|
Usage: "Subscribe to one or more topics on a ntfy server",
|
||||||
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
|
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
|
||||||
Action: execSubscribe,
|
Action: execSubscribe,
|
||||||
|
Category: categoryClient,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
&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)"},
|
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||||
@@ -180,7 +181,7 @@ func runCommandInternal(c *cli.Context, command string, m *client.Message) error
|
|||||||
defer os.Remove(scriptFile)
|
defer os.Remove(scriptFile)
|
||||||
verbose := c.Bool("verbose")
|
verbose := c.Bool("verbose")
|
||||||
if verbose {
|
if verbose {
|
||||||
log.Printf("[%s] Executing: %s (for message: %s)", collapseTopicURL(m.TopicURL), command, m.Raw)
|
log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("sh", "-c", scriptFile)
|
cmd := exec.Command("sh", "-c", scriptFile)
|
||||||
cmd.Stdin = c.App.Reader
|
cmd.Stdin = c.App.Reader
|
||||||
|
|||||||
288
cmd/user.go
Normal 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
@@ -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
|
||||||
|
}
|
||||||
618
docs/config.md
@@ -1,6 +1,6 @@
|
|||||||
# Configuring the ntfy server
|
# Configuring the ntfy server
|
||||||
The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`,
|
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.
|
or using environment variables.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
@@ -13,9 +13,58 @@ $ ntfy serve
|
|||||||
|
|
||||||
You can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md),
|
You can immediately start [publishing messages](publish.md), or subscribe via the [Android app](subscribe/phone.md),
|
||||||
[the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure
|
[the web UI](subscribe/web.md), or simply via [curl or your favorite HTTP client](subscribe/api.md). To configure
|
||||||
the server further, check out the [config options table](#config-options) or simply type `ntfy --help` to
|
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).
|
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
|
## Message cache
|
||||||
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
|
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
|
of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve
|
||||||
@@ -35,23 +84,307 @@ the message to the subscribers.
|
|||||||
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the
|
Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the
|
||||||
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
|
[`since=` parameter](subscribe/api.md#fetch-cached-messages).
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable
|
||||||
|
this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`).
|
||||||
|
Once these options are set and the directory is writable by the server user, you can upload attachments via PUT.
|
||||||
|
|
||||||
|
By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues
|
||||||
|
and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download
|
||||||
|
feature) to download the file. The following config options are relevant to attachments:
|
||||||
|
|
||||||
|
* `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs
|
||||||
|
* `attachment-cache-dir` is the cache directory for attached files
|
||||||
|
* `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G)
|
||||||
|
* `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M)
|
||||||
|
* `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h)
|
||||||
|
|
||||||
|
Here's an example config using mostly the defaults (except for the cache directory, which is empty by default):
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (minimal)"
|
||||||
|
``` yaml
|
||||||
|
base-url: "https://ntfy.sh"
|
||||||
|
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml (all options)"
|
||||||
|
``` yaml
|
||||||
|
base-url: "https://ntfy.sh"
|
||||||
|
attachment-cache-dir: "/var/cache/ntfy/attachments"
|
||||||
|
attachment-total-size-limit: "5G"
|
||||||
|
attachment-file-size-limit: "15M"
|
||||||
|
attachment-expiry-duration: "3h"
|
||||||
|
visitor-attachment-total-size-limit: "100M"
|
||||||
|
visitor-attachment-daily-bandwidth-limit: "500M"
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
`curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`).
|
||||||
|
|
||||||
|
As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the
|
||||||
|
following settings:
|
||||||
|
|
||||||
|
* `base-url` is the root URL for the ntfy server; this is needed for e-mail footer
|
||||||
|
* `smtp-sender-addr` is the hostname:port of the SMTP server
|
||||||
|
* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user
|
||||||
|
* `smtp-sender-from` is the e-mail address of the sender
|
||||||
|
|
||||||
|
Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is
|
||||||
|
configured for `ntfy.sh`):
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml"
|
||||||
|
``` yaml
|
||||||
|
base-url: "https://ntfy.sh"
|
||||||
|
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"
|
||||||
|
```
|
||||||
|
|
||||||
|
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
|
||||||
|
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
|
||||||
|
|
||||||
|
## E-mail publishing
|
||||||
|
To allow publishing messages via e-mail, ntfy can run a lightweight **SMTP server for incoming messages**. Once configured,
|
||||||
|
users can [send emails to a topic e-mail address](publish.md#e-mail-publishing) (e.g. `mytopic@ntfy.sh` or
|
||||||
|
`myprefix-mytopic@ntfy.sh`) to publish messages to a topic. This is useful for e-mail based integrations such as for
|
||||||
|
statuspage.io (though these days most services also support webhooks and HTTP calls).
|
||||||
|
|
||||||
|
To configure the SMTP server, you must at least set `smtp-server-listen` and `smtp-server-domain`:
|
||||||
|
|
||||||
|
* `smtp-server-listen` defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25`
|
||||||
|
* `smtp-server-domain` is the e-mail domain, e.g. `ntfy.sh`
|
||||||
|
* `smtp-server-addr-prefix` is an optional prefix for the e-mail addresses to prevent spam. If set to `ntfy-`, for instance,
|
||||||
|
only e-mails to `ntfy-$topic@ntfy.sh` will be accepted. If this is not set, all emails to `$topic@ntfy.sh` will be
|
||||||
|
accepted (which may obviously be a spam problem).
|
||||||
|
|
||||||
|
Here's an example config (this is how it is configured for `ntfy.sh`):
|
||||||
|
|
||||||
|
=== "/etc/ntfy/server.yml"
|
||||||
|
``` yaml
|
||||||
|
smtp-server-listen: ":25"
|
||||||
|
smtp-server-domain: "ntfy.sh"
|
||||||
|
smtp-server-addr-prefix: "ntfy-"
|
||||||
|
```
|
||||||
|
|
||||||
|
In addition to configuring the ntfy server, you have to create two DNS records (an [MX record](https://en.wikipedia.org/wiki/MX_record)
|
||||||
|
and a corresponding A record), so incoming mail will find its way to your server. Here's an example of how `ntfy.sh` is
|
||||||
|
configured (in [Amazon Route 53](https://aws.amazon.com/route53/)):
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=600 }
|
||||||
|
<figcaption>DNS records for incoming mail</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
## Behind a proxy (TLS, etc.)
|
## Behind a proxy (TLS, etc.)
|
||||||
!!! warning
|
!!! warning
|
||||||
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
If you are running ntfy behind a proxy, you must set the `behind-proxy` flag. Otherwise, all visitors are
|
||||||
[rate limited](#rate-limiting) as if they are one.
|
[rate limited](#rate-limiting) as if they are one.
|
||||||
|
|
||||||
It may be desirable to run ntfy behind a proxy, e.g. so you can provide TLS certificates using Let's Encrypt using certbot,
|
It may be desirable to run ntfy behind a proxy (e.g. nginx, HAproxy or Apache), so you can provide TLS certificates
|
||||||
or simply because you'd like to share the ports (80/443) with other services. Whatever your reasons may be, there are a
|
using Let's Encrypt using certbot, or simply because you'd like to share the ports (80/443) with other services.
|
||||||
few things to consider.
|
Whatever your reasons may be, there are a few things to consider.
|
||||||
|
|
||||||
### Rate limiting
|
If you are running ntfy behind a proxy, you should set the `behind-proxy` flag. This will instruct the
|
||||||
If you are running ntfy behind a proxy (e.g. nginx, HAproxy or Apache), you should set the `behind-proxy`
|
[rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary identifier for a visitor,
|
||||||
flag. This will instruct the [rate limiting](#rate-limiting) logic to use the `X-Forwarded-For` header as the primary
|
as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
|
||||||
identifier for a visitor, as opposed to the remote IP address. If the `behind-proxy` flag is not set, all visitors will
|
|
||||||
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
|
be counted as one, because from the perspective of the ntfy server, they all share the proxy's IP address.
|
||||||
|
|
||||||
=== "/etc/ntfy/server.yml"
|
=== "/etc/ntfy/server.yml"
|
||||||
```
|
``` yaml
|
||||||
# Tell ntfy to use "X-Forwarded-For" to identify visitors
|
# Tell ntfy to use "X-Forwarded-For" to identify visitors
|
||||||
behind-proxy: true
|
behind-proxy: true
|
||||||
```
|
```
|
||||||
@@ -65,7 +398,7 @@ which lets you use [AWS Route 53](https://aws.amazon.com/route53/) as the challe
|
|||||||
HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encrypt-in-development-with-nginx-and-aws-route53) to
|
HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encrypt-in-development-with-nginx-and-aws-route53) to
|
||||||
be incredibly helpful.
|
be incredibly helpful.
|
||||||
|
|
||||||
### nginx/Apache2
|
### nginx/Apache2/caddy
|
||||||
For your convenience, here's a working config that'll help configure things behind a proxy. In this
|
For your convenience, here's a working config that'll help configure things behind a proxy. In this
|
||||||
example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic
|
||||||
or the root domain:
|
or the root domain:
|
||||||
@@ -83,7 +416,7 @@ or the root domain:
|
|||||||
if ($request_method = GET) {
|
if ($request_method = GET) {
|
||||||
set $redirect_https "yes";
|
set $redirect_https "yes";
|
||||||
}
|
}
|
||||||
if ($request_uri ~* "^/[-_a-z0-9]{0,64}$") {
|
if ($request_uri ~* "^/([-_a-z0-9]{0,64}$|docs/|static/)") {
|
||||||
set $redirect_https "${redirect_https}yes";
|
set $redirect_https "${redirect_https}yes";
|
||||||
}
|
}
|
||||||
if ($redirect_https = "yesyes") {
|
if ($redirect_https = "yesyes") {
|
||||||
@@ -94,16 +427,19 @@ or the root domain:
|
|||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
proxy_connect_timeout 1m;
|
proxy_connect_timeout 3m;
|
||||||
proxy_send_timeout 1m;
|
proxy_send_timeout 3m;
|
||||||
proxy_read_timeout 1m;
|
proxy_read_timeout 3m;
|
||||||
|
|
||||||
|
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,18 +458,21 @@ or the root domain:
|
|||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:2586;
|
proxy_pass http://127.0.0.1:2586;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
proxy_connect_timeout 1m;
|
proxy_connect_timeout 3m;
|
||||||
proxy_send_timeout 1m;
|
proxy_send_timeout 3m;
|
||||||
proxy_read_timeout 1m;
|
proxy_read_timeout 3m;
|
||||||
|
|
||||||
|
client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -149,7 +488,7 @@ or the root domain:
|
|||||||
ProxyPass / http://127.0.0.1:2586/
|
ProxyPass / http://127.0.0.1:2586/
|
||||||
ProxyPassReverse / http://127.0.0.1:2586/
|
ProxyPassReverse / http://127.0.0.1:2586/
|
||||||
|
|
||||||
# Higher than the max message size of 512k
|
# Higher than the max message size of 4096 bytes
|
||||||
LimitRequestBody 102400
|
LimitRequestBody 102400
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||||
@@ -173,7 +512,7 @@ or the root domain:
|
|||||||
ProxyPass / http://127.0.0.1:2586/
|
ProxyPass / http://127.0.0.1:2586/
|
||||||
ProxyPassReverse / http://127.0.0.1:2586/
|
ProxyPassReverse / http://127.0.0.1:2586/
|
||||||
|
|
||||||
# Higher than the max message size of 512k
|
# Higher than the max message size of 4096 bytes
|
||||||
LimitRequestBody 102400
|
LimitRequestBody 102400
|
||||||
|
|
||||||
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
# Redirect HTTP to HTTPS, but only for GET topic addresses, since we want
|
||||||
@@ -184,6 +523,16 @@ or the root domain:
|
|||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
=== "caddy"
|
||||||
|
```
|
||||||
|
# Note that this config is most certainly incomplete. Please help out and let me know what's missing
|
||||||
|
# via Discord/Matrix or in a GitHub issue.
|
||||||
|
|
||||||
|
ntfy.sh, http://nfty.sh {
|
||||||
|
reverse_proxy 127.0.0.1:2586
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Firebase (FCM)
|
## Firebase (FCM)
|
||||||
!!! info
|
!!! info
|
||||||
Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app).
|
Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app).
|
||||||
@@ -214,30 +563,55 @@ firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json"
|
|||||||
## Rate limiting
|
## Rate limiting
|
||||||
!!! info
|
!!! info
|
||||||
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
Be aware that if you are running ntfy behind a proxy, you must set the `behind-proxy` flag.
|
||||||
Otherwise all visitors are rate limited as if they are one.
|
Otherwise, all visitors are rate limited as if they are one.
|
||||||
|
|
||||||
By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload.
|
By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload.
|
||||||
There are various limits and rate limits in place that you can use to configure the server. Let's do the easy ones first:
|
There are various limits and rate limits in place that you can use to configure the server:
|
||||||
|
|
||||||
* `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 5000.
|
* **Global limit**: A global limit applies across all visitors (IPs, clients, users)
|
||||||
|
* **Visitor limit**: A visitor limit only applies to a certain visitor. A **visitor** is identified by its IP address
|
||||||
|
(or the `X-Forwarded-For` header if `behind-proxy` is set). All config options that start with the word `visitor` apply
|
||||||
|
only on a per-visitor basis.
|
||||||
|
|
||||||
|
During normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails
|
||||||
|
(e.g. when you reconnect after a connection drop), it shouldn't have any effect.
|
||||||
|
|
||||||
|
### General limits
|
||||||
|
Let's do the easy limits first:
|
||||||
|
|
||||||
|
* `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 15,000.
|
||||||
* `visitor-subscription-limit` is the number of subscriptions (open connections) per visitor. This value defaults to 30.
|
* `visitor-subscription-limit` is the number of subscriptions (open connections) per visitor. This value defaults to 30.
|
||||||
|
|
||||||
A **visitor** is identified by its IP address (or the `X-Forwarded-For` header if `behind-proxy` is set). All config
|
### Request limits
|
||||||
options that start with the word `visitor` apply only on a per-visitor basis.
|
|
||||||
|
|
||||||
In addition to the limits above, there is a requests/second limit per visitor for all sensitive GET/PUT/POST requests.
|
In addition to the limits above, there is a requests/second limit per visitor for all sensitive GET/PUT/POST requests.
|
||||||
This limit uses a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) (using Go's [rate package](https://pkg.go.dev/golang.org/x/time/rate)):
|
This limit uses a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) (using Go's [rate package](https://pkg.go.dev/golang.org/x/time/rate)):
|
||||||
|
|
||||||
Each visitor has a bucket of 60 requests they can fire against the server (defined by `visitor-request-limit-burst`).
|
Each visitor has a bucket of 60 requests they can fire against the server (defined by `visitor-request-limit-burst`).
|
||||||
After the 60, new requests will encounter a `429 Too Many Requests` response. The visitor request bucket is refilled at a rate of one
|
After the 60, new requests will encounter a `429 Too Many Requests` response. The visitor request bucket is refilled at a rate of one
|
||||||
request every 10s (defined by `visitor-request-limit-replenish`)
|
request every 5s (defined by `visitor-request-limit-replenish`)
|
||||||
|
|
||||||
* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.
|
* `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60.
|
||||||
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s.
|
* `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 5s.
|
||||||
|
* `visitor-request-limit-exempt-hosts` is a comma-separated list of hostnames and IPs to be exempt from request rate
|
||||||
|
limiting; hostnames are resolved at the time the server is started. Defaults to an empty list.
|
||||||
|
|
||||||
|
### Attachment limits
|
||||||
|
Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant
|
||||||
|
per-visitor limits:
|
||||||
|
|
||||||
During normal usage, you shouldn't encounter this limit at all, and even if you burst a few requests shortly (e.g. when you
|
* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M.
|
||||||
reconnect after a connection drop), it shouldn't have any effect.
|
The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`,
|
||||||
|
see [publishing docs](publish.md#attachments)) do not count here.
|
||||||
|
* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor,
|
||||||
|
including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in
|
||||||
|
most cloud providers. This defaults to 500M.
|
||||||
|
|
||||||
|
### E-mail limits
|
||||||
|
Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications)
|
||||||
|
are enabled):
|
||||||
|
|
||||||
|
* `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16.
|
||||||
|
* `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h.
|
||||||
|
|
||||||
## Tuning for scale
|
## Tuning for scale
|
||||||
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
|
If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config,
|
||||||
@@ -249,7 +623,7 @@ Depending on *how you run it*, here are a few limits that are relevant:
|
|||||||
|
|
||||||
### For systemd services
|
### For systemd services
|
||||||
If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the
|
If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the
|
||||||
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10000. You can override it
|
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it
|
||||||
by creating a `/etc/systemd/system/ntfy.service.d/override.conf` file. As far as I can tell, `/etc/security/limits.conf`
|
by creating a `/etc/systemd/system/ntfy.service.d/override.conf` file. As far as I can tell, `/etc/security/limits.conf`
|
||||||
is not relevant.
|
is not relevant.
|
||||||
|
|
||||||
@@ -262,7 +636,7 @@ is not relevant.
|
|||||||
|
|
||||||
### Outside of systemd
|
### Outside of systemd
|
||||||
If you're running outside systemd, you may want to adjust your `/etc/security/limits.conf` file to
|
If you're running outside systemd, you may want to adjust your `/etc/security/limits.conf` file to
|
||||||
increase the `nofile` setting. Here's an example that increases the limit to 5000. You can find out the current setting
|
increase the `nofile` setting. Here's an example that increases the limit to 5,000. You can find out the current setting
|
||||||
by running `ulimit -n`, or manually override it temporarily by running `ulimit -n 50000`.
|
by running `ulimit -n`, or manually override it temporarily by running `ulimit -n 50000`.
|
||||||
|
|
||||||
=== "/etc/security/limits.conf"
|
=== "/etc/security/limits.conf"
|
||||||
@@ -285,6 +659,7 @@ to maintain the client connection and the connection to ntfy.
|
|||||||
worker_connections 40500;
|
worker_connections 40500;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "/etc/systemd/system/nginx.service.d/override.conf"
|
=== "/etc/systemd/system/nginx.service.d/override.conf"
|
||||||
```
|
```
|
||||||
# Allow 40,000 proxy connections (2x of the desired ntfy connection count;
|
# Allow 40,000 proxy connections (2x of the desired ntfy connection count;
|
||||||
@@ -293,56 +668,153 @@ to maintain the client connection and the connection to ntfy.
|
|||||||
LimitNOFILE=40500
|
LimitNOFILE=40500
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Banning bad actors (fail2ban)
|
||||||
|
If you put stuff on the Internet, bad actors will try to break them or break in. [fail2ban](https://www.fail2ban.org/)
|
||||||
|
and nginx's [ngx_http_limit_req_module module](http://nginx.org/en/docs/http/ngx_http_limit_req_module.html) can be used
|
||||||
|
to ban client IPs if they misbehave. This is on top of the [rate limiting](#rate-limiting) inside the ntfy server.
|
||||||
|
|
||||||
|
Here's an example for how ntfy.sh is configured, following the instructions from two tutorials ([here](https://easyengine.io/tutorials/nginx/fail2ban/)
|
||||||
|
and [here](https://easyengine.io/tutorials/nginx/block-wp-login-php-bruteforce-attack/)):
|
||||||
|
|
||||||
|
=== "/etc/nginx/nginx.conf"
|
||||||
|
```
|
||||||
|
http {
|
||||||
|
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/nginx/sites-enabled/ntfy.sh"
|
||||||
|
```
|
||||||
|
# For each server/location block
|
||||||
|
server {
|
||||||
|
location / {
|
||||||
|
limit_req zone=one burst=1000 nodelay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/fail2ban/filter.d/nginx-req-limit.conf"
|
||||||
|
```
|
||||||
|
[Definition]
|
||||||
|
failregex = limiting requests, excess:.* by zone.*client: <HOST>
|
||||||
|
ignoreregex =
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "/etc/fail2ban/jail.local"
|
||||||
|
```
|
||||||
|
[nginx-req-limit]
|
||||||
|
enabled = true
|
||||||
|
filter = nginx-req-limit
|
||||||
|
action = iptables-multiport[name=ReqLimit, port="http,https", protocol=tcp]
|
||||||
|
logpath = /var/log/nginx/error.log
|
||||||
|
findtime = 600
|
||||||
|
bantime = 7200
|
||||||
|
maxretry = 10
|
||||||
|
```
|
||||||
|
|
||||||
## Config options
|
## Config options
|
||||||
Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a
|
Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `listen-http: :80`) or as a
|
||||||
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
|
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`).
|
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||||
|
|
||||||
| Config option | Env variable | Format | Default | Description |
|
| Config option | Env variable | Format | Default | Description |
|
||||||
|---|---|---|---|---|
|
|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
|
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
|
||||||
| `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-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
|
||||||
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
| `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`. |
|
||||||
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
||||||
| `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). |
|
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
|
||||||
| `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). |
|
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
|
||||||
| `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. |
|
| `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). |
|
||||||
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 30s | 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. |
|
| `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). |
|
||||||
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
|
| `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. |
|
||||||
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 5000 | Rate limiting: Total number of topics before the server rejects new topics. |
|
| `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). |
|
||||||
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
| `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`. |
|
||||||
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
|
| `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. |
|
||||||
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
|
||||||
| `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-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* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||||
|
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||||
|
| `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 *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||||
|
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||||
|
|
||||||
## Command line options
|
## Command line options
|
||||||
```
|
```
|
||||||
$ ntfy --help
|
$ ntfy serve --help
|
||||||
NAME:
|
NAME:
|
||||||
ntfy - Simple pub-sub notification service
|
ntfy serve - Run the ntfy server
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
ntfy [OPTION..]
|
ntfy serve [OPTIONS..]
|
||||||
|
|
||||||
GLOBAL OPTIONS:
|
CATEGORY:
|
||||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
Server commands
|
||||||
--listen-http value, -l value ip:port used to as listen address (default: ":80") [$NTFY_LISTEN_HTTP]
|
|
||||||
--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]
|
|
||||||
--keepalive-interval value, -k value interval of keepalive messages (default: 30s) [$NTFY_KEEPALIVE_INTERVAL]
|
|
||||||
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
|
||||||
--global-topic-limit value, -T value total number of topics allowed (default: 5000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
|
||||||
--visitor-subscription-limit value, -V value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
|
||||||
--visitor-request-limit-burst value, -B value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
|
||||||
--visitor-request-limit-replenish value, -R value interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
|
||||||
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
|
||||||
|
|
||||||
Try 'ntfy COMMAND --help' for more information.
|
DESCRIPTION:
|
||||||
|
Run the ntfy server and listen for incoming requests
|
||||||
|
|
||||||
|
The command will load the configuration from /etc/ntfy/server.yml. Config options can
|
||||||
|
be overridden using the command line options.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
ntfy serve # Starts server in the foreground (on port 80)
|
||||||
|
ntfy serve --listen-http :8080 # Starts server with alternate port
|
||||||
|
|
||||||
ntfy v1.4.8 (7b8185c), runtime go1.17, built at 1637872539
|
OPTIONS:
|
||||||
Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0
|
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||||
|
--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: 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]
|
||||||
|
--smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS]
|
||||||
|
--smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM]
|
||||||
|
--smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN]
|
||||||
|
--smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN]
|
||||||
|
--smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX]
|
||||||
|
--global-topic-limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT]
|
||||||
|
--visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
|
||||||
|
--visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
|
||||||
|
--visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
|
||||||
|
--visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
|
||||||
|
--visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 5s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
|
||||||
|
--visitor-request-limit-exempt-hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
|
||||||
|
--visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST]
|
||||||
|
--visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH]
|
||||||
|
--behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||||
|
--help, -h show help (default: false)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_x86_64.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||||
sudo ./ntfy serve
|
sudo ./ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_armv7.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||||
sudo ./ntfy serve
|
sudo ./ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_arm64.tar.gz
|
||||||
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
|
||||||
sudo ./ntfy serve
|
sudo ./ntfy serve
|
||||||
```
|
```
|
||||||
@@ -88,7 +88,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_amd64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -96,7 +96,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_armv7.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -104,7 +104,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_arm64.deb
|
||||||
sudo dpkg -i ntfy_*.deb
|
sudo dpkg -i ntfy_*.deb
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
@@ -114,25 +114,39 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_armv7.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.8.0/ntfy_1.8.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Arch Linux
|
||||||
|
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/). You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download, build and install ntfy and keep it up to date.
|
||||||
|
```
|
||||||
|
paru -S ntfysh-bin
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, run the following commands to install ntfy manually:
|
||||||
|
```
|
||||||
|
curl https://aur.archlinux.org/cgit/aur.git/snapshot/ntfysh-bin.tar.gz | tar xzv
|
||||||
|
cd ntfysh-bin
|
||||||
|
makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv7 and arm64. It should be pretty
|
||||||
straight forward to use.
|
straight forward to use.
|
||||||
@@ -167,6 +181,14 @@ docker run \
|
|||||||
serve
|
serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alternatively, you may wish to build a customized Docker image that can be run with fewer command-line arguments and without delivering the configuration file separately.
|
||||||
|
```
|
||||||
|
FROM binwiederhier/ntfy
|
||||||
|
COPY server.yml /etc/ntfy/server.yml
|
||||||
|
ENTRYPOINT ["ntfy", "serve"]
|
||||||
|
```
|
||||||
|
This image can be pushed to a container registry and shipped independently. All that's needed when running it is mapping ntfy's port to a host port.
|
||||||
|
|
||||||
## Go
|
## Go
|
||||||
To install via Go, simply run:
|
To install via Go, simply run:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
490
docs/publish.md
@@ -592,8 +592,430 @@ Here's an example with a custom message, tags and a priority:
|
|||||||
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
|
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Click action
|
||||||
|
You can define which URL to open when a notification is clicked. This may be useful if your notification is related
|
||||||
|
to a Zabbix alert or a transaction that you'd like to provide the deep-link for. Tapping the notification will open
|
||||||
|
the web browser (or the app) and open the website.
|
||||||
|
|
||||||
|
Here's an example that will open Reddit when the notification is clicked:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-d "New messages on Reddit" \
|
||||||
|
-H "Click: https://www.reddit.com/message/messages" \
|
||||||
|
ntfy.sh/reddit_alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--click="https://www.reddit.com/message/messages" \
|
||||||
|
reddit_alerts "New messages on Reddit"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /reddit_alerts HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Click: https://www.reddit.com/message/messages
|
||||||
|
|
||||||
|
New messages on Reddit
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/reddit_alerts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'New messages on Reddit',
|
||||||
|
headers: { 'Click': 'https://www.reddit.com/message/messages' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/reddit_alerts", strings.NewReader("New messages on Reddit"))
|
||||||
|
req.Header.Set("Click", "https://www.reddit.com/message/messages")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/reddit_alerts",
|
||||||
|
data="New messages on Reddit",
|
||||||
|
headers={ "Click": "https://www.reddit.com/message/messages" })
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' =>
|
||||||
|
"Content-Type: text/plain\r\n" .
|
||||||
|
"Click: https://www.reddit.com/message/messages",
|
||||||
|
'content' => 'New messages on Reddit'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Attachments
|
||||||
|
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
|
||||||
|
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
|
||||||
|
|
||||||
|
There are two different ways to send attachments:
|
||||||
|
|
||||||
|
* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3`
|
||||||
|
* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk`
|
||||||
|
|
||||||
|
### Attach local file
|
||||||
|
To **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater
|
||||||
|
than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically
|
||||||
|
detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files
|
||||||
|
as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases
|
||||||
|
`Filename`, `File` or `f`).
|
||||||
|
|
||||||
|
By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor).
|
||||||
|
Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app
|
||||||
|
to auto-download it. Please also check out the [other limits below](#limitations).
|
||||||
|
|
||||||
|
Here's an example showing how to upload an image:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-T flower.jpg \
|
||||||
|
-H "Filename: flower.jpg" \
|
||||||
|
ntfy.sh/flowers
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--file=flower.jpg \
|
||||||
|
flowers
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
PUT /flowers HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Filename: flower.jpg
|
||||||
|
Content-Type: 52312
|
||||||
|
|
||||||
|
<binary JPEG data>
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/flowers', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: document.getElementById("file").files[0],
|
||||||
|
headers: { 'Filename': 'flower.jpg' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
file, _ := os.Open("flower.jpg")
|
||||||
|
req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file)
|
||||||
|
req.Header.Set("Filename", "flower.jpg")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.put("https://ntfy.sh/flowers",
|
||||||
|
data=open("flower.jpg", 'rb'),
|
||||||
|
headers={ "Filename": "flower.jpg" })
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'PUT',
|
||||||
|
'header' =>
|
||||||
|
"Content-Type: application/octet-stream\r\n" . // Does not matter
|
||||||
|
"Filename: flower.jpg",
|
||||||
|
'content' => file_get_contents('flower.jpg') // Dangerous for large files
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's what that looks like on Android:
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=500 }
|
||||||
|
<figcaption>Image attachment sent from a local file</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
### 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 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 attach an APK file:
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-X POST \
|
||||||
|
-H "Attach: https://f-droid.org/F-Droid.apk" \
|
||||||
|
ntfy.sh/mydownloads
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--attach="https://f-droid.org/F-Droid.apk" \
|
||||||
|
mydownloads
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /mydownloads HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Attach: https://f-droid.org/F-Droid.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/mydownloads', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file)
|
||||||
|
req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.put("https://ntfy.sh/mydownloads",
|
||||||
|
headers={ "Attach": "https://f-droid.org/F-Droid.apk" })
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'PUT',
|
||||||
|
'header' =>
|
||||||
|
"Content-Type: text/plain\r\n" . // Does not matter
|
||||||
|
"Attach: https://f-droid.org/F-Droid.apk",
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=500 }
|
||||||
|
<figcaption>File attachment sent from an external URL</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
## E-mail notifications
|
||||||
|
You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that
|
||||||
|
you'd like to persist longer, or to blast-notify yourself on all possible channels.
|
||||||
|
|
||||||
|
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
|
||||||
|
Only one e-mail address is supported.
|
||||||
|
|
||||||
|
Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the
|
||||||
|
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
|
||||||
|
that, your IP address appears in the e-mail body. This is to prevent abuse.
|
||||||
|
|
||||||
|
=== "Command line (curl)"
|
||||||
|
```
|
||||||
|
curl \
|
||||||
|
-H "Email: phil@example.com" \
|
||||||
|
-H "Tags: warning,skull,backup-host,ssh-login" \
|
||||||
|
-H "Priority: high" \
|
||||||
|
-d "Unknown login from 5.31.23.83 to backups.example.com" \
|
||||||
|
ntfy.sh/alerts
|
||||||
|
curl -H "Email: phil@example.com" -d "You've Got Mail"
|
||||||
|
curl -d "You've Got Mail" "ntfy.sh/alerts?email=phil@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "ntfy CLI"
|
||||||
|
```
|
||||||
|
ntfy publish \
|
||||||
|
--email=phil@example.com \
|
||||||
|
--tags=warning,skull,backup-host,ssh-login \
|
||||||
|
--priority=high \
|
||||||
|
alerts "Unknown login from 5.31.23.83 to backups.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "HTTP"
|
||||||
|
``` http
|
||||||
|
POST /alerts HTTP/1.1
|
||||||
|
Host: ntfy.sh
|
||||||
|
Email: phil@example.com
|
||||||
|
Tags: warning,skull,backup-host,ssh-login
|
||||||
|
Priority: high
|
||||||
|
|
||||||
|
Unknown login from 5.31.23.83 to backups.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "JavaScript"
|
||||||
|
``` javascript
|
||||||
|
fetch('https://ntfy.sh/alerts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: "Unknown login from 5.31.23.83 to backups.example.com",
|
||||||
|
headers: {
|
||||||
|
'Email': 'phil@example.com',
|
||||||
|
'Tags': 'warning,skull,backup-host,ssh-login',
|
||||||
|
'Priority': 'high'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Go"
|
||||||
|
``` go
|
||||||
|
req, _ := http.NewRequest("POST", "https://ntfy.sh/alerts",
|
||||||
|
strings.NewReader("Unknown login from 5.31.23.83 to backups.example.com"))
|
||||||
|
req.Header.Set("Email", "phil@example.com")
|
||||||
|
req.Header.Set("Tags", "warning,skull,backup-host,ssh-login")
|
||||||
|
req.Header.Set("Priority", "high")
|
||||||
|
http.DefaultClient.Do(req)
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "Python"
|
||||||
|
``` python
|
||||||
|
requests.post("https://ntfy.sh/alerts",
|
||||||
|
data="Unknown login from 5.31.23.83 to backups.example.com",
|
||||||
|
headers={
|
||||||
|
"Email": "phil@example.com",
|
||||||
|
"Tags": "warning,skull,backup-host,ssh-login",
|
||||||
|
"Priority": "high"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "PHP"
|
||||||
|
``` php-inline
|
||||||
|
file_get_contents('https://ntfy.sh/alerts', false, stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' =>
|
||||||
|
"Content-Type: text/plain\r\n" .
|
||||||
|
"Email: phil@example.com\r\n" .
|
||||||
|
"Tags: warning,skull,backup-host,ssh-login\r\n" .
|
||||||
|
"Priority: high",
|
||||||
|
'content' => 'Unknown login from 5.31.23.83 to backups.example.com'
|
||||||
|
]
|
||||||
|
]));
|
||||||
|
```
|
||||||
|
|
||||||
|
Here's what that looks like in Google Mail:
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=600 }
|
||||||
|
<figcaption>E-mail notification</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
## E-mail publishing
|
||||||
|
You can publish messages to a topic via e-mail, i.e. by sending an email to a specific address. For instance, you can
|
||||||
|
publish a message to the topic `sometopic` by sending an e-mail to `ntfy-sometopic@ntfy.sh`. This is useful for e-mail
|
||||||
|
based integrations such as for statuspage.io (though these days most services also support webhooks and HTTP calls).
|
||||||
|
|
||||||
|
Depending on the [server configuration](config.md#e-mail-publishing), the e-mail address format can have a prefix to
|
||||||
|
prevent spam on topics. For ntfy.sh, the prefix is configured to `ntfy-`, meaning that the general e-mail address
|
||||||
|
format is:
|
||||||
|
|
||||||
|
```
|
||||||
|
ntfy-$topic@ntfy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
As of today, e-mail publishing only supports adding a [message title](#message-title) (the e-mail subject). Tags, priority,
|
||||||
|
delay and other features are not supported (yet). Here's an example that will publish a message with the
|
||||||
|
title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://ntfy.sh/sometopic)):
|
||||||
|
|
||||||
|
<figure markdown>
|
||||||
|
{ width=500 }
|
||||||
|
<figcaption>Publishing a message via e-mail</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
## Advanced features
|
## 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
|
### Message caching
|
||||||
!!! info
|
!!! info
|
||||||
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
|
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
|
||||||
@@ -739,27 +1161,63 @@ to `no`. This will instruct the server not to forward messages to Firebase.
|
|||||||
]));
|
]));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### UnifiedPush
|
||||||
|
!!! info
|
||||||
|
This setting is not relevant to users, only to app developers and people interested in [UnifiedPush](https://unifiedpush.org).
|
||||||
|
|
||||||
|
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
||||||
|
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
||||||
|
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
## Public topics
|
||||||
|
Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics
|
||||||
|
that you can use to try out what [authentication and access control](#authentication) looks like.
|
||||||
|
|
||||||
|
| Topic | User | Permissions | Description |
|
||||||
|
|------------------------------------------------|-----------------------------------|------------------------------------------------------|--------------------------------------|
|
||||||
|
| [announcements](https://ntfy.sh/announcements) | `*` (unauthenticated) | Read-only for everyone | Release announcements and such |
|
||||||
|
| [stats](https://ntfy.sh/stats) | `*` (unauthenticated) | Read-only for everyone | Daily statistics about ntfy.sh usage |
|
||||||
|
| [mytopic-rw](https://ntfy.sh/mytopic-rw) | `testuser` (password: `testuser`) | Read-write for `testuser`, no access for anyone else | Test topic |
|
||||||
|
| [mytopic-ro](https://ntfy.sh/mytopic-ro) | `testuser` (password: `testuser`) | Read-only for `testuser`, no access for anyone else | Test topic |
|
||||||
|
| [mytopic-wo](https://ntfy.sh/mytopic-wo) | `testuser` (password: `testuser`) | Write-only for `testuser`, no access for anyone else | Test topic |
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
There are a few limitations to the API to prevent abuse and to keep the server healthy. Most of them you won't run into,
|
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:
|
but just in case, let's list them all:
|
||||||
|
|
||||||
| Limit | Description |
|
| Limit | Description |
|
||||||
|---|---|
|
|----------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| **Message length** | Each message can be up to 512 bytes long. Longer messages are truncated. |
|
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
|
||||||
| **Requests per second** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. |
|
| **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 5 seconds. |
|
||||||
| **Subscription limits** | By default, the server allows each visitor to keep 30 connections to the server open. |
|
| **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. |
|
||||||
| **Total number of topics** | By default, the server is configured to allow 5,000 topics. The ntfy.sh server has higher limits though. |
|
| **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
|
## 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**,
|
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.
|
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 |
|
| Parameter | Aliases (case-insensitive) | Description |
|
||||||
|---|---|---|
|
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
|
||||||
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
|
||||||
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
|
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
|
||||||
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
|
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
|
||||||
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
|
| `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-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
|
||||||
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
|
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
|
||||||
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
|
| `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 |
|
||||||
|
|||||||
6
docs/static/css/extra.css
vendored
@@ -8,6 +8,12 @@
|
|||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-typeset h4 {
|
||||||
|
font-weight: 500 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
font-size: 1.1em !important;
|
||||||
|
}
|
||||||
|
|
||||||
.admonition {
|
.admonition {
|
||||||
font-size: .74rem !important;
|
font-size: .74rem !important;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
docs/static/img/android-screenshot-attachment-file.png
vendored
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/static/img/android-screenshot-attachment-image.png
vendored
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
docs/static/img/android-screenshot-unifiedpush-fluffychat.jpg
vendored
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
docs/static/img/android-screenshot-unifiedpush-settings.jpg
vendored
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/static/img/android-screenshot-unifiedpush-subscription.jpg
vendored
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/static/img/screenshot-email-publishing-dns.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/static/img/screenshot-email-publishing-gmail.png
vendored
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/static/img/screenshot-email.png
vendored
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -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
|
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).
|
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
|
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:
|
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)
|
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
|
* [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
|
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
|
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.
|
[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);
|
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
|
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
|
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).
|
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
|
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),
|
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
|
[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);
|
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
|
## Advanced features
|
||||||
|
|
||||||
### Poll for messages
|
### Poll for messages
|
||||||
@@ -229,12 +278,12 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
|
|||||||
|
|
||||||
Available filters (all case-insensitive):
|
Available filters (all case-insensitive):
|
||||||
|
|
||||||
| Filter variable | Alias | Example | Description |
|
| Filter variable | Alias | Example | Description |
|
||||||
|---|---|---|---|
|
|-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
|
||||||
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
|
| `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 |
|
| `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) |
|
| `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) |
|
| `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
|
### Subscribe to multiple topics
|
||||||
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of 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"}
|
{"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
|
## JSON message format
|
||||||
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
|
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:
|
format of the message. It's very straight forward:
|
||||||
|
|
||||||
| Field | Required | Type | Example | Description |
|
**Message**:
|
||||||
|---|---|---|---|---|
|
|
||||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
| Field | Required | Type | Example | Description |
|
||||||
| `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` |
|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||||
| `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 |
|
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
| `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 |
|
||||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `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:
|
Here's an example for each message type:
|
||||||
|
|
||||||
=== "Notification message"
|
=== "Notification message"
|
||||||
``` json
|
``` json
|
||||||
{
|
{
|
||||||
"id": "wze9zgqK41",
|
"id": "sPs71M8A2T",
|
||||||
"time": 1638542110,
|
"time": 1643935928,
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"topic": "phil_alerts",
|
"topic": "mytopic",
|
||||||
"priority": 5,
|
"priority": 5,
|
||||||
"tags": [
|
"tags": [
|
||||||
"warning",
|
"warning",
|
||||||
"skull"
|
"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",
|
"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
|
## 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.
|
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 |
|
| Parameter | Aliases (case-insensitive) | Description |
|
||||||
|---|---|---|
|
|-------------|----------------------------|---------------------------------------------------------------------------------|
|
||||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `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) |
|
| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) |
|
||||||
|
|||||||
@@ -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
|
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:
|
in double-quotes, you should be fine:
|
||||||
|
|
||||||
| Variable | Aliases | Description |
|
| Variable | Aliases | Description |
|
||||||
|---|---|---
|
|------------------|----------------------------|----------------------------------------|
|
||||||
| `$NTFY_ID` | `$id` | Unique message ID |
|
| `$NTFY_ID` | `$id` | Unique message ID |
|
||||||
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
|
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
|
||||||
| `$NTFY_TOPIC` | `$topic` | Topic name |
|
| `$NTFY_TOPIC` | `$topic` | Topic name |
|
||||||
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
|
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
|
||||||
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
|
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
|
||||||
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
|
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
|
||||||
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
|
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
|
||||||
| `$NTFY_RAW` | `$raw` | Raw JSON message |
|
| `$NTFY_RAW` | `$raw` | Raw JSON message |
|
||||||
|
|
||||||
### Subscribe to multiple topics
|
### Subscribe to multiple topics
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -81,11 +81,28 @@ The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in
|
|||||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
|
|
||||||
|
### UnifiedPush
|
||||||
|
[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned
|
||||||
|
[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications
|
||||||
|
in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it.
|
||||||
|
|
||||||
|
To use ntfy as a distributor, simply select it in one of the [supported apps](https://unifiedpush.org/users/apps/).
|
||||||
|
That's it. It's a one-step installation 😀. If desired, you can select your own [selfhosted ntfy server](../install.md)
|
||||||
|
to handle messages. Here's an example with [FluffyChat](https://fluffychat.im/):
|
||||||
|
|
||||||
|
<div id="unifiedpush-screenshots" class="screenshots">
|
||||||
|
<a href="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"><img src="../../static/img/android-screenshot-unifiedpush-fluffychat.jpg"/></a>
|
||||||
|
<a href="../../static/img/android-screenshot-unifiedpush-subscription.jpg"><img src="../../static/img/android-screenshot-unifiedpush-subscription.jpg"/></a>
|
||||||
|
<a href="../../static/img/android-screenshot-unifiedpush-settings.jpg"><img src="../../static/img/android-screenshot-unifiedpush-settings.jpg"/></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Automation apps
|
||||||
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
The ntfy Android app integrates nicely with automation apps such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
or [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm). Using Android intents, you can
|
||||||
**react to incoming messages**, as well as **send messages**.
|
**react to incoming messages**, as well as **send messages**.
|
||||||
|
|
||||||
### React to incoming messages
|
#### React to incoming messages
|
||||||
To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see
|
To react on incoming notifications, you have to register to intents with the `io.heckel.ntfy.MESSAGE_RECEIVED` action (see
|
||||||
[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)).
|
[code for details](https://github.com/binwiederhier/ntfy-android/blob/main/app/src/main/java/io/heckel/ntfy/msg/BroadcastService.kt)).
|
||||||
Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
Here's an example using [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
@@ -127,7 +144,7 @@ Here's a list of extras you can access. Most likely, you'll want to filter for `
|
|||||||
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
| `tags_map` | *string* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||||
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
| `priority` | *int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||||
|
|
||||||
### Send messages using intents
|
#### Send messages using intents
|
||||||
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||||
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can
|
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm)), you can
|
||||||
broadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP
|
broadcast an intent with the `io.heckel.ntfy.SEND_MESSAGE` action. The ntfy Android app will forward the intent as a HTTP
|
||||||
|
|||||||
33
go.mod
@@ -4,45 +4,48 @@ go 1.17
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.6.1 // indirect
|
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
|
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/cpuguy83/go-md2man/v2 v2.0.1 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.9
|
github.com/emersion/go-smtp v0.15.0
|
||||||
|
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/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/urfave/cli/v2 v2.3.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/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
|
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
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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/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/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/envoyproxy/go-control-plane v0.10.1 // indirect
|
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
|
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // 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/googleapis/gax-go/v2 v2.1.1 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
go.opencensus.io v0.23.0 // indirect
|
go.opencensus.io v0.23.0 // indirect
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // 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/text v0.3.7 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
|
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f // indirect
|
||||||
google.golang.org/grpc v1.43.0 // indirect
|
google.golang.org/grpc v1.44.0 // indirect
|
||||||
google.golang.org/protobuf v1.27.1 // indirect
|
google.golang.org/protobuf v1.27.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
92
go.sum
@@ -25,18 +25,25 @@ cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aD
|
|||||||
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
||||||
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
||||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
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.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.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.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.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
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.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/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.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.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/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 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
||||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
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.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.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
@@ -46,27 +53,22 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
|
|||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
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.19.0 h1:XOQSnPJD8hRtZJ3VdCyK0mBZsGGImrzPAMbSWcHSe6Q=
|
||||||
cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM=
|
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=
|
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 h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
|
||||||
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
|
||||||
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
||||||
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
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 v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
|
||||||
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
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/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/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.3.0 h1:t/LhUZLVitR1Ow2YOnduCsavhwFUklBMoGVYUCqmCqk=
|
|
||||||
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
|
||||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
|
||||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
|
||||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
@@ -74,21 +76,22 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
|
|||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
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-20210930031921-04548b0d99d4/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-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-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
|
||||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
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/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.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 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-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=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
@@ -97,11 +100,9 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m
|
|||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
|
||||||
github.com/envoyproxy/go-control-plane v0.10.1 h1:cgDRLG7bs59Zd+apAWuzLQL95obVYAymNJek76W3mgw=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
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/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
|
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
@@ -153,8 +154,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
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.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 h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
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=
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
@@ -176,37 +178,34 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe
|
|||||||
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
|
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 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
|
||||||
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
|
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/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.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
|
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
|
||||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
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/mattn/go-sqlite3 v1.14.11 h1:gt+cp9c0XGqe9S/wAHTL3n/7MqY+siPWgWJgqdsFrzQ=
|
||||||
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
|
github.com/mattn/go-sqlite3 v1.14.11/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.9/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 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
|
||||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
|
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=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
@@ -217,8 +216,6 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
|
|||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
|
|
||||||
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
@@ -245,8 +242,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe
|
|||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
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-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/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-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
@@ -283,7 +280,6 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -319,6 +315,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
|
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c=
|
||||||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
@@ -349,6 +346,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-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-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-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/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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -390,18 +388,22 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210917161153-d61c044b1678/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
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-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/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/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 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.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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@@ -502,11 +504,14 @@ google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6
|
|||||||
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
||||||
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
|
||||||
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
|
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
|
||||||
google.golang.org/api v0.58.0/go.mod h1:cAbP2FsxoGVNwtgNAmmn3y5G1TWAiVYRmg4yku3lv+E=
|
|
||||||
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
|
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
|
||||||
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
|
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.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/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.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.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
@@ -570,15 +575,22 @@ google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEc
|
|||||||
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||||
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||||
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
||||||
google.golang.org/genproto v0.0.0-20210917145530-b395a37504d4/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
|
|
||||||
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211016002631-37fc39342514/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
|
||||||
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
||||||
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
|
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-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/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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
@@ -605,8 +617,8 @@ google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnD
|
|||||||
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
|
||||||
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
||||||
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
|
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.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg=
|
||||||
google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
|
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/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-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
|||||||
@@ -4,35 +4,34 @@ set -e
|
|||||||
# Restart systemd service if it was already running. Note that "deb-systemd-invoke try-restart" will
|
# Restart systemd service if it was already running. Note that "deb-systemd-invoke try-restart" will
|
||||||
# only act if the service is already running. If it's not running, it's a no-op.
|
# only act if the service is already running. If it's not running, it's a no-op.
|
||||||
#
|
#
|
||||||
# TODO: This is only tested on Debian.
|
if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then
|
||||||
#
|
if [ -d /run/systemd/system ]; then
|
||||||
if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then
|
# Create ntfy user/group
|
||||||
# Create ntfy user/group
|
id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy
|
||||||
id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy
|
chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy
|
||||||
chown ntfy.ntfy /var/cache/ntfy
|
chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy
|
||||||
chmod 700 /var/cache/ntfy
|
|
||||||
|
|
||||||
# Hack to change permissions on cache file
|
# Hack to change permissions on cache file
|
||||||
configfile="/etc/ntfy/server.yml"
|
configfile="/etc/ntfy/server.yml"
|
||||||
if [ -f "$configfile" ]; then
|
if [ -f "$configfile" ]; then
|
||||||
cachefile="$(cat "$configfile" | perl -n -e'/^\s*cache-file: ["'"'"']?([^"'"'"']+)["'"'"']?/ && print $1')" # Oh my, see #47
|
cachefile="$(cat "$configfile" | perl -n -e'/^\s*cache-file: ["'"'"']?([^"'"'"']+)["'"'"']?/ && print $1')" # Oh my, see #47
|
||||||
if [ -n "$cachefile" ]; then
|
if [ -n "$cachefile" ]; then
|
||||||
chown ntfy.ntfy "$cachefile" || true
|
chown ntfy.ntfy "$cachefile" || true
|
||||||
chmod 600 "$cachefile" || true
|
chmod 600 "$cachefile" || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
systemctl --system daemon-reload >/dev/null || true
|
systemctl --system daemon-reload >/dev/null || true
|
||||||
if systemctl is-active -q ntfy.service; then
|
if systemctl is-active -q ntfy.service; then
|
||||||
echo "Restarting ntfy.service ..."
|
echo "Restarting ntfy.service ..."
|
||||||
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
||||||
deb-systemd-invoke try-restart ntfy.service >/dev/null || true
|
deb-systemd-invoke try-restart ntfy.service >/dev/null || true
|
||||||
else
|
else
|
||||||
systemctl restart ntfy.service >/dev/null || true
|
systemctl restart ntfy.service >/dev/null || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
if systemctl is-active -q ntfy-client.service; then
|
||||||
if systemctl is-active -q ntfy-client.service; then
|
|
||||||
echo "Restarting ntfy-client.service ..."
|
echo "Restarting ntfy-client.service ..."
|
||||||
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
||||||
deb-systemd-invoke try-restart ntfy-client.service >/dev/null || true
|
deb-systemd-invoke try-restart ntfy-client.service >/dev/null || true
|
||||||
@@ -40,4 +39,5 @@ if [ "$1" = "configure" ] && [ -d /run/systemd/system ]; then
|
|||||||
systemctl restart ntfy-client.service >/dev/null || true
|
systemctl restart ntfy-client.service >/dev/null || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Delete the config if package is purged
|
# Delete the config if package is purged
|
||||||
if [ "$1" = "purge" ]; then
|
if [ "$1" = "purge" ] || [ "$1" = "0" ]; then
|
||||||
id ntfy >/dev/null 2>&1 && userdel ntfy
|
id ntfy >/dev/null 2>&1 && userdel ntfy
|
||||||
rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml
|
rm -f /etc/ntfy/server.yml /etc/ntfy/client.yml
|
||||||
rmdir /etc/ntfy || true
|
rmdir /etc/ntfy || true
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
if [ "$1" = "install" ] || [ "$1" = "upgrade" ]; then
|
if [ "$1" = "install" ] || [ "$1" = "upgrade" ] || [ "$1" -ge 1 ]; then
|
||||||
# Migration of old to new config file name
|
# Migration of old to new config file name
|
||||||
oldconfigfile="/etc/ntfy/config.yml"
|
oldconfigfile="/etc/ntfy/config.yml"
|
||||||
configfile="/etc/ntfy/server.yml"
|
configfile="/etc/ntfy/server.yml"
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Stop systemd service
|
# Stop systemd service
|
||||||
if [ -d /run/systemd/system ] && [ "$1" = remove ]; then
|
if [ -d /run/systemd/system ]; then
|
||||||
echo "Stopping ntfy.service ..."
|
if [ "$1" = "remove" ] || [ "$1" = "0" ]; then
|
||||||
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
echo "Stopping ntfy.service ..."
|
||||||
deb-systemd-invoke stop 'ntfy.service' >/dev/null || true
|
if [ -x /usr/bin/deb-systemd-invoke ]; then
|
||||||
else
|
deb-systemd-invoke stop 'ntfy.service' >/dev/null || true
|
||||||
systemctl stop ntfy >/dev/null 2>&1 || true
|
else
|
||||||
|
systemctl stop ntfy >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -20,4 +20,6 @@ type cache interface {
|
|||||||
Topics() (map[string]*topic, error)
|
Topics() (map[string]*topic, error)
|
||||||
Prune(olderThan time.Time) error
|
Prune(olderThan time.Time) error
|
||||||
MarkPublished(m *message) error
|
MarkPublished(m *message) error
|
||||||
|
AttachmentsSize(owner string) (int64, error)
|
||||||
|
AttachmentsExpired() ([]string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,35 @@ func (c *memCache) Prune(olderThan time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *memCache) AttachmentsSize(owner string) (int64, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
var size int64
|
||||||
|
for topic := range c.messages {
|
||||||
|
for _, m := range c.messages[topic] {
|
||||||
|
counted := m.Attachment != nil && m.Attachment.Owner == owner && m.Attachment.Expires > time.Now().Unix()
|
||||||
|
if counted {
|
||||||
|
size += m.Attachment.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *memCache) AttachmentsExpired() ([]string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
ids := make([]string, 0)
|
||||||
|
for topic := range c.messages {
|
||||||
|
for _, m := range c.messages[topic] {
|
||||||
|
if m.Attachment != nil && m.Attachment.Expires > 0 && m.Attachment.Expires < time.Now().Unix() {
|
||||||
|
ids = append(ids, m.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *memCache) pruneTopic(topic string, olderThan time.Time) {
|
func (c *memCache) pruneTopic(topic string, olderThan time.Time) {
|
||||||
messages := make([]*message, 0)
|
messages := make([]*message, 0)
|
||||||
for _, m := range c.messages[topic] {
|
for _, m := range c.messages[topic] {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ func TestMemCache_Prune(t *testing.T) {
|
|||||||
testCachePrune(t, newMemCache())
|
testCachePrune(t, newMemCache())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMemCache_Attachments(t *testing.T) {
|
||||||
|
testCacheAttachments(t, newMemCache())
|
||||||
|
}
|
||||||
|
|
||||||
func TestMemCache_NopCache(t *testing.T) {
|
func TestMemCache_NopCache(t *testing.T) {
|
||||||
c := newNopCache()
|
c := newNopCache()
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||||
|
|||||||
@@ -15,34 +15,45 @@ const (
|
|||||||
createMessagesTableQuery = `
|
createMessagesTableQuery = `
|
||||||
BEGIN;
|
BEGIN;
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
id VARCHAR(20) PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
time INT NOT NULL,
|
time INT NOT NULL,
|
||||||
topic VARCHAR(64) NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
message VARCHAR(512) NOT NULL,
|
message TEXT NOT NULL,
|
||||||
title VARCHAR(256) NOT NULL,
|
title TEXT NOT NULL,
|
||||||
priority INT NOT NULL,
|
priority INT NOT NULL,
|
||||||
tags VARCHAR(256) NOT NULL,
|
tags TEXT NOT NULL,
|
||||||
|
click TEXT NOT NULL,
|
||||||
|
attachment_name TEXT NOT NULL,
|
||||||
|
attachment_type TEXT NOT NULL,
|
||||||
|
attachment_size INT NOT NULL,
|
||||||
|
attachment_expires INT NOT NULL,
|
||||||
|
attachment_url TEXT NOT NULL,
|
||||||
|
attachment_owner TEXT NOT NULL,
|
||||||
|
encoding TEXT NOT NULL,
|
||||||
published INT NOT NULL
|
published INT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `INSERT INTO messages (id, time, topic, message, title, priority, tags, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
insertMessageQuery = `
|
||||||
|
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`
|
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||||
selectMessagesSinceTimeQuery = `
|
selectMessagesSinceTimeQuery = `
|
||||||
SELECT id, time, topic, message, title, priority, tags
|
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time ASC
|
ORDER BY time ASC
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT id, time, topic, message, title, priority, tags
|
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time ASC
|
ORDER BY time ASC
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT id, time, topic, message, title, priority, tags
|
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
`
|
`
|
||||||
@@ -50,11 +61,13 @@ const (
|
|||||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||||
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
||||||
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
||||||
|
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?`
|
||||||
|
selectAttachmentsExpiredQuery = `SELECT id FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 2
|
currentSchemaVersion = 4
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
@@ -68,9 +81,9 @@ const (
|
|||||||
// 0 -> 1
|
// 0 -> 1
|
||||||
migrate0To1AlterMessagesTableQuery = `
|
migrate0To1AlterMessagesTableQuery = `
|
||||||
BEGIN;
|
BEGIN;
|
||||||
ALTER TABLE messages ADD COLUMN title VARCHAR(256) NOT NULL DEFAULT('');
|
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
|
||||||
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
|
||||||
ALTER TABLE messages ADD COLUMN tags VARCHAR(256) NOT NULL DEFAULT('');
|
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -78,6 +91,23 @@ const (
|
|||||||
migrate1To2AlterMessagesTableQuery = `
|
migrate1To2AlterMessagesTableQuery = `
|
||||||
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
|
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 2 -> 3
|
||||||
|
migrate2To3AlterMessagesTableQuery = `
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE messages ADD COLUMN click TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT('');
|
||||||
|
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 {
|
type sqliteCache struct {
|
||||||
@@ -91,7 +121,7 @@ func newSqliteCache(filename string) (*sqliteCache, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setupDB(db); err != nil {
|
if err := setupCacheDB(db); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &sqliteCache{
|
return &sqliteCache{
|
||||||
@@ -104,7 +134,36 @@ func (c *sqliteCache) AddMessage(m *message) error {
|
|||||||
return errUnexpectedMessageType
|
return errUnexpectedMessageType
|
||||||
}
|
}
|
||||||
published := m.Time <= time.Now().Unix()
|
published := m.Time <= time.Now().Unix()
|
||||||
_, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message, m.Title, m.Priority, strings.Join(m.Tags, ","), published)
|
tags := strings.Join(m.Tags, ",")
|
||||||
|
var attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
||||||
|
var attachmentSize, attachmentExpires int64
|
||||||
|
if m.Attachment != nil {
|
||||||
|
attachmentName = m.Attachment.Name
|
||||||
|
attachmentType = m.Attachment.Type
|
||||||
|
attachmentSize = m.Attachment.Size
|
||||||
|
attachmentExpires = m.Attachment.Expires
|
||||||
|
attachmentURL = m.Attachment.URL
|
||||||
|
attachmentOwner = m.Attachment.Owner
|
||||||
|
}
|
||||||
|
_, err := c.db.Exec(
|
||||||
|
insertMessageQuery,
|
||||||
|
m.ID,
|
||||||
|
m.Time,
|
||||||
|
m.Topic,
|
||||||
|
m.Message,
|
||||||
|
m.Title,
|
||||||
|
m.Priority,
|
||||||
|
tags,
|
||||||
|
m.Click,
|
||||||
|
attachmentName,
|
||||||
|
attachmentType,
|
||||||
|
attachmentSize,
|
||||||
|
attachmentExpires,
|
||||||
|
attachmentURL,
|
||||||
|
attachmentOwner,
|
||||||
|
m.Encoding,
|
||||||
|
published,
|
||||||
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,29 +240,98 @@ func (c *sqliteCache) Prune(olderThan time.Time) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) {
|
||||||
|
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var size int64
|
||||||
|
if !rows.Next() {
|
||||||
|
return 0, errors.New("no rows found")
|
||||||
|
}
|
||||||
|
if err := rows.Scan(&size); err != nil {
|
||||||
|
return 0, err
|
||||||
|
} else if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *sqliteCache) AttachmentsExpired() ([]string, error) {
|
||||||
|
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
ids := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
func readMessages(rows *sql.Rows) ([]*message, error) {
|
func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
messages := make([]*message, 0)
|
messages := make([]*message, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var timestamp int64
|
var timestamp, attachmentSize, attachmentExpires int64
|
||||||
var priority int
|
var priority int
|
||||||
var id, topic, msg, title, tagsStr string
|
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
|
||||||
if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr); err != nil {
|
err := rows.Scan(
|
||||||
|
&id,
|
||||||
|
×tamp,
|
||||||
|
&topic,
|
||||||
|
&msg,
|
||||||
|
&title,
|
||||||
|
&priority,
|
||||||
|
&tagsStr,
|
||||||
|
&click,
|
||||||
|
&attachmentName,
|
||||||
|
&attachmentType,
|
||||||
|
&attachmentSize,
|
||||||
|
&attachmentExpires,
|
||||||
|
&attachmentURL,
|
||||||
|
&attachmentOwner,
|
||||||
|
&encoding,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var tags []string
|
var tags []string
|
||||||
if tagsStr != "" {
|
if tagsStr != "" {
|
||||||
tags = strings.Split(tagsStr, ",")
|
tags = strings.Split(tagsStr, ",")
|
||||||
}
|
}
|
||||||
|
var att *attachment
|
||||||
|
if attachmentName != "" && attachmentURL != "" {
|
||||||
|
att = &attachment{
|
||||||
|
Name: attachmentName,
|
||||||
|
Type: attachmentType,
|
||||||
|
Size: attachmentSize,
|
||||||
|
Expires: attachmentExpires,
|
||||||
|
URL: attachmentURL,
|
||||||
|
Owner: attachmentOwner,
|
||||||
|
}
|
||||||
|
}
|
||||||
messages = append(messages, &message{
|
messages = append(messages, &message{
|
||||||
ID: id,
|
ID: id,
|
||||||
Time: timestamp,
|
Time: timestamp,
|
||||||
Event: messageEvent,
|
Event: messageEvent,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
Title: title,
|
Title: title,
|
||||||
Priority: priority,
|
Priority: priority,
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
|
Click: click,
|
||||||
|
Attachment: att,
|
||||||
|
Encoding: encoding,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -212,11 +340,11 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
return messages, nil
|
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
|
// If 'messages' table does not exist, this must be a new database
|
||||||
rowsMC, err := db.Query(selectMessagesCountQuery)
|
rowsMC, err := db.Query(selectMessagesCountQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return setupNewDB(db)
|
return setupNewCacheDB(db)
|
||||||
}
|
}
|
||||||
rowsMC.Close()
|
rowsMC.Close()
|
||||||
|
|
||||||
@@ -241,11 +369,15 @@ func setupDB(db *sql.DB) error {
|
|||||||
return migrateFrom0(db)
|
return migrateFrom0(db)
|
||||||
} else if schemaVersion == 1 {
|
} else if schemaVersion == 1 {
|
||||||
return migrateFrom1(db)
|
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)
|
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 {
|
if _, err := db.Exec(createMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -280,5 +412,27 @@ func migrateFrom1(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 2); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return migrateFrom2(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom2(db *sql.DB) error {
|
||||||
|
log.Print("Migrating cache database schema: from 2 to 3")
|
||||||
|
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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
|
return nil // Update this when a new version is added
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ func TestSqliteCache_Prune(t *testing.T) {
|
|||||||
testCachePrune(t, newSqliteTestCache(t))
|
testCachePrune(t, newSqliteTestCache(t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Attachments(t *testing.T) {
|
||||||
|
testCacheAttachments(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
func TestSqliteCache_Migration_From0(t *testing.T) {
|
func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||||
filename := newSqliteTestCacheFile(t)
|
filename := newSqliteTestCacheFile(t)
|
||||||
db, err := sql.Open("sqlite3", filename)
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/require"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -13,71 +13,71 @@ func testCacheMessages(t *testing.T, c cache) {
|
|||||||
m2 := newDefaultMessage("mytopic", "my other message")
|
m2 := newDefaultMessage("mytopic", "my other message")
|
||||||
m2.Time = 2
|
m2.Time = 2
|
||||||
|
|
||||||
assert.Nil(t, c.AddMessage(m1))
|
require.Nil(t, c.AddMessage(m1))
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
||||||
assert.Nil(t, c.AddMessage(m2))
|
require.Nil(t, c.AddMessage(m2))
|
||||||
|
|
||||||
// Adding invalid
|
// Adding invalid
|
||||||
assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
|
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
|
||||||
assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
||||||
|
|
||||||
// mytopic: count
|
// mytopic: count
|
||||||
count, err := c.MessageCount("mytopic")
|
count, err := c.MessageCount("mytopic")
|
||||||
assert.Nil(t, err)
|
require.Nil(t, err)
|
||||||
assert.Equal(t, 2, count)
|
require.Equal(t, 2, count)
|
||||||
|
|
||||||
// mytopic: since all
|
// mytopic: since all
|
||||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
assert.Equal(t, 2, len(messages))
|
require.Equal(t, 2, len(messages))
|
||||||
assert.Equal(t, "my message", messages[0].Message)
|
require.Equal(t, "my message", messages[0].Message)
|
||||||
assert.Equal(t, "mytopic", messages[0].Topic)
|
require.Equal(t, "mytopic", messages[0].Topic)
|
||||||
assert.Equal(t, messageEvent, messages[0].Event)
|
require.Equal(t, messageEvent, messages[0].Event)
|
||||||
assert.Equal(t, "", messages[0].Title)
|
require.Equal(t, "", messages[0].Title)
|
||||||
assert.Equal(t, 0, messages[0].Priority)
|
require.Equal(t, 0, messages[0].Priority)
|
||||||
assert.Nil(t, messages[0].Tags)
|
require.Nil(t, messages[0].Tags)
|
||||||
assert.Equal(t, "my other message", messages[1].Message)
|
require.Equal(t, "my other message", messages[1].Message)
|
||||||
|
|
||||||
// mytopic: since none
|
// mytopic: since none
|
||||||
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
|
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
|
||||||
assert.Empty(t, messages)
|
require.Empty(t, messages)
|
||||||
|
|
||||||
// mytopic: since 2
|
// mytopic: since 2
|
||||||
messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false)
|
messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false)
|
||||||
assert.Equal(t, 1, len(messages))
|
require.Equal(t, 1, len(messages))
|
||||||
assert.Equal(t, "my other message", messages[0].Message)
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
|
|
||||||
// example: count
|
// example: count
|
||||||
count, err = c.MessageCount("example")
|
count, err = c.MessageCount("example")
|
||||||
assert.Nil(t, err)
|
require.Nil(t, err)
|
||||||
assert.Equal(t, 1, count)
|
require.Equal(t, 1, count)
|
||||||
|
|
||||||
// example: since all
|
// example: since all
|
||||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||||
assert.Equal(t, "my example message", messages[0].Message)
|
require.Equal(t, "my example message", messages[0].Message)
|
||||||
|
|
||||||
// non-existing: count
|
// non-existing: count
|
||||||
count, err = c.MessageCount("doesnotexist")
|
count, err = c.MessageCount("doesnotexist")
|
||||||
assert.Nil(t, err)
|
require.Nil(t, err)
|
||||||
assert.Equal(t, 0, count)
|
require.Equal(t, 0, count)
|
||||||
|
|
||||||
// non-existing: since all
|
// non-existing: since all
|
||||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||||
assert.Empty(t, messages)
|
require.Empty(t, messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCacheTopics(t *testing.T, c cache) {
|
func testCacheTopics(t *testing.T, c cache) {
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
||||||
assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
|
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
|
||||||
|
|
||||||
topics, err := c.Topics()
|
topics, err := c.Topics()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
assert.Equal(t, 2, len(topics))
|
require.Equal(t, 2, len(topics))
|
||||||
assert.Equal(t, "topic1", topics["topic1"].ID)
|
require.Equal(t, "topic1", topics["topic1"].ID)
|
||||||
assert.Equal(t, "topic2", topics["topic2"].ID)
|
require.Equal(t, "topic2", topics["topic2"].ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCachePrune(t *testing.T, c cache) {
|
func testCachePrune(t *testing.T, c cache) {
|
||||||
@@ -90,23 +90,23 @@ func testCachePrune(t *testing.T, c cache) {
|
|||||||
m3 := newDefaultMessage("another_topic", "and another one")
|
m3 := newDefaultMessage("another_topic", "and another one")
|
||||||
m3.Time = 1
|
m3.Time = 1
|
||||||
|
|
||||||
assert.Nil(t, c.AddMessage(m1))
|
require.Nil(t, c.AddMessage(m1))
|
||||||
assert.Nil(t, c.AddMessage(m2))
|
require.Nil(t, c.AddMessage(m2))
|
||||||
assert.Nil(t, c.AddMessage(m3))
|
require.Nil(t, c.AddMessage(m3))
|
||||||
assert.Nil(t, c.Prune(time.Unix(2, 0)))
|
require.Nil(t, c.Prune(time.Unix(2, 0)))
|
||||||
|
|
||||||
count, err := c.MessageCount("mytopic")
|
count, err := c.MessageCount("mytopic")
|
||||||
assert.Nil(t, err)
|
require.Nil(t, err)
|
||||||
assert.Equal(t, 1, count)
|
require.Equal(t, 1, count)
|
||||||
|
|
||||||
count, err = c.MessageCount("another_topic")
|
count, err = c.MessageCount("another_topic")
|
||||||
assert.Nil(t, err)
|
require.Nil(t, err)
|
||||||
assert.Equal(t, 0, count)
|
require.Equal(t, 0, count)
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
assert.Nil(t, err)
|
require.Nil(t, err)
|
||||||
assert.Equal(t, 1, len(messages))
|
require.Equal(t, 1, len(messages))
|
||||||
assert.Equal(t, "my other message", messages[0].Message)
|
require.Equal(t, "my other message", messages[0].Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
|
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
|
||||||
@@ -114,12 +114,12 @@ func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) {
|
|||||||
m.Tags = []string{"tag1", "tag2"}
|
m.Tags = []string{"tag1", "tag2"}
|
||||||
m.Priority = 5
|
m.Priority = 5
|
||||||
m.Title = "some title"
|
m.Title = "some title"
|
||||||
assert.Nil(t, c.AddMessage(m))
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
assert.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
||||||
assert.Equal(t, 5, messages[0].Priority)
|
require.Equal(t, 5, messages[0].Priority)
|
||||||
assert.Equal(t, "some title", messages[0].Title)
|
require.Equal(t, "some title", messages[0].Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCacheMessagesScheduled(t *testing.T, c cache) {
|
func testCacheMessagesScheduled(t *testing.T, c cache) {
|
||||||
@@ -130,20 +130,93 @@ func testCacheMessagesScheduled(t *testing.T, c cache) {
|
|||||||
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
||||||
m4 := newDefaultMessage("mytopic2", "message 4")
|
m4 := newDefaultMessage("mytopic2", "message 4")
|
||||||
m4.Time = time.Now().Add(time.Minute).Unix()
|
m4.Time = time.Now().Add(time.Minute).Unix()
|
||||||
assert.Nil(t, c.AddMessage(m1))
|
require.Nil(t, c.AddMessage(m1))
|
||||||
assert.Nil(t, c.AddMessage(m2))
|
require.Nil(t, c.AddMessage(m2))
|
||||||
assert.Nil(t, c.AddMessage(m3))
|
require.Nil(t, c.AddMessage(m3))
|
||||||
|
|
||||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
|
messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
|
||||||
assert.Equal(t, 1, len(messages))
|
require.Equal(t, 1, len(messages))
|
||||||
assert.Equal(t, "message 1", messages[0].Message)
|
require.Equal(t, "message 1", messages[0].Message)
|
||||||
|
|
||||||
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
|
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
|
||||||
assert.Equal(t, 3, len(messages))
|
require.Equal(t, 3, len(messages))
|
||||||
assert.Equal(t, "message 1", messages[0].Message)
|
require.Equal(t, "message 1", messages[0].Message)
|
||||||
assert.Equal(t, "message 3", messages[1].Message) // Order!
|
require.Equal(t, "message 3", messages[1].Message) // Order!
|
||||||
assert.Equal(t, "message 2", messages[2].Message)
|
require.Equal(t, "message 2", messages[2].Message)
|
||||||
|
|
||||||
messages, _ = c.MessagesDue()
|
messages, _ = c.MessagesDue()
|
||||||
assert.Empty(t, messages)
|
require.Empty(t, messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheAttachments(t *testing.T, c cache) {
|
||||||
|
expires1 := time.Now().Add(-4 * time.Hour).Unix()
|
||||||
|
m := newDefaultMessage("mytopic", "flower for you")
|
||||||
|
m.ID = "m1"
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "flower.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 5000,
|
||||||
|
Expires: expires1,
|
||||||
|
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
||||||
|
Owner: "1.2.3.4",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||||
|
m = newDefaultMessage("mytopic", "sending you a car")
|
||||||
|
m.ID = "m2"
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "car.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 10000,
|
||||||
|
Expires: expires2,
|
||||||
|
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||||
|
Owner: "1.2.3.4",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||||
|
m = newDefaultMessage("another-topic", "sending you another car")
|
||||||
|
m.ID = "m3"
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "another-car.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 20000,
|
||||||
|
Expires: expires3,
|
||||||
|
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
||||||
|
Owner: "1.2.3.4",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(messages))
|
||||||
|
|
||||||
|
require.Equal(t, "flower for you", messages[0].Message)
|
||||||
|
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
|
||||||
|
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
|
||||||
|
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
||||||
|
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
||||||
|
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
||||||
|
require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner)
|
||||||
|
|
||||||
|
require.Equal(t, "sending you a car", messages[1].Message)
|
||||||
|
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
||||||
|
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
|
||||||
|
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
||||||
|
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
||||||
|
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||||
|
require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner)
|
||||||
|
|
||||||
|
size, err := c.AttachmentsSize("1.2.3.4")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(30000), size)
|
||||||
|
|
||||||
|
size, err = c.AttachmentsSize("5.6.7.8")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), size)
|
||||||
|
|
||||||
|
ids, err := c.AttachmentsExpired()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []string{"m1"}, ids)
|
||||||
}
|
}
|
||||||
|
|||||||
152
server/config.go
@@ -4,74 +4,126 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines default config settings
|
// Defines default config settings (excluding limits, see below)
|
||||||
const (
|
const (
|
||||||
DefaultListenHTTP = ":80"
|
DefaultListenHTTP = ":80"
|
||||||
DefaultCacheDuration = 12 * time.Hour
|
DefaultCacheDuration = 12 * time.Hour
|
||||||
DefaultKeepaliveInterval = 30 * time.Second
|
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||||
DefaultManagerInterval = time.Minute
|
DefaultManagerInterval = time.Minute
|
||||||
DefaultAtSenderInterval = 10 * time.Second
|
DefaultAtSenderInterval = 10 * time.Second
|
||||||
DefaultMinDelay = 10 * time.Second
|
DefaultMinDelay = 10 * time.Second
|
||||||
DefaultMaxDelay = 3 * 24 * time.Hour
|
DefaultMaxDelay = 3 * 24 * time.Hour
|
||||||
DefaultMessageLimit = 512
|
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery
|
||||||
DefaultFirebaseKeepaliveInterval = time.Hour
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines all the limits
|
// Defines all global and per-visitor limits
|
||||||
// - global topic limit: max number of topics overall
|
// - message size limit: the max number of bytes for a message
|
||||||
// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds)
|
// - total topic limit: max number of topics overall
|
||||||
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
// - various attachment limits
|
||||||
const (
|
const (
|
||||||
DefaultGlobalTopicLimit = 5000
|
DefaultMessageLengthLimit = 4096 // Bytes
|
||||||
DefaultVisitorRequestLimitBurst = 60
|
DefaultTotalTopicLimit = 15000
|
||||||
DefaultVisitorRequestLimitReplenish = 10 * time.Second
|
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
||||||
DefaultVisitorSubscriptionLimit = 30
|
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
||||||
|
DefaultAttachmentExpiryDuration = 3 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defines all per-visitor limits
|
||||||
|
// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP
|
||||||
|
// - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 5 seconds)
|
||||||
|
// - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour)
|
||||||
|
// - per visitor attachment size limit: total per-visitor attachment size in bytes to be stored on the server
|
||||||
|
// - per visitor attachment daily bandwidth limit: number of bytes that can be transferred to/from the server
|
||||||
|
const (
|
||||||
|
DefaultVisitorSubscriptionLimit = 30
|
||||||
|
DefaultVisitorRequestLimitBurst = 60
|
||||||
|
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
||||||
|
DefaultVisitorEmailLimitBurst = 16
|
||||||
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
|
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||||
|
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
// Config is the main config struct for the application. Use New to instantiate a default config struct.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ListenHTTP string
|
BaseURL string
|
||||||
ListenHTTPS string
|
ListenHTTP string
|
||||||
KeyFile string
|
ListenHTTPS string
|
||||||
CertFile string
|
ListenUnix string
|
||||||
FirebaseKeyFile string
|
KeyFile string
|
||||||
CacheFile string
|
CertFile string
|
||||||
CacheDuration time.Duration
|
FirebaseKeyFile string
|
||||||
KeepaliveInterval time.Duration
|
CacheFile string
|
||||||
ManagerInterval time.Duration
|
CacheDuration time.Duration
|
||||||
AtSenderInterval time.Duration
|
AuthFile string
|
||||||
FirebaseKeepaliveInterval time.Duration
|
AuthDefaultRead bool
|
||||||
MessageLimit int
|
AuthDefaultWrite bool
|
||||||
MinDelay time.Duration
|
AttachmentCacheDir string
|
||||||
MaxDelay time.Duration
|
AttachmentTotalSizeLimit int64
|
||||||
GlobalTopicLimit int
|
AttachmentFileSizeLimit int64
|
||||||
VisitorRequestLimitBurst int
|
AttachmentExpiryDuration time.Duration
|
||||||
VisitorRequestLimitReplenish time.Duration
|
KeepaliveInterval time.Duration
|
||||||
VisitorSubscriptionLimit int
|
ManagerInterval time.Duration
|
||||||
BehindProxy bool
|
AtSenderInterval time.Duration
|
||||||
|
FirebaseKeepaliveInterval time.Duration
|
||||||
|
SMTPSenderAddr string
|
||||||
|
SMTPSenderUser string
|
||||||
|
SMTPSenderPass string
|
||||||
|
SMTPSenderFrom string
|
||||||
|
SMTPServerListen string
|
||||||
|
SMTPServerDomain string
|
||||||
|
SMTPServerAddrPrefix string
|
||||||
|
MessageLimit int
|
||||||
|
MinDelay time.Duration
|
||||||
|
MaxDelay time.Duration
|
||||||
|
TotalTopicLimit int
|
||||||
|
TotalAttachmentSizeLimit int64
|
||||||
|
VisitorSubscriptionLimit int
|
||||||
|
VisitorAttachmentTotalSizeLimit int64
|
||||||
|
VisitorAttachmentDailyBandwidthLimit int
|
||||||
|
VisitorRequestLimitBurst int
|
||||||
|
VisitorRequestLimitReplenish time.Duration
|
||||||
|
VisitorRequestExemptIPAddrs []string
|
||||||
|
VisitorEmailLimitBurst int
|
||||||
|
VisitorEmailLimitReplenish time.Duration
|
||||||
|
BehindProxy bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig instantiates a default new server config
|
// NewConfig instantiates a default new server config
|
||||||
func NewConfig() *Config {
|
func NewConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
ListenHTTP: DefaultListenHTTP,
|
BaseURL: "",
|
||||||
ListenHTTPS: "",
|
ListenHTTP: DefaultListenHTTP,
|
||||||
KeyFile: "",
|
ListenHTTPS: "",
|
||||||
CertFile: "",
|
ListenUnix: "",
|
||||||
FirebaseKeyFile: "",
|
KeyFile: "",
|
||||||
CacheFile: "",
|
CertFile: "",
|
||||||
CacheDuration: DefaultCacheDuration,
|
FirebaseKeyFile: "",
|
||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
CacheFile: "",
|
||||||
ManagerInterval: DefaultManagerInterval,
|
CacheDuration: DefaultCacheDuration,
|
||||||
MessageLimit: DefaultMessageLimit,
|
AuthFile: "",
|
||||||
MinDelay: DefaultMinDelay,
|
AuthDefaultRead: true,
|
||||||
MaxDelay: DefaultMaxDelay,
|
AuthDefaultWrite: true,
|
||||||
AtSenderInterval: DefaultAtSenderInterval,
|
AttachmentCacheDir: "",
|
||||||
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||||
GlobalTopicLimit: DefaultGlobalTopicLimit,
|
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||||
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||||
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
ManagerInterval: DefaultManagerInterval,
|
||||||
BehindProxy: false,
|
MessageLimit: DefaultMessageLengthLimit,
|
||||||
|
MinDelay: DefaultMinDelay,
|
||||||
|
MaxDelay: DefaultMaxDelay,
|
||||||
|
AtSenderInterval: DefaultAtSenderInterval,
|
||||||
|
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
||||||
|
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||||
|
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||||
|
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
||||||
|
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
||||||
|
VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst,
|
||||||
|
VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish,
|
||||||
|
VisitorRequestExemptIPAddrs: make([]string, 0),
|
||||||
|
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||||
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
|
BehindProxy: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
server/errors.go
Normal 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", ""}
|
||||||
|
)
|
||||||
121
server/file_cache.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
fileIDRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||||
|
errInvalidFileID = errors.New("invalid file ID")
|
||||||
|
errFileExists = errors.New("file exists")
|
||||||
|
)
|
||||||
|
|
||||||
|
type fileCache struct {
|
||||||
|
dir string
|
||||||
|
totalSizeCurrent int64
|
||||||
|
totalSizeLimit int64
|
||||||
|
fileSizeLimit int64
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileCache, error) {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
size, err := dirSize(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &fileCache{
|
||||||
|
dir: dir,
|
||||||
|
totalSizeCurrent: size,
|
||||||
|
totalSizeLimit: totalSizeLimit,
|
||||||
|
fileSizeLimit: fileSizeLimit,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) {
|
||||||
|
if !fileIDRegex.MatchString(id) {
|
||||||
|
return 0, errInvalidFileID
|
||||||
|
}
|
||||||
|
file := filepath.Join(c.dir, id)
|
||||||
|
if _, err := os.Stat(file); err == nil {
|
||||||
|
return 0, errFileExists
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
limiters = append(limiters, util.NewFixedLimiter(c.Remaining()), util.NewFixedLimiter(c.fileSizeLimit))
|
||||||
|
limitWriter := util.NewLimitWriter(f, limiters...)
|
||||||
|
size, err := io.Copy(limitWriter, in)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(file)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
os.Remove(file)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.totalSizeCurrent += size
|
||||||
|
c.mu.Unlock()
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fileCache) Remove(ids ...string) error {
|
||||||
|
for _, id := range ids {
|
||||||
|
if !fileIDRegex.MatchString(id) {
|
||||||
|
return errInvalidFileID
|
||||||
|
}
|
||||||
|
file := filepath.Join(c.dir, id)
|
||||||
|
_ = os.Remove(file) // Best effort delete
|
||||||
|
}
|
||||||
|
size, err := dirSize(c.dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.totalSizeCurrent = size
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fileCache) Size() int64 {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.totalSizeCurrent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fileCache) Remaining() int64 {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
remaining := c.totalSizeLimit - c.totalSizeCurrent
|
||||||
|
if remaining < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirSize(dir string) (int64, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var size int64
|
||||||
|
for _, e := range entries {
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
size += info.Size()
|
||||||
|
}
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
83
server/file_cache_test.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
oneKilobyteArray = make([]byte, 1024)
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFileCache_Write_Success(t *testing.T) {
|
||||||
|
dir, c := newTestFileCache(t)
|
||||||
|
size, err := c.Write("abc", strings.NewReader("normal file"), util.NewFixedLimiter(999))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(11), size)
|
||||||
|
require.Equal(t, "normal file", readFile(t, dir+"/abc"))
|
||||||
|
require.Equal(t, int64(11), c.Size())
|
||||||
|
require.Equal(t, int64(10229), c.Remaining())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCache_Write_Remove_Success(t *testing.T) {
|
||||||
|
dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024)
|
||||||
|
for i := 0; i < 10; i++ { // 10x999 = 9990
|
||||||
|
size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(make([]byte, 999)))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(999), size)
|
||||||
|
}
|
||||||
|
require.Equal(t, int64(9990), c.Size())
|
||||||
|
require.Equal(t, int64(250), c.Remaining())
|
||||||
|
require.FileExists(t, dir+"/abc1")
|
||||||
|
require.FileExists(t, dir+"/abc5")
|
||||||
|
|
||||||
|
require.Nil(t, c.Remove("abc1", "abc5"))
|
||||||
|
require.NoFileExists(t, dir+"/abc1")
|
||||||
|
require.NoFileExists(t, dir+"/abc5")
|
||||||
|
require.Equal(t, int64(7992), c.Size())
|
||||||
|
require.Equal(t, int64(2248), c.Remaining())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {
|
||||||
|
dir, c := newTestFileCache(t)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
size, err := c.Write(fmt.Sprintf("abc%d", i), bytes.NewReader(oneKilobyteArray))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(1024), size)
|
||||||
|
}
|
||||||
|
_, err := c.Write("abc11", bytes.NewReader(oneKilobyteArray))
|
||||||
|
require.Equal(t, util.ErrLimitReached, err)
|
||||||
|
require.NoFileExists(t, dir+"/abc11")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) {
|
||||||
|
dir, c := newTestFileCache(t)
|
||||||
|
_, err := c.Write("abc", bytes.NewReader(make([]byte, 1025)))
|
||||||
|
require.Equal(t, util.ErrLimitReached, err)
|
||||||
|
require.NoFileExists(t, dir+"/abc")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
|
||||||
|
dir, c := newTestFileCache(t)
|
||||||
|
_, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
|
||||||
|
require.Equal(t, util.ErrLimitReached, err)
|
||||||
|
require.NoFileExists(t, dir+"/abc")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {
|
||||||
|
dir = t.TempDir()
|
||||||
|
cache, err := newFileCache(dir, 10*1024, 1*1024)
|
||||||
|
require.Nil(t, err)
|
||||||
|
return dir, cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFile(t *testing.T, f string) string {
|
||||||
|
b, err := os.ReadFile(f)
|
||||||
|
require.Nil(t, err)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
curl -d "Backup failed" <span id="detailTopicUrl">ntfy.sh/topic</span>
|
curl -d "Backup failed" <span id="detailTopicUrl">ntfy.sh/topic</span>
|
||||||
</code>
|
</code>
|
||||||
<p id="detailNotificationsDisallowed">
|
<p id="detailNotificationsDisallowed">
|
||||||
If you'd like to receive desktop notifications when new messages arrive on this topic, you have
|
If you'd like to receive desktop notifications when new messages arrive on this topic, you have to
|
||||||
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
|
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
|
||||||
Click the link to do so.
|
Click the link to do so.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
1
server/mailer_emoji.json
Normal file
@@ -1,61 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"heckel.io/ntfy/util"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// List of possible events
|
|
||||||
const (
|
|
||||||
openEvent = "open"
|
|
||||||
keepaliveEvent = "keepalive"
|
|
||||||
messageEvent = "message"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
messageIDLength = 10
|
|
||||||
)
|
|
||||||
|
|
||||||
// message represents a message published to a topic
|
|
||||||
type message struct {
|
|
||||||
ID string `json:"id"` // Random message ID
|
|
||||||
Time int64 `json:"time"` // Unix time in seconds
|
|
||||||
Event string `json:"event"` // One of the above
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
Priority int `json:"priority,omitempty"`
|
|
||||||
Tags []string `json:"tags,omitempty"`
|
|
||||||
Title string `json:"title,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// messageEncoder is a function that knows how to encode a message
|
|
||||||
type messageEncoder func(msg *message) (string, error)
|
|
||||||
|
|
||||||
// newMessage creates a new message with the current timestamp
|
|
||||||
func newMessage(event, topic, msg string) *message {
|
|
||||||
return &message{
|
|
||||||
ID: util.RandomString(messageIDLength),
|
|
||||||
Time: time.Now().Unix(),
|
|
||||||
Event: event,
|
|
||||||
Topic: topic,
|
|
||||||
Priority: 0,
|
|
||||||
Tags: nil,
|
|
||||||
Title: "",
|
|
||||||
Message: msg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newOpenMessage is a convenience method to create an open message
|
|
||||||
func newOpenMessage(topic string) *message {
|
|
||||||
return newMessage(openEvent, topic, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// newKeepaliveMessage is a convenience method to create a keepalive message
|
|
||||||
func newKeepaliveMessage(topic string) *message {
|
|
||||||
return newMessage(keepaliveEvent, topic, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
// newDefaultMessage is a convenience method to create a notification message
|
|
||||||
func newDefaultMessage(topic, msg string) *message {
|
|
||||||
return newMessage(messageEvent, topic, msg)
|
|
||||||
}
|
|
||||||
853
server/server.go
@@ -1,16 +1,28 @@
|
|||||||
# ntfy server config file
|
# 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 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
|
# Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also
|
||||||
# set "key-file" and "cert-file".
|
# set "key-file" and "cert-file". Format: [<ip>]:<port>, e.g. "1.2.3.4:8080".
|
||||||
# Format: <hostname>:<port>
|
#
|
||||||
|
# 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-http: ":80"
|
||||||
# listen-https:
|
# 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.
|
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
|
||||||
#
|
#
|
||||||
# key-file:
|
# key-file: <filename>
|
||||||
# cert-file:
|
# cert-file: <filename>
|
||||||
|
|
||||||
# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.
|
# 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.
|
# This is optional and only required to save battery when using the Android app.
|
||||||
@@ -20,46 +32,39 @@
|
|||||||
# If set, messages are cached in a local SQLite database instead of only in-memory. This
|
# 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.
|
# 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.
|
# 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
|
# Debian/RPM package users:
|
||||||
# ntfy user and group by running: chown ntfy.ntfy <filename>.
|
# 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>
|
# cache-file: <filename>
|
||||||
|
# cache-duration: "12h"
|
||||||
|
|
||||||
# Duration for which messages will be buffered before they are deleted.
|
# If set, access to the ntfy server and API can be controlled on a granular level using
|
||||||
# This is required to support the "since=..." and "poll=1" parameter.
|
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
|
||||||
#
|
#
|
||||||
# You can disable the cache entirely by setting this to 0.
|
# - 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".
|
||||||
#
|
#
|
||||||
# cache-duration: 12h
|
# Debian/RPM package users:
|
||||||
|
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
# creates this folder for you.
|
||||||
# intermediaries closing the connection for inactivity.
|
|
||||||
#
|
#
|
||||||
# Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
|
# 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>.
|
||||||
#
|
#
|
||||||
# keepalive-interval: 30s
|
# auth-file: <filename>
|
||||||
|
# auth-default-access: "read-write"
|
||||||
# Interval in which the manager prunes old messages, deletes topics
|
|
||||||
# and prints the stats.
|
|
||||||
#
|
|
||||||
# manager-interval: 1m
|
|
||||||
|
|
||||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
|
||||||
#
|
|
||||||
# global-topic-limit: 5000
|
|
||||||
|
|
||||||
# Rate limiting: Number of subscriptions per visitor (IP address)
|
|
||||||
#
|
|
||||||
# visitor-subscription-limit: 30
|
|
||||||
|
|
||||||
# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:
|
|
||||||
# - visitor-request-limit-burst is the initial bucket of requests each visitor has
|
|
||||||
# - visitor-request-limit-replenish is the rate at which the bucket is refilled
|
|
||||||
#
|
|
||||||
# visitor-request-limit-burst: 60
|
|
||||||
# visitor-request-limit-replenish: 10s
|
|
||||||
|
|
||||||
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
# If set, the X-Forwarded-For header is used to determine the visitor IP address
|
||||||
# instead of the remote address of the connection.
|
# instead of the remote address of the connection.
|
||||||
@@ -68,3 +73,87 @@
|
|||||||
# as if they are one.
|
# as if they are one.
|
||||||
#
|
#
|
||||||
# behind-proxy: false
|
# behind-proxy: false
|
||||||
|
|
||||||
|
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
||||||
|
# are "attachment-cache-dir" and "base-url".
|
||||||
|
#
|
||||||
|
# - attachment-cache-dir is the cache directory for attached files
|
||||||
|
# - attachment-total-size-limit is the limit of the on-disk attachment cache directory (total size)
|
||||||
|
# - attachment-file-size-limit is the per-file attachment size limit (e.g. 300k, 2M, 100M)
|
||||||
|
# - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h)
|
||||||
|
#
|
||||||
|
# attachment-cache-dir:
|
||||||
|
# attachment-total-size-limit: "5G"
|
||||||
|
# attachment-file-size-limit: "15M"
|
||||||
|
# attachment-expiry-duration: "3h"
|
||||||
|
|
||||||
|
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||||
|
# messages will additionally be sent out as e-mail using an external SMTP server. As of today, only
|
||||||
|
# SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings
|
||||||
|
# below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||||
|
#
|
||||||
|
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||||
|
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user
|
||||||
|
# - smtp-sender-from is the e-mail address of the sender
|
||||||
|
#
|
||||||
|
# smtp-sender-addr:
|
||||||
|
# smtp-sender-user:
|
||||||
|
# smtp-sender-pass:
|
||||||
|
# smtp-sender-from:
|
||||||
|
|
||||||
|
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
|
||||||
|
# emails to a topic e-mail address to publish messages to a topic.
|
||||||
|
#
|
||||||
|
# - smtp-server-listen defines the IP address and port the SMTP server will listen on, e.g. :25 or 1.2.3.4:25
|
||||||
|
# - smtp-server-domain is the e-mail domain, e.g. ntfy.sh
|
||||||
|
# - smtp-server-addr-prefix is an optional prefix for the e-mail addresses to prevent spam. If set to "ntfy-",
|
||||||
|
# for instance, only e-mails to ntfy-$topic@ntfy.sh will be accepted. If this is not set, all emails to
|
||||||
|
# $topic@ntfy.sh will be accepted (which may obviously be a spam problem).
|
||||||
|
#
|
||||||
|
# smtp-server-listen:
|
||||||
|
# smtp-server-domain:
|
||||||
|
# smtp-server-addr-prefix:
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# keepalive-interval: "45s"
|
||||||
|
|
||||||
|
# Interval in which the manager prunes old messages, deletes topics
|
||||||
|
# and prints the stats.
|
||||||
|
#
|
||||||
|
# manager-interval: "1m"
|
||||||
|
|
||||||
|
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||||
|
#
|
||||||
|
# global-topic-limit: 15000
|
||||||
|
|
||||||
|
# Rate limiting: Number of subscriptions per visitor (IP address)
|
||||||
|
#
|
||||||
|
# visitor-subscription-limit: 30
|
||||||
|
|
||||||
|
# Rate limiting: Allowed GET/PUT/POST requests per second, per visitor:
|
||||||
|
# - visitor-request-limit-burst is the initial bucket of requests each visitor has
|
||||||
|
# - visitor-request-limit-replenish is the rate at which the bucket is refilled
|
||||||
|
# - visitor-request-limit-exempt-hosts is a comma-separated list of hostnames and IPs to be
|
||||||
|
# exempt from request rate limiting; hostnames are resolved at the time the server is started
|
||||||
|
#
|
||||||
|
# visitor-request-limit-burst: 60
|
||||||
|
# visitor-request-limit-replenish: "5s"
|
||||||
|
# visitor-request-limit-exempt-hosts: ""
|
||||||
|
|
||||||
|
# Rate limiting: Allowed emails per visitor:
|
||||||
|
# - visitor-email-limit-burst is the initial bucket of emails each visitor has
|
||||||
|
# - visitor-email-limit-replenish is the rate at which the bucket is refilled
|
||||||
|
#
|
||||||
|
# visitor-email-limit-burst: 16
|
||||||
|
# visitor-email-limit-replenish: "1h"
|
||||||
|
|
||||||
|
# Rate limiting: Attachment size and bandwidth limits per visitor:
|
||||||
|
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
|
||||||
|
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor
|
||||||
|
#
|
||||||
|
# visitor-attachment-total-size-limit: "100M"
|
||||||
|
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||||
|
|||||||
113
server/server_firebase.go
Normal 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
|
||||||
|
}
|
||||||
170
server/server_firebase_test.go
Normal 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"])
|
||||||
|
}
|
||||||
@@ -3,14 +3,19 @@ package server
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/auth"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -161,19 +166,13 @@ func TestServer_StaticSites(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishLargeMessage(t *testing.T) {
|
func TestServer_PublishLargeMessage(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
c := newTestConfig(t)
|
||||||
|
c.AttachmentCacheDir = "" // Disable attachments
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
body := strings.Repeat("this is a large message", 1000)
|
body := strings.Repeat("this is a large message", 5000)
|
||||||
truncated := body[0:512]
|
|
||||||
response := request(t, s, "PUT", "/mytopic", body, nil)
|
response := request(t, s, "PUT", "/mytopic", body, nil)
|
||||||
msg := toMessage(t, response.Body.String())
|
require.Equal(t, 400, response.Code)
|
||||||
require.NotEmpty(t, msg.ID)
|
|
||||||
require.Equal(t, truncated, msg.Message)
|
|
||||||
|
|
||||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
|
||||||
messages := toMessages(t, response.Body.String())
|
|
||||||
require.Equal(t, 1, len(messages))
|
|
||||||
require.Equal(t, truncated, messages[0].Message)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishPriority(t *testing.T) {
|
func TestServer_PublishPriority(t *testing.T) {
|
||||||
@@ -202,6 +201,9 @@ func TestServer_PublishPriority(t *testing.T) {
|
|||||||
|
|
||||||
response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil)
|
response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil)
|
||||||
require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
|
require.Equal(t, 5, toMessage(t, response.Body.String()).Priority)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil)
|
||||||
|
require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishNoCache(t *testing.T) {
|
func TestServer_PublishNoCache(t *testing.T) {
|
||||||
@@ -251,6 +253,7 @@ func TestServer_PublishAtWithCacheError(t *testing.T) {
|
|||||||
"In": "30 min",
|
"In": "30 min",
|
||||||
})
|
})
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, errHTTPBadRequestDelayNoCache, toHTTPError(t, response.Body.String()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAtTooShortDelay(t *testing.T) {
|
func TestServer_PublishAtTooShortDelay(t *testing.T) {
|
||||||
@@ -264,13 +267,28 @@ func TestServer_PublishAtTooShortDelay(t *testing.T) {
|
|||||||
|
|
||||||
func TestServer_PublishAtTooLongDelay(t *testing.T) {
|
func TestServer_PublishAtTooLongDelay(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{
|
||||||
"In": "99999999h",
|
"In": "99999999h",
|
||||||
})
|
})
|
||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 400, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAtInvalidDelay(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic?delay=INVALID", "a message", nil)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40004, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAtTooLarge(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic?x-in=99999h", "a message", nil)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40006, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAtAndPrune(t *testing.T) {
|
func TestServer_PublishAtAndPrune(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -352,6 +370,19 @@ func TestServer_PublishAndPollSince(t *testing.T) {
|
|||||||
messages := toMessages(t, response.Body.String())
|
messages := toMessages(t, response.Body.String())
|
||||||
require.Equal(t, 1, len(messages))
|
require.Equal(t, 1, len(messages))
|
||||||
require.Equal(t, "test 2", messages[0].Message)
|
require.Equal(t, "test 2", messages[0].Message)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&since=10s", "", nil)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 2, len(messages))
|
||||||
|
require.Equal(t, "test 1", messages[0].Message)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&since=100ms", "", nil)
|
||||||
|
messages = toMessages(t, response.Body.String())
|
||||||
|
require.Equal(t, 1, len(messages))
|
||||||
|
require.Equal(t, "test 2", messages[0].Message)
|
||||||
|
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1&since=INVALID", "", nil)
|
||||||
|
require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishViaGET(t *testing.T) {
|
func TestServer_PublishViaGET(t *testing.T) {
|
||||||
@@ -372,6 +403,17 @@ func TestServer_PublishViaGET(t *testing.T) {
|
|||||||
require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix())
|
require.Greater(t, msg.Time, time.Now().Add(23*time.Hour).Unix())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishMessageInHeaderWithNewlines(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "", map[string]string{
|
||||||
|
"Message": "Line 1\\nLine 2",
|
||||||
|
})
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.NotEmpty(t, msg.ID)
|
||||||
|
require.Equal(t, "Line 1\nLine 2", msg.Message) // \\n -> \n !
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishFirebase(t *testing.T) {
|
func TestServer_PublishFirebase(t *testing.T) {
|
||||||
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
|
// This is unfortunately not much of a test, since it merely fires the messages towards Firebase,
|
||||||
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
|
// but cannot re-read them. There is no way from Go to read the messages back, or even get an error back.
|
||||||
@@ -392,6 +434,13 @@ func TestServer_PublishFirebase(t *testing.T) {
|
|||||||
time.Sleep(500 * time.Millisecond) // Time for sends
|
time.Sleep(500 * time.Millisecond) // Time for sends
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishInvalidTopic(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
s.mailer = &testMailer{}
|
||||||
|
response := request(t, s, "PUT", "/docs", "fail", nil)
|
||||||
|
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PollWithQueryFilters(t *testing.T) {
|
func TestServer_PollWithQueryFilters(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
|
||||||
@@ -487,6 +536,127 @@ func TestServer_SubscribeWithQueryFilters(t *testing.T) {
|
|||||||
require.Equal(t, keepaliveEvent, messages[2].Event)
|
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) {
|
func TestServer_Curl_Publish_Poll(t *testing.T) {
|
||||||
s, port := test.StartServer(t)
|
s, port := test.StartServer(t)
|
||||||
@@ -508,9 +678,409 @@ func TestServer_Curl_Publish_Poll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type testMailer struct {
|
||||||
|
count int
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *testMailer) Send(from, to string, m *message) error {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.count++
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooRequests_Defaults(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooRequests_Defaults_ExemptHosts(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorRequestExemptIPAddrs = []string{"9.9.9.9"} // see request()
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
for i := 0; i < 65; i++ { // > 60
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooRequests_ShortReplenish(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorRequestLimitBurst = 60
|
||||||
|
c.VisitorRequestLimitReplenish = 500 * time.Millisecond
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
|
||||||
|
time.Sleep(510 * time.Millisecond)
|
||||||
|
response = request(t, s, "PUT", "/mytopic", "message", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooManyEmails_Defaults(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
s.mailer = &testMailer{}
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishTooManyEmails_Replenish(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorEmailLimitReplenish = 500 * time.Millisecond
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
s.mailer = &testMailer{}
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("message %d", i), map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "one too many", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
|
||||||
|
time.Sleep(510 * time.Millisecond)
|
||||||
|
response = request(t, s, "PUT", "/mytopic", "this should be okay again too many", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
response = request(t, s, "PUT", "/mytopic", "and bad again", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishDelayedEmail_Fail(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
s.mailer = &testMailer{}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
"Delay": "20 min",
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishEmailNoMailer_Fail(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "fail", map[string]string{
|
||||||
|
"E-Mail": "test@example.com",
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_UnifiedPushDiscovery(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "GET", "/mytopic?up=1", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
|
||||||
|
b := make([]byte, 12) // Max length
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
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_PublishUnifiedPushBinary_Truncated(t *testing.T) {
|
||||||
|
b := make([]byte, 5000) // Longer than max length
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
content := util.RandomString(5000) // > 4096
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "attachment.txt", msg.Attachment.Name)
|
||||||
|
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
||||||
|
require.Equal(t, int64(5000), msg.Attachment.Size)
|
||||||
|
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||||
|
response = request(t, s, "GET", path, "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "5000", response.Header().Get("Content-Length"))
|
||||||
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
|
size, err := s.cache.AttachmentsSize("9.9.9.9") // See request()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(5000), size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.BehindProxy = true
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
content := "this is an ATTACHMENT"
|
||||||
|
response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, map[string]string{
|
||||||
|
"X-Forwarded-For": "1.2.3.4",
|
||||||
|
})
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, "myfile.txt", msg.Attachment.Name)
|
||||||
|
require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type)
|
||||||
|
require.Equal(t, int64(21), msg.Attachment.Size)
|
||||||
|
require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
require.Equal(t, "", msg.Attachment.Owner) // Should never be returned
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||||
|
response = request(t, s, "GET", path, "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "21", response.Header().Get("Content-Length"))
|
||||||
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
|
size, err := s.cache.AttachmentsSize("1.2.3.4")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(21), size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "", map[string]string{
|
||||||
|
"Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg",
|
||||||
|
})
|
||||||
|
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, "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
|
||||||
|
size, err := s.cache.AttachmentsSize("127.0.0.1")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "This is a custom message", map[string]string{
|
||||||
|
"X-Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg",
|
||||||
|
"File": "some file.jpg",
|
||||||
|
})
|
||||||
|
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, "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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentBadURL(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic?a=not+a+URL", "", nil)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 400, err.HTTPCode)
|
||||||
|
require.Equal(t, 40013, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) {
|
||||||
|
content := util.RandomString(5000) // > 4096
|
||||||
|
s := newTestServer(t, newTestConfig(t))
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, map[string]string{
|
||||||
|
"Content-Length": "20000000",
|
||||||
|
})
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 400, err.HTTPCode)
|
||||||
|
require.Equal(t, 40012, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) {
|
||||||
|
content := util.RandomString(5001) // > 5000, see below
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AttachmentFileSizeLimit = 5000
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 400, err.HTTPCode)
|
||||||
|
require.Equal(t, 40012, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AttachmentExpiryDuration = 10 * time.Minute
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), map[string]string{
|
||||||
|
"Delay": "11 min", // > AttachmentExpiryDuration
|
||||||
|
})
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 400, err.HTTPCode)
|
||||||
|
require.Equal(t, 40015, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) {
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorAttachmentTotalSizeLimit = 10000
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), nil)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, "You received a file: attachment.txt", msg.Message)
|
||||||
|
require.Equal(t, int64(5000), msg.Attachment.Size)
|
||||||
|
|
||||||
|
content := util.RandomString(5001) // 5000+5001 > , see below
|
||||||
|
response = request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 400, err.HTTPCode)
|
||||||
|
require.Equal(t, 40012, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
||||||
|
content := util.RandomString(5000) // > 4096
|
||||||
|
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.AttachmentExpiryDuration = time.Millisecond // Hack
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Publish and make sure we can retrieve it
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
file := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
|
||||||
|
require.FileExists(t, file)
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||||
|
response = request(t, s, "GET", path, "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
|
// Prune and makes sure it's gone
|
||||||
|
time.Sleep(time.Second) // Sigh ...
|
||||||
|
s.updateStatsAndPrune()
|
||||||
|
require.NoFileExists(t, file)
|
||||||
|
response = request(t, s, "GET", path, "", nil)
|
||||||
|
require.Equal(t, 404, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
|
||||||
|
content := util.RandomString(5000) // > 4096
|
||||||
|
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Publish attachment
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
|
||||||
|
// Get it 4 times successfully
|
||||||
|
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||||
|
for i := 1; i <= 4; i++ { // 4 successful downloads
|
||||||
|
response = request(t, s, "GET", path, "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Equal(t, content, response.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And then fail with a 429
|
||||||
|
response = request(t, s, "GET", path, "", nil)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
require.Equal(t, 42905, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
|
||||||
|
content := util.RandomString(5000) // > 4096
|
||||||
|
|
||||||
|
c := newTestConfig(t)
|
||||||
|
c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 500 // 5 successful uploads
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// 5 successful uploads
|
||||||
|
for i := 1; i <= 5; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a failed one
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, nil)
|
||||||
|
err := toHTTPError(t, response.Body.String())
|
||||||
|
require.Equal(t, 400, response.Code)
|
||||||
|
require.Equal(t, 40012, err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
func newTestConfig(t *testing.T) *Config {
|
func newTestConfig(t *testing.T) *Config {
|
||||||
conf := NewConfig()
|
conf := NewConfig()
|
||||||
|
conf.BaseURL = "http://127.0.0.1:12345"
|
||||||
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
|
conf.AttachmentCacheDir = t.TempDir()
|
||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +1098,7 @@ func request(t *testing.T, s *Server, method, url, body string, headers map[stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
req.RemoteAddr = "9.9.9.9" // Used for tests
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
req.Header.Set(k, v)
|
req.Header.Set(k, v)
|
||||||
}
|
}
|
||||||
@@ -570,6 +1141,12 @@ func toMessage(t *testing.T, s string) *message {
|
|||||||
return &m
|
return &m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toHTTPError(t *testing.T, s string) *errHTTP {
|
||||||
|
var e errHTTP
|
||||||
|
require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&e))
|
||||||
|
return &e
|
||||||
|
}
|
||||||
|
|
||||||
func firebaseServiceAccountFile(t *testing.T) string {
|
func firebaseServiceAccountFile(t *testing.T) string {
|
||||||
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
|
if os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE") != "" {
|
||||||
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
|
return os.Getenv("NTFY_TEST_FIREBASE_SERVICE_ACCOUNT_FILE")
|
||||||
@@ -581,3 +1158,7 @@ func firebaseServiceAccountFile(t *testing.T) string {
|
|||||||
t.SkipNow()
|
t.SkipNow()
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func basicAuth(s string) string {
|
||||||
|
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
|
||||||
|
}
|
||||||
|
|||||||
119
server/smtp_sender.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed" // required by go:embed
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mailer interface {
|
||||||
|
Send(from, to string, m *message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type smtpSender struct {
|
||||||
|
config *Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSender) Send(senderIP, to string, m *message) error {
|
||||||
|
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message, err := formatMail(s.config.BaseURL, senderIP, s.config.SMTPSenderFrom, to, m)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth := smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||||
|
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
||||||
|
topicURL := baseURL + "/" + m.Topic
|
||||||
|
subject := m.Title
|
||||||
|
if subject == "" {
|
||||||
|
subject = m.Message
|
||||||
|
}
|
||||||
|
subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ")
|
||||||
|
message := m.Message
|
||||||
|
trailer := ""
|
||||||
|
if len(m.Tags) > 0 {
|
||||||
|
emojis, tags, err := toEmojis(m.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(emojis) > 0 {
|
||||||
|
subject = strings.Join(emojis, " ") + " " + subject
|
||||||
|
}
|
||||||
|
if len(tags) > 0 {
|
||||||
|
trailer = "Tags: " + strings.Join(tags, ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Priority != 0 && m.Priority != 3 {
|
||||||
|
priority, err := util.PriorityString(m.Priority)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if trailer != "" {
|
||||||
|
trailer += "\n"
|
||||||
|
}
|
||||||
|
trailer += fmt.Sprintf("Priority: %s", priority)
|
||||||
|
}
|
||||||
|
if trailer != "" {
|
||||||
|
message += "\n\n" + trailer
|
||||||
|
}
|
||||||
|
subject = mime.BEncoding.Encode("utf-8", subject)
|
||||||
|
body := `From: "{shortTopicURL}" <{from}>
|
||||||
|
To: {to}
|
||||||
|
Subject: {subject}
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
{message}
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by {ip} at {time} via {topicURL}`
|
||||||
|
body = strings.ReplaceAll(body, "{from}", from)
|
||||||
|
body = strings.ReplaceAll(body, "{to}", to)
|
||||||
|
body = strings.ReplaceAll(body, "{subject}", subject)
|
||||||
|
body = strings.ReplaceAll(body, "{message}", message)
|
||||||
|
body = strings.ReplaceAll(body, "{topicURL}", topicURL)
|
||||||
|
body = strings.ReplaceAll(body, "{shortTopicURL}", util.ShortTopicURL(topicURL))
|
||||||
|
body = strings.ReplaceAll(body, "{time}", time.Unix(m.Time, 0).UTC().Format(time.RFC1123))
|
||||||
|
body = strings.ReplaceAll(body, "{ip}", senderIP)
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed "mailer_emoji.json"
|
||||||
|
emojisJSON string
|
||||||
|
)
|
||||||
|
|
||||||
|
type emoji struct {
|
||||||
|
Emoji string `json:"emoji"`
|
||||||
|
Aliases []string `json:"aliases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
||||||
|
var emojis []emoji
|
||||||
|
if err = json.Unmarshal([]byte(emojisJSON), &emojis); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
tagsOut = make([]string, 0)
|
||||||
|
emojisOut = make([]string, 0)
|
||||||
|
nextTag:
|
||||||
|
for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map
|
||||||
|
for _, e := range emojis {
|
||||||
|
if util.InStringList(e.Aliases, t) {
|
||||||
|
emojisOut = append(emojisOut, e.Emoji)
|
||||||
|
continue nextTag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tagsOut = append(tagsOut, t)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
141
server/smtp_sender_test.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatMail_Basic(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_JustEmojis(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
Tags: []string{"grinning"},
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_JustOtherTags(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
Tags: []string{"not-an-emoji"},
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
Tags: not-an-emoji
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_JustPriority(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
Priority: 2,
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: A simple message
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
Priority: low
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_UTF8Subject(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Message: "A simple message",
|
||||||
|
Title: " :: A not so simple title öäüß ¡Hola, señor!",
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: =?utf-8?b?IDo6IEEgbm90IHNvIHNpbXBsZSB0aXRsZSDDtsOkw7zDnyDCoUhvbGEsIHNl?= =?utf-8?b?w7FvciE=?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A simple message
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatMail_WithAllTheThings(t *testing.T) {
|
||||||
|
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||||
|
ID: "abc",
|
||||||
|
Time: 1640382204,
|
||||||
|
Event: "message",
|
||||||
|
Topic: "alerts",
|
||||||
|
Priority: 5,
|
||||||
|
Tags: []string{"warning", "skull", "tag123", "other"},
|
||||||
|
Title: "Oh no 🙈\nThis is a message across\nmultiple lines",
|
||||||
|
Message: "A message that contains monkeys 🙉\nNo really, though. Monkeys!",
|
||||||
|
})
|
||||||
|
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||||
|
To: phil@example.com
|
||||||
|
Subject: =?utf-8?b?4pqg77iPIPCfkoAgT2ggbm8g8J+ZiCBUaGlzIGlzIGEgbWVzc2FnZSBhY3Jv?= =?utf-8?b?c3MgbXVsdGlwbGUgbGluZXM=?=
|
||||||
|
Content-Type: text/plain; charset="utf-8"
|
||||||
|
|
||||||
|
A message that contains monkeys 🙉
|
||||||
|
No really, though. Monkeys!
|
||||||
|
|
||||||
|
Tags: tag123, other
|
||||||
|
Priority: max
|
||||||
|
|
||||||
|
--
|
||||||
|
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||||
|
require.Equal(t, expected, actual)
|
||||||
|
}
|
||||||
195
server/smtp_server.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errInvalidDomain = errors.New("invalid domain")
|
||||||
|
errInvalidAddress = errors.New("invalid address")
|
||||||
|
errInvalidTopic = errors.New("invalid topic")
|
||||||
|
errTooManyRecipients = errors.New("too many recipients")
|
||||||
|
errUnsupportedContentType = errors.New("unsupported content type")
|
||||||
|
)
|
||||||
|
|
||||||
|
// smtpBackend implements SMTP server methods.
|
||||||
|
type smtpBackend struct {
|
||||||
|
config *Config
|
||||||
|
sub subscriber
|
||||||
|
success int64
|
||||||
|
failure int64
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMailBackend(conf *Config, sub subscriber) *smtpBackend {
|
||||||
|
return &smtpBackend{
|
||||||
|
config: conf,
|
||||||
|
sub: sub,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||||
|
return &smtpSession{backend: b}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||||
|
return &smtpSession{backend: b}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *smtpBackend) Counts() (success int64, failure int64) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.success, b.failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// smtpSession is returned after EHLO.
|
||||||
|
type smtpSession struct {
|
||||||
|
backend *smtpBackend
|
||||||
|
topic string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Rcpt(to string) error {
|
||||||
|
return s.withFailCount(func() error {
|
||||||
|
conf := s.backend.config
|
||||||
|
addressList, err := mail.ParseAddressList(to)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if len(addressList) != 1 {
|
||||||
|
return errTooManyRecipients
|
||||||
|
}
|
||||||
|
to = addressList[0].Address
|
||||||
|
if !strings.HasSuffix(to, "@"+conf.SMTPServerDomain) {
|
||||||
|
return errInvalidDomain
|
||||||
|
}
|
||||||
|
to = strings.TrimSuffix(to, "@"+conf.SMTPServerDomain)
|
||||||
|
if conf.SMTPServerAddrPrefix != "" {
|
||||||
|
if !strings.HasPrefix(to, conf.SMTPServerAddrPrefix) {
|
||||||
|
return errInvalidAddress
|
||||||
|
}
|
||||||
|
to = strings.TrimPrefix(to, conf.SMTPServerAddrPrefix)
|
||||||
|
}
|
||||||
|
if !topicRegex.MatchString(to) {
|
||||||
|
return errInvalidTopic
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.topic = to
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Data(r io.Reader) error {
|
||||||
|
return s.withFailCount(func() error {
|
||||||
|
conf := s.backend.config
|
||||||
|
b, err := io.ReadAll(r) // Protected by MaxMessageBytes
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body, err := readMailBody(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body = strings.TrimSpace(body)
|
||||||
|
if len(body) > conf.MessageLimit {
|
||||||
|
body = body[:conf.MessageLimit]
|
||||||
|
}
|
||||||
|
m := newDefaultMessage(s.topic, body)
|
||||||
|
subject := strings.TrimSpace(msg.Header.Get("Subject"))
|
||||||
|
if subject != "" {
|
||||||
|
dec := mime.WordDecoder{}
|
||||||
|
subject, err := dec.DecodeHeader(subject)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Title = subject
|
||||||
|
}
|
||||||
|
if m.Title != "" && m.Message == "" {
|
||||||
|
m.Message = m.Title // Flip them, this makes more sense
|
||||||
|
m.Title = ""
|
||||||
|
}
|
||||||
|
if err := s.backend.sub(m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.backend.mu.Lock()
|
||||||
|
s.backend.success++
|
||||||
|
s.backend.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Reset() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.topic = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) Logout() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *smtpSession) withFailCount(fn func() error) error {
|
||||||
|
err := fn()
|
||||||
|
s.backend.mu.Lock()
|
||||||
|
defer s.backend.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
s.backend.failure++
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMailBody(msg *mail.Message) (string, error) {
|
||||||
|
contentType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if contentType == "text/plain" {
|
||||||
|
body, err := io.ReadAll(msg.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(contentType, "multipart/") {
|
||||||
|
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil { // may be io.EOF
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
partContentType, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if partContentType != "text/plain" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(part)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errUnsupportedContentType
|
||||||
|
}
|
||||||
290
server/smtp_server_test.go
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emersion/go-smtp"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSmtpBackend_Multipart(t *testing.T) {
|
||||||
|
email := `MIME-Version: 1.0
|
||||||
|
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
|
Subject: and one more
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: ntfy-mytopic@ntfy.sh
|
||||||
|
Content-Type: multipart/alternative; boundary="000000000000f3320b05d42915c9"
|
||||||
|
|
||||||
|
--000000000000f3320b05d42915c9
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
what's up
|
||||||
|
|
||||||
|
--000000000000f3320b05d42915c9
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
|
||||||
|
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
||||||
|
|
||||||
|
--000000000000f3320b05d42915c9--`
|
||||||
|
_, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "and one more", m.Title)
|
||||||
|
require.Equal(t, "what's up", m.Message)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_MultipartNoBody(t *testing.T) {
|
||||||
|
email := `MIME-Version: 1.0
|
||||||
|
Date: Tue, 28 Dec 2021 01:33:34 +0100
|
||||||
|
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
|
||||||
|
Subject: This email has a subject but no body
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: ntfy-emailtest@ntfy.sh
|
||||||
|
Content-Type: multipart/alternative; boundary="000000000000bcf4a405d429f8d4"
|
||||||
|
|
||||||
|
--000000000000bcf4a405d429f8d4
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--000000000000bcf4a405d429f8d4
|
||||||
|
Content-Type: text/html; charset="UTF-8"
|
||||||
|
|
||||||
|
<div dir="ltr"><br></div>
|
||||||
|
|
||||||
|
--000000000000bcf4a405d429f8d4--`
|
||||||
|
_, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
require.Equal(t, "emailtest", m.Topic)
|
||||||
|
require.Equal(t, "", m.Title) // We flipped message and body
|
||||||
|
require.Equal(t, "This email has a subject but no body", m.Message)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Plaintext(t *testing.T) {
|
||||||
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
|
Subject: and one more
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: mytopic@ntfy.sh
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
what's up
|
||||||
|
`
|
||||||
|
conf, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
require.Equal(t, "mytopic", m.Topic)
|
||||||
|
require.Equal(t, "and one more", m.Title)
|
||||||
|
require.Equal(t, "what's up", m.Message)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
conf.SMTPServerAddrPrefix = ""
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
|
||||||
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: ntfy-mytopic@ntfy.sh
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
what's up
|
||||||
|
`
|
||||||
|
_, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
require.Equal(t, "Three santas 🎅🎅🎅", m.Title)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
|
||||||
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
|
Subject: and one more
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: mytopic@ntfy.sh
|
||||||
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
|
you know this is a string.
|
||||||
|
it's a long string.
|
||||||
|
it's supposed to be longer than the max message length
|
||||||
|
which is 4096 bytes,
|
||||||
|
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||||
|
the 512 bytes was a little short, some people said
|
||||||
|
but it kinda makes sense when you look at what it looks like one a phone
|
||||||
|
heck this wasn't even half of it so far.
|
||||||
|
so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
|
that should do it
|
||||||
|
`
|
||||||
|
conf, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
expected := `you know this is a string.
|
||||||
|
it's a long string.
|
||||||
|
it's supposed to be longer than the max message length
|
||||||
|
which is 4096 bytes,
|
||||||
|
it used to be 512 bytes, but I increased that for the UnifiedPush support
|
||||||
|
the 512 bytes was a little short, some people said
|
||||||
|
but it kinda makes sense when you look at what it looks like one a phone
|
||||||
|
heck this wasn't even half of it so far.
|
||||||
|
so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||||
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
......................................................................
|
||||||
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
|
BBBBBBBBBBBBBBBBBBBBBBBB`
|
||||||
|
require.Equal(t, 4096, len(expected)) // Sanity check
|
||||||
|
require.Equal(t, expected, m.Message)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
conf.SMTPServerAddrPrefix = ""
|
||||||
|
session, _ := backend.AnonymousLogin(nil)
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSmtpBackend_Unsupported(t *testing.T) {
|
||||||
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
|
Subject: and one more
|
||||||
|
From: Phil <phil@example.com>
|
||||||
|
To: mytopic@ntfy.sh
|
||||||
|
Content-Type: text/SOMETHINGELSE
|
||||||
|
|
||||||
|
what's up
|
||||||
|
`
|
||||||
|
conf, backend := newTestBackend(t, func(m *message) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
conf.SMTPServerAddrPrefix = ""
|
||||||
|
session, _ := backend.Login(nil, "user", "pass")
|
||||||
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Equal(t, errUnsupportedContentType, session.Data(strings.NewReader(email)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestBackend(t *testing.T, sub subscriber) (*Config, *smtpBackend) {
|
||||||
|
conf := newTestConfig(t)
|
||||||
|
conf.SMTPServerListen = ":25"
|
||||||
|
conf.SMTPServerDomain = "ntfy.sh"
|
||||||
|
conf.SMTPServerAddrPrefix = "ntfy-"
|
||||||
|
backend := newMailBackend(conf, sub)
|
||||||
|
return conf, backend
|
||||||
|
}
|
||||||
144
server/types.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// List of possible events
|
||||||
|
const (
|
||||||
|
openEvent = "open"
|
||||||
|
keepaliveEvent = "keepalive"
|
||||||
|
messageEvent = "message"
|
||||||
|
pollRequestEvent = "poll_request"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
messageIDLength = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// message represents a message published to a topic
|
||||||
|
type message struct {
|
||||||
|
ID string `json:"id"` // Random message ID
|
||||||
|
Time int64 `json:"time"` // Unix time in seconds
|
||||||
|
Event string `json:"event"` // One of the above
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
Tags []string `json:"tags,omitempty"`
|
||||||
|
Click string `json:"click,omitempty"`
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageEncoder is a function that knows how to encode a message
|
||||||
|
type messageEncoder func(msg *message) (string, error)
|
||||||
|
|
||||||
|
// newMessage creates a new message with the current timestamp
|
||||||
|
func newMessage(event, topic, msg string) *message {
|
||||||
|
return &message{
|
||||||
|
ID: util.RandomString(messageIDLength),
|
||||||
|
Time: time.Now().Unix(),
|
||||||
|
Event: event,
|
||||||
|
Topic: topic,
|
||||||
|
Priority: 0,
|
||||||
|
Tags: nil,
|
||||||
|
Title: "",
|
||||||
|
Message: msg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newOpenMessage is a convenience method to create an open message
|
||||||
|
func newOpenMessage(topic string) *message {
|
||||||
|
return newMessage(openEvent, topic, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newKeepaliveMessage is a convenience method to create a keepalive message
|
||||||
|
func newKeepaliveMessage(topic string) *message {
|
||||||
|
return newMessage(keepaliveEvent, topic, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDefaultMessage is a convenience method to create a notification 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
|
||||||
|
}
|
||||||
30
server/util.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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 ""
|
||||||
|
}
|
||||||
29
server/util_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -8,39 +9,63 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
visitorExpungeAfter = 30 * time.Minute
|
// visitorExpungeAfter defines how long a visitor is active before it is removed from memory. This number
|
||||||
|
// has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since
|
||||||
|
// they are replenished faster (typically).
|
||||||
|
visitorExpungeAfter = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errVisitorLimitReached = errors.New("limit reached")
|
||||||
)
|
)
|
||||||
|
|
||||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||||
type visitor struct {
|
type visitor struct {
|
||||||
config *Config
|
config *Config
|
||||||
limiter *rate.Limiter
|
ip string
|
||||||
subscriptions *util.Limiter
|
requests *rate.Limiter
|
||||||
|
emails *rate.Limiter
|
||||||
|
subscriptions util.Limiter
|
||||||
|
bandwidth util.Limiter
|
||||||
seen time.Time
|
seen time.Time
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVisitor(conf *Config) *visitor {
|
func newVisitor(conf *Config, ip string) *visitor {
|
||||||
return &visitor{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
limiter: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
ip: ip,
|
||||||
subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)),
|
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
||||||
|
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
||||||
|
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||||
|
bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
||||||
seen: time.Now(),
|
seen: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) IP() string {
|
||||||
|
return v.ip
|
||||||
|
}
|
||||||
|
|
||||||
func (v *visitor) RequestAllowed() error {
|
func (v *visitor) RequestAllowed() error {
|
||||||
if !v.limiter.Allow() {
|
if !v.requests.Allow() {
|
||||||
return errHTTPTooManyRequests
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) AddSubscription() error {
|
func (v *visitor) EmailAllowed() error {
|
||||||
|
if !v.emails.Allow() {
|
||||||
|
return errVisitorLimitReached
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visitor) SubscriptionAllowed() error {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
if err := v.subscriptions.Add(1); err != nil {
|
if err := v.subscriptions.Allow(1); err != nil {
|
||||||
return errHTTPTooManyRequests
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -48,7 +73,7 @@ func (v *visitor) AddSubscription() error {
|
|||||||
func (v *visitor) RemoveSubscription() {
|
func (v *visitor) RemoveSubscription() {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
v.subscriptions.Sub(1)
|
v.subscriptions.Allow(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) Keepalive() {
|
func (v *visitor) Keepalive() {
|
||||||
@@ -57,6 +82,10 @@ func (v *visitor) Keepalive() {
|
|||||||
v.seen = time.Now()
|
v.seen = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) BandwidthLimiter() util.Limiter {
|
||||||
|
return v.bandwidth
|
||||||
|
}
|
||||||
|
|
||||||
func (v *visitor) Stale() bool {
|
func (v *visitor) Stale() bool {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
|
|||||||
@@ -5,19 +5,26 @@ import (
|
|||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rand.Seed(time.Now().Unix())
|
rand.Seed(time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartServer starts a server.Server with a random port and waits for the server to be up
|
// StartServer starts a server.Server with a random port and waits for the server to be up
|
||||||
func StartServer(t *testing.T) (*server.Server, int) {
|
func StartServer(t *testing.T) (*server.Server, int) {
|
||||||
|
return StartServerWithConfig(t, server.NewConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartServerWithConfig starts a server.Server with a random port and waits for the server to be up
|
||||||
|
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
|
||||||
port := 10000 + rand.Intn(20000)
|
port := 10000 + rand.Intn(20000)
|
||||||
conf := server.NewConfig()
|
|
||||||
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
conf.ListenHTTP = fmt.Sprintf(":%d", port)
|
||||||
|
conf.AttachmentCacheDir = t.TempDir()
|
||||||
|
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
|
||||||
s, err := server.New(conf)
|
s, err := server.New(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
42
util/content_type_writer.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentTypeWriter is an implementation of http.ResponseWriter that will detect the content type and set the
|
||||||
|
// Content-Type and (optionally) Content-Disposition headers accordingly.
|
||||||
|
//
|
||||||
|
// It will always set a Content-Type based on http.DetectContentType, but will never send the "text/html"
|
||||||
|
// content type.
|
||||||
|
type ContentTypeWriter struct {
|
||||||
|
w http.ResponseWriter
|
||||||
|
filename string
|
||||||
|
sniffed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContentTypeWriter creates a new ContentTypeWriter
|
||||||
|
func NewContentTypeWriter(w http.ResponseWriter, filename string) *ContentTypeWriter {
|
||||||
|
return &ContentTypeWriter{w, filename, false}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *ContentTypeWriter) Write(p []byte) (n int, err error) {
|
||||||
|
if w.sniffed {
|
||||||
|
return w.w.Write(p)
|
||||||
|
}
|
||||||
|
// Detect and set Content-Type header
|
||||||
|
// Fix content types that we don't want to inline-render in the browser. In particular,
|
||||||
|
// we don't want to render HTML in the browser for security reasons.
|
||||||
|
contentType, _ := DetectContentType(p, w.filename)
|
||||||
|
if strings.HasPrefix(contentType, "text/html") {
|
||||||
|
contentType = strings.ReplaceAll(contentType, "text/html", "text/plain")
|
||||||
|
} else if contentType == "application/octet-stream" {
|
||||||
|
contentType = "" // Reset to let downstream http.ResponseWriter take care of it
|
||||||
|
}
|
||||||
|
if contentType != "" {
|
||||||
|
w.w.Header().Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
w.sniffed = true
|
||||||
|
return w.w.Write(p)
|
||||||
|
}
|
||||||
57
util/content_type_writer_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSniffWriter_WriteHTML(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
sw := NewContentTypeWriter(rr, "")
|
||||||
|
sw.Write([]byte("<script>alert('hi')</script>"))
|
||||||
|
require.Equal(t, "text/plain; charset=utf-8", rr.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSniffWriter_WriteTwoWriteCalls(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
sw := NewContentTypeWriter(rr, "")
|
||||||
|
sw.Write([]byte{0x25, 0x50, 0x44, 0x46, 0x2d, 0x11, 0x22, 0x33})
|
||||||
|
sw.Write([]byte("<script>alert('hi')</script>"))
|
||||||
|
require.Equal(t, "application/pdf", rr.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSniffWriter_NoSniffWriterWriteHTML(t *testing.T) {
|
||||||
|
// This test just makes sure that without the sniff-w, we would get text/html
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
rr.Write([]byte("<script>alert('hi')</script>"))
|
||||||
|
require.Equal(t, "text/html; charset=utf-8", rr.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSniffWriter_WriteHTMLSplitIntoTwoWrites(t *testing.T) {
|
||||||
|
// This test shows how splitting the HTML into two Write() calls will still yield text/plain
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
sw := NewContentTypeWriter(rr, "")
|
||||||
|
sw.Write([]byte("<scr"))
|
||||||
|
sw.Write([]byte("ipt>alert('hi')</script>"))
|
||||||
|
require.Equal(t, "text/plain; charset=utf-8", rr.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSniffWriter_WriteUnknownMimeType(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
sw := NewContentTypeWriter(rr, "")
|
||||||
|
randomBytes := make([]byte, 199)
|
||||||
|
rand.Read(randomBytes)
|
||||||
|
sw.Write(randomBytes)
|
||||||
|
require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSniffWriter_WriteWithFilenameAPK(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
sw := NewContentTypeWriter(rr, "https://example.com/ntfy.apk")
|
||||||
|
sw.Write([]byte{0x50, 0x4B, 0x03, 0x04})
|
||||||
|
require.Equal(t, "application/vnd.android.package-archive", rr.Header().Get("Content-Type"))
|
||||||
|
}
|
||||||
108
util/limit.go
@@ -2,59 +2,109 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"golang.org/x/time/rate"
|
||||||
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrLimitReached is the error returned by the Limiter and LimitWriter when the predefined limit has been reached
|
// ErrLimitReached is the error returned by the Limiter and LimitWriter when the predefined limit has been reached
|
||||||
var ErrLimitReached = errors.New("limit reached")
|
var ErrLimitReached = errors.New("limit reached")
|
||||||
|
|
||||||
// Limiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached
|
// Limiter is an interface that implements a rate limiting mechanism, e.g. based on time or a fixed value
|
||||||
// ErrLimitReached will be returned. Limiter may be used by multiple goroutines.
|
type Limiter interface {
|
||||||
type Limiter struct {
|
// Allow adds n to the limiters internal value, or returns ErrLimitReached if the limit has been reached
|
||||||
|
Allow(n int64) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached
|
||||||
|
// ErrLimitReached will be returned. FixedLimiter may be used by multiple goroutines.
|
||||||
|
type FixedLimiter struct {
|
||||||
value int64
|
value int64
|
||||||
limit int64
|
limit int64
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLimiter creates a new Limiter
|
// NewFixedLimiter creates a new Limiter
|
||||||
func NewLimiter(limit int64) *Limiter {
|
func NewFixedLimiter(limit int64) *FixedLimiter {
|
||||||
return &Limiter{
|
return &FixedLimiter{
|
||||||
limit: limit,
|
limit: limit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add adds n to the limiters internal value, but only if the limit has not been reached. If the limit would be
|
// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was
|
||||||
// exceeded after adding n, ErrLimitReached is returned.
|
// exceeded after adding n, ErrLimitReached is returned.
|
||||||
func (l *Limiter) Add(n int64) error {
|
func (l *FixedLimiter) Allow(n int64) error {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
if l.limit == 0 {
|
if l.value+n > l.limit {
|
||||||
l.value += n
|
|
||||||
return nil
|
|
||||||
} else if l.value+n <= l.limit {
|
|
||||||
l.value += n
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
return ErrLimitReached
|
return ErrLimitReached
|
||||||
}
|
}
|
||||||
|
l.value += n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit.
|
||||||
|
type RateLimiter struct {
|
||||||
|
limiter *rate.Limiter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimiter creates a new RateLimiter
|
||||||
|
func NewRateLimiter(r rate.Limit, b int) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
limiter: rate.NewLimiter(r, b),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sub subtracts a value from the limiters internal value
|
// NewBytesLimiter creates a RateLimiter that is meant to be used for a bytes-per-interval limit,
|
||||||
func (l *Limiter) Sub(n int64) {
|
// e.g. 250 MB per day. And example of the underlying idea can be found here: https://go.dev/play/p/0ljgzIZQ6dJ
|
||||||
l.Add(-n)
|
func NewBytesLimiter(bytes int, interval time.Duration) *RateLimiter {
|
||||||
|
return NewRateLimiter(rate.Limit(bytes)*rate.Every(interval), bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set sets the value of the limiter to n. This function ignores the limit. It is meant to set the value
|
// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was
|
||||||
// based on reality.
|
// exceeded after adding n, ErrLimitReached is returned.
|
||||||
func (l *Limiter) Set(n int64) {
|
func (l *RateLimiter) Allow(n int64) error {
|
||||||
l.mu.Lock()
|
if n <= 0 {
|
||||||
l.value = n
|
return nil // No-op. Can't take back bytes you're written!
|
||||||
l.mu.Unlock()
|
}
|
||||||
|
if !l.limiter.AllowN(time.Now(), int(n)) {
|
||||||
|
return ErrLimitReached
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value returns the internal value of the limiter
|
// LimitWriter implements an io.Writer that will pass through all Write calls to the underlying
|
||||||
func (l *Limiter) Value() int64 {
|
// writer w until any of the limiter's limit is reached, at which point a Write will return ErrLimitReached.
|
||||||
l.mu.Lock()
|
// Each limiter's value is increased with every write.
|
||||||
defer l.mu.Unlock()
|
type LimitWriter struct {
|
||||||
return l.value
|
w io.Writer
|
||||||
|
written int64
|
||||||
|
limiters []Limiter
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLimitWriter creates a new LimitWriter
|
||||||
|
func NewLimitWriter(w io.Writer, limiters ...Limiter) *LimitWriter {
|
||||||
|
return &LimitWriter{
|
||||||
|
w: w,
|
||||||
|
limiters: limiters,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write passes through all writes to the underlying writer until any of the given limiter's limit is reached
|
||||||
|
func (w *LimitWriter) Write(p []byte) (n int, err error) {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
for i := 0; i < len(w.limiters); i++ {
|
||||||
|
if err := w.limiters[i].Allow(int64(len(p))); err != nil {
|
||||||
|
for j := i - 1; j >= 0; j-- {
|
||||||
|
w.limiters[j].Allow(-int64(len(p))) // Revert limiters limits if allowed
|
||||||
|
}
|
||||||
|
return 0, ErrLimitReached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n, err = w.w.Write(p)
|
||||||
|
w.written += int64(n)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,139 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLimiter_Add(t *testing.T) {
|
func TestFixedLimiter_Add(t *testing.T) {
|
||||||
l := NewLimiter(10)
|
l := NewFixedLimiter(10)
|
||||||
if err := l.Add(5); err != nil {
|
if err := l.Allow(5); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := l.Add(5); err != nil {
|
if err := l.Allow(5); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := l.Add(5); err != ErrLimitReached {
|
if err := l.Allow(5); err != ErrLimitReached {
|
||||||
t.Fatalf("expected ErrLimitReached, got %#v", err)
|
t.Fatalf("expected ErrLimitReached, got %#v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLimiter_AddSub(t *testing.T) {
|
func TestFixedLimiter_AddSub(t *testing.T) {
|
||||||
l := NewLimiter(10)
|
l := NewFixedLimiter(10)
|
||||||
l.Add(5)
|
l.Allow(5)
|
||||||
if l.Value() != 5 {
|
if l.value != 5 {
|
||||||
t.Fatalf("expected value to be %d, got %d", 5, l.Value())
|
t.Fatalf("expected value to be %d, got %d", 5, l.value)
|
||||||
}
|
}
|
||||||
l.Sub(2)
|
l.Allow(-2)
|
||||||
if l.Value() != 3 {
|
if l.value != 3 {
|
||||||
t.Fatalf("expected value to be %d, got %d", 3, l.Value())
|
t.Fatalf("expected value to be %d, got %d", 7, l.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBytesLimiter_Add_Simple(t *testing.T) {
|
||||||
|
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h
|
||||||
|
require.Nil(t, l.Allow(100*1024*1024))
|
||||||
|
require.Nil(t, l.Allow(100*1024*1024))
|
||||||
|
require.Equal(t, ErrLimitReached, l.Allow(300*1024*1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBytesLimiter_Add_Wait(t *testing.T) {
|
||||||
|
l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h (~ 303 bytes per 100ms)
|
||||||
|
require.Nil(t, l.Allow(250*1024*1024))
|
||||||
|
require.Equal(t, ErrLimitReached, l.Allow(400))
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
require.Nil(t, l.Allow(400))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitWriter_WriteNoLimiter(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
lw := NewLimitWriter(&buf)
|
||||||
|
if _, err := lw.Write(make([]byte, 10)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := lw.Write(make([]byte, 1)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 11 {
|
||||||
|
t.Fatalf("expected buffer length to be %d, got %d", 11, buf.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitWriter_WriteOneLimiter(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l := NewFixedLimiter(10)
|
||||||
|
lw := NewLimitWriter(&buf, l)
|
||||||
|
if _, err := lw.Write(make([]byte, 10)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := lw.Write(make([]byte, 1)); err != ErrLimitReached {
|
||||||
|
t.Fatalf("expected ErrLimitReached, got %#v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 10 {
|
||||||
|
t.Fatalf("expected buffer length to be %d, got %d", 10, buf.Len())
|
||||||
|
}
|
||||||
|
if l.value != 10 {
|
||||||
|
t.Fatalf("expected limiter value to be %d, got %d", 10, l.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitWriter_WriteTwoLimiters(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l1 := NewFixedLimiter(11)
|
||||||
|
l2 := NewFixedLimiter(9)
|
||||||
|
lw := NewLimitWriter(&buf, l1, l2)
|
||||||
|
if _, err := lw.Write(make([]byte, 8)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := lw.Write(make([]byte, 2)); err != ErrLimitReached {
|
||||||
|
t.Fatalf("expected ErrLimitReached, got %#v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 8 {
|
||||||
|
t.Fatalf("expected buffer length to be %d, got %d", 8, buf.Len())
|
||||||
|
}
|
||||||
|
if l1.value != 8 {
|
||||||
|
t.Fatalf("expected limiter 1 value to be %d, got %d", 8, l1.value)
|
||||||
|
}
|
||||||
|
if l2.value != 8 {
|
||||||
|
t.Fatalf("expected limiter 2 value to be %d, got %d", 8, l2.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitWriter_WriteTwoDifferentLimiters(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l1 := NewFixedLimiter(32)
|
||||||
|
l2 := NewBytesLimiter(8, 200*time.Millisecond)
|
||||||
|
lw := NewLimitWriter(&buf, l1, l2)
|
||||||
|
_, err := lw.Write(make([]byte, 8))
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = lw.Write(make([]byte, 4))
|
||||||
|
require.Equal(t, ErrLimitReached, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitWriter_WriteTwoDifferentLimiters_Wait(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l1 := NewFixedLimiter(32)
|
||||||
|
l2 := NewBytesLimiter(8, 200*time.Millisecond)
|
||||||
|
lw := NewLimitWriter(&buf, l1, l2)
|
||||||
|
_, err := lw.Write(make([]byte, 8))
|
||||||
|
require.Nil(t, err)
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
_, err = lw.Write(make([]byte, 8))
|
||||||
|
require.Nil(t, err)
|
||||||
|
_, err = lw.Write(make([]byte, 4))
|
||||||
|
require.Equal(t, ErrLimitReached, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLimitWriter_WriteTwoDifferentLimiters_Wait_FixedLimiterFail(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
l1 := NewFixedLimiter(11) // <<< This fails below
|
||||||
|
l2 := NewBytesLimiter(8, 200*time.Millisecond)
|
||||||
|
lw := NewLimitWriter(&buf, l1, l2)
|
||||||
|
_, err := lw.Write(make([]byte, 8))
|
||||||
|
require.Nil(t, err)
|
||||||
|
time.Sleep(250 * time.Millisecond)
|
||||||
|
_, err = lw.Write(make([]byte, 8)) // <<< FixedLimiter fails
|
||||||
|
require.Equal(t, ErrLimitReached, err)
|
||||||
|
}
|
||||||
|
|||||||
61
util/peak.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeakedReadCloser is a ReadCloser that allows peaking into a stream and buffering it in memory.
|
||||||
|
// It can be instantiated using the Peak function. After a stream has been peaked, it can still be fully
|
||||||
|
// read by reading the PeakedReadCloser. It first drained from the memory buffer, and then from the remaining
|
||||||
|
// underlying reader.
|
||||||
|
type PeakedReadCloser struct {
|
||||||
|
PeakedBytes []byte
|
||||||
|
LimitReached bool
|
||||||
|
peaked io.Reader
|
||||||
|
underlying io.ReadCloser
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peak reads the underlying ReadCloser into memory up until the limit and returns a PeakedReadCloser
|
||||||
|
func Peak(underlying io.ReadCloser, limit int) (*PeakedReadCloser, error) {
|
||||||
|
if underlying == nil {
|
||||||
|
underlying = io.NopCloser(strings.NewReader(""))
|
||||||
|
}
|
||||||
|
peaked := make([]byte, limit)
|
||||||
|
read, err := io.ReadFull(underlying, peaked)
|
||||||
|
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PeakedReadCloser{
|
||||||
|
PeakedBytes: peaked[:read],
|
||||||
|
LimitReached: read == limit,
|
||||||
|
underlying: underlying,
|
||||||
|
peaked: bytes.NewReader(peaked[:read]),
|
||||||
|
closed: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read reads from the peaked bytes and then from the underlying stream
|
||||||
|
func (r *PeakedReadCloser) Read(p []byte) (n int, err error) {
|
||||||
|
if r.closed {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
n, err = r.peaked.Read(p)
|
||||||
|
if err == io.EOF {
|
||||||
|
return r.underlying.Read(p)
|
||||||
|
} else if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the underlying stream
|
||||||
|
func (r *PeakedReadCloser) Close() error {
|
||||||
|
if r.closed {
|
||||||
|
return io.EOF
|
||||||
|
}
|
||||||
|
r.closed = true
|
||||||
|
return r.underlying.Close()
|
||||||
|
}
|
||||||
55
util/peak_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPeak_LimitReached(t *testing.T) {
|
||||||
|
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
||||||
|
peaked, err := Peak(underlying, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
|
||||||
|
require.Equal(t, true, peaked.LimitReached)
|
||||||
|
|
||||||
|
all, err := io.ReadAll(peaked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
require.Equal(t, []byte("1234567890"), all)
|
||||||
|
require.Equal(t, []byte("12345"), peaked.PeakedBytes)
|
||||||
|
require.Equal(t, true, peaked.LimitReached)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeak_LimitNotReached(t *testing.T) {
|
||||||
|
underlying := io.NopCloser(strings.NewReader("1234567890"))
|
||||||
|
peaked, err := Peak(underlying, 15)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, err := io.ReadAll(peaked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
require.Equal(t, []byte("1234567890"), all)
|
||||||
|
require.Equal(t, []byte("1234567890"), peaked.PeakedBytes)
|
||||||
|
require.Equal(t, false, peaked.LimitReached)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPeak_Nil(t *testing.T) {
|
||||||
|
peaked, err := Peak(nil, 15)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, err := io.ReadAll(peaked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
require.Equal(t, []byte(""), all)
|
||||||
|
require.Equal(t, []byte(""), peaked.PeakedBytes)
|
||||||
|
require.Equal(t, false, peaked.LimitReached)
|
||||||
|
}
|
||||||
114
util/util.go
@@ -1,10 +1,16 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/gabriel-vasile/mimetype"
|
||||||
|
"golang.org/x/term"
|
||||||
|
"io"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,9 +21,9 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
random = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
randomMutex = sync.Mutex{}
|
randomMutex = sync.Mutex{}
|
||||||
|
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
||||||
errInvalidPriority = errors.New("invalid priority")
|
errInvalidPriority = errors.New("invalid priority")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,7 +140,109 @@ func ParsePriority(priority string) (int, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PriorityString converts a priority number to a string
|
||||||
|
func PriorityString(priority int) (string, error) {
|
||||||
|
switch priority {
|
||||||
|
case 0:
|
||||||
|
return "default", nil
|
||||||
|
case 1:
|
||||||
|
return "min", nil
|
||||||
|
case 2:
|
||||||
|
return "low", nil
|
||||||
|
case 3:
|
||||||
|
return "default", nil
|
||||||
|
case 4:
|
||||||
|
return "high", nil
|
||||||
|
case 5:
|
||||||
|
return "max", nil
|
||||||
|
default:
|
||||||
|
return "", errInvalidPriority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ExpandHome replaces "~" with the user's home directory
|
// ExpandHome replaces "~" with the user's home directory
|
||||||
func ExpandHome(path string) string {
|
func ExpandHome(path string) string {
|
||||||
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
|
return os.ExpandEnv(strings.ReplaceAll(path, "~", "$HOME"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShortTopicURL shortens the topic URL to be human-friendly, removing the http:// or https://
|
||||||
|
func ShortTopicURL(s string) string {
|
||||||
|
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectContentType probes the byte array b and returns mime type and file extension.
|
||||||
|
// The filename is only used to override certain special cases.
|
||||||
|
func DetectContentType(b []byte, filename string) (mimeType string, ext string) {
|
||||||
|
if strings.HasSuffix(strings.ToLower(filename), ".apk") {
|
||||||
|
return "application/vnd.android.package-archive", ".apk"
|
||||||
|
}
|
||||||
|
m := mimetype.Detect(b)
|
||||||
|
mimeType, ext = m.String(), m.Extension()
|
||||||
|
if ext == "" {
|
||||||
|
ext = ".bin"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSize parses a size string like 2K or 2M into bytes. If no unit is found, e.g. 123, bytes is assumed.
|
||||||
|
func ParseSize(s string) (int64, error) {
|
||||||
|
matches := sizeStrRegex.FindStringSubmatch(s)
|
||||||
|
if matches == nil {
|
||||||
|
return -1, fmt.Errorf("invalid size %s", s)
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(matches[1])
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("cannot convert number %s", matches[1])
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(matches[2]) {
|
||||||
|
case "G":
|
||||||
|
return int64(value) * 1024 * 1024 * 1024, nil
|
||||||
|
case "M":
|
||||||
|
return int64(value) * 1024 * 1024, nil
|
||||||
|
case "K":
|
||||||
|
return int64(value) * 1024, nil
|
||||||
|
default:
|
||||||
|
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))))
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,3 +100,55 @@ func TestParsePriority_Invalid(t *testing.T) {
|
|||||||
require.Equal(t, errInvalidPriority, err)
|
require.Equal(t, errInvalidPriority, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPriorityString(t *testing.T) {
|
||||||
|
priorities := []int{0, 1, 2, 3, 4, 5}
|
||||||
|
expected := []string{"default", "min", "low", "default", "high", "max"}
|
||||||
|
for i, priority := range priorities {
|
||||||
|
actual, err := PriorityString(priority)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, expected[i], actual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPriorityString_Invalid(t *testing.T) {
|
||||||
|
_, err := PriorityString(99)
|
||||||
|
require.Equal(t, err, errInvalidPriority)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortTopicURL(t *testing.T) {
|
||||||
|
require.Equal(t, "ntfy.sh/mytopic", ShortTopicURL("https://ntfy.sh/mytopic"))
|
||||||
|
require.Equal(t, "ntfy.sh/mytopic", ShortTopicURL("http://ntfy.sh/mytopic"))
|
||||||
|
require.Equal(t, "lalala", ShortTopicURL("lalala"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSize_10GSuccess(t *testing.T) {
|
||||||
|
s, err := ParseSize("10G")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
require.Equal(t, int64(10*1024*1024*1024), s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSize_10MUpperCaseSuccess(t *testing.T) {
|
||||||
|
s, err := ParseSize("10M")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
require.Equal(t, int64(10*1024*1024), s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSize_10kLowerCaseSuccess(t *testing.T) {
|
||||||
|
s, err := ParseSize("10k")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
require.Equal(t, int64(10*1024), s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSize_FailureInvalid(t *testing.T) {
|
||||||
|
_, err := ParseSize("not a size")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, but got none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||