Compare commits
118 Commits
v1.31.0
...
new-homepa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31a3bb7cd6 | ||
|
|
45b97c7054 | ||
|
|
4e51a715c1 | ||
|
|
3bd6518309 | ||
|
|
f945fb4cdd | ||
|
|
7cff44b647 | ||
|
|
cead305a9a | ||
|
|
4092f7fd51 | ||
|
|
695c1349e8 | ||
|
|
83de879894 | ||
|
|
7faed3ee1e | ||
|
|
c06bfb989e | ||
|
|
f7f7f469ad | ||
|
|
01fd4754f9 | ||
|
|
7007c0a0bd | ||
|
|
24529bd0ad | ||
|
|
d4ec5eb497 | ||
|
|
fdee54f921 | ||
|
|
3dd8dd4288 | ||
|
|
2908c429a5 | ||
|
|
7e528d9c10 | ||
|
|
b27c608508 | ||
|
|
a4529617cc | ||
|
|
a6564fb43c | ||
|
|
3aba7404fc | ||
|
|
d8032e1c9e | ||
|
|
1f54adad71 | ||
|
|
df512d0ba2 | ||
|
|
a54a11db88 | ||
|
|
ac4042ca04 | ||
|
|
a51d95743a | ||
|
|
1bc40693bb | ||
|
|
1e7dd8fc80 | ||
|
|
7fa63c8e19 | ||
|
|
60f1882bec | ||
|
|
3280c2c440 | ||
|
|
a91da7cf2c | ||
|
|
6c0429351a | ||
|
|
82d3b41699 | ||
|
|
1a87e5c3d4 | ||
|
|
1e16545517 | ||
|
|
2500ce0920 | ||
|
|
2f725bf80d | ||
|
|
21c33f1e82 | ||
|
|
bb583eaa72 | ||
|
|
d666cab77a | ||
|
|
4b9d40464c | ||
|
|
1733323132 | ||
|
|
1256ba0429 | ||
|
|
7487b0da58 | ||
|
|
e650f813c5 | ||
|
|
2267d27c9b | ||
|
|
598d0bdda3 | ||
|
|
0bb3c84b9e | ||
|
|
cf7f118784 | ||
|
|
3dedc1f824 | ||
|
|
3d921f4570 | ||
|
|
bd86e3d951 | ||
|
|
b131d676c4 | ||
|
|
036f08a729 | ||
|
|
f4ffcebb14 | ||
|
|
bd2ec7b2af | ||
|
|
57814cf855 | ||
|
|
66cb35b5fc | ||
|
|
9be8be49ef | ||
|
|
3512db1fe7 | ||
|
|
367d024a2d | ||
|
|
7ca9afad57 | ||
|
|
f79348817f | ||
|
|
a2e474c375 | ||
|
|
d9722a9825 | ||
|
|
95a8e64fbb | ||
|
|
3492558e06 | ||
|
|
dbd8efbf16 | ||
|
|
2fb4bd4975 | ||
|
|
7ae8049438 | ||
|
|
276301dc87 | ||
|
|
d4c7ad4beb | ||
|
|
3aac1b2715 | ||
|
|
1b39ba70cb | ||
|
|
fd2d7fe14d | ||
|
|
fb470eec79 | ||
|
|
7bd1c6e115 | ||
|
|
6039002ed5 | ||
|
|
7a507505aa | ||
|
|
b5e2c83fba | ||
|
|
d982ce13f5 | ||
|
|
2b833413cf | ||
|
|
cc55bec521 | ||
|
|
2f567af80b | ||
|
|
0b3cfdce32 | ||
|
|
ae5832b8a5 | ||
|
|
2b78a8cb51 | ||
|
|
84785b7a60 | ||
|
|
6598ce2fe4 | ||
|
|
42e46a7c22 | ||
|
|
56ab34a57f | ||
|
|
ac56fa36ba | ||
|
|
8752680233 | ||
|
|
81a8efcca3 | ||
|
|
8ff168283c | ||
|
|
c2f16f740b | ||
|
|
c35e5b33d1 | ||
|
|
50204599b4 | ||
|
|
bec7cffe2a | ||
|
|
4bf2fb85e3 | ||
|
|
4e4d410803 | ||
|
|
cf68414c40 | ||
|
|
538aa45e8b | ||
|
|
92bf7ebc52 | ||
|
|
2e1ddc9ae1 | ||
|
|
c5b6971447 | ||
|
|
8dcb4be8a8 | ||
|
|
35657a7bbd | ||
|
|
c9fb0729f3 | ||
|
|
d499d20a9c | ||
|
|
d3dfeeccc3 | ||
|
|
2772a38dae |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,2 +1 @@
|
|||||||
github: [binwiederhier]
|
github: [binwiederhier]
|
||||||
liberapay: ntfy
|
|
||||||
|
|||||||
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -8,7 +8,7 @@ jobs:
|
|||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.18.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
|
|||||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.18.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
|
|||||||
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@@ -8,7 +8,7 @@ jobs:
|
|||||||
name: Install Go
|
name: Install Go
|
||||||
uses: actions/setup-go@v2
|
uses: actions/setup-go@v2
|
||||||
with:
|
with:
|
||||||
go-version: '1.19.x'
|
go-version: '1.18.x'
|
||||||
-
|
-
|
||||||
name: Install node
|
name: Install node
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -88,6 +88,7 @@ build-deps-ubuntu:
|
|||||||
curl \
|
curl \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
gcc-arm-linux-gnueabi \
|
gcc-arm-linux-gnueabi \
|
||||||
|
upx \
|
||||||
jq
|
jq
|
||||||
which pip3 || sudo apt install -y python3-pip
|
which pip3 || sudo apt install -y python3-pip
|
||||||
|
|
||||||
@@ -200,6 +201,7 @@ cli-deps-static-sites:
|
|||||||
touch server/docs/index.html server/site/app.html
|
touch server/docs/index.html server/site/app.html
|
||||||
|
|
||||||
cli-deps-all:
|
cli-deps-all:
|
||||||
|
which upx || { echo "ERROR: upx not installed. On Ubuntu, run: apt install upx"; exit 1; }
|
||||||
go install github.com/goreleaser/goreleaser@latest
|
go install github.com/goreleaser/goreleaser@latest
|
||||||
|
|
||||||
cli-deps-gcc-armv6-armv7:
|
cli-deps-gcc-armv6-armv7:
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -61,9 +61,9 @@ for the server and the Android app. Or, if you'd like to help translate 🇩🇪
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Sponsors
|
## Sponsors
|
||||||
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier),
|
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
|
||||||
and [Liberapay](https://liberapay.com/ntfy). I would be humbled if you helped me carry the server and developer
|
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
|
||||||
account costs. Even small donations are very much appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
appreciated. A big fat **Thank You** to the folks already sponsoring ntfy:
|
||||||
|
|
||||||
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
|
||||||
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
|
||||||
@@ -110,18 +110,11 @@ account costs. Even small donations are very much appreciated. A big fat **Thank
|
|||||||
<a href="https://github.com/biopsin"><img src="https://github.com/biopsin.png" width="40px" /></a>
|
<a href="https://github.com/biopsin"><img src="https://github.com/biopsin.png" width="40px" /></a>
|
||||||
<a href="https://github.com/thebino"><img src="https://github.com/thebino.png" width="40px" /></a>
|
<a href="https://github.com/thebino"><img src="https://github.com/thebino.png" width="40px" /></a>
|
||||||
<a href="https://github.com/sky4055"><img src="https://github.com/sky4055.png" width="40px" /></a>
|
<a href="https://github.com/sky4055"><img src="https://github.com/sky4055.png" width="40px" /></a>
|
||||||
<a href="https://github.com/julianlam"><img src="https://github.com/julianlam.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/andreapx"><img src="https://github.com/andreapx.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/billycao"><img src="https://github.com/billycao.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/zoic21"><img src="https://github.com/zoic21.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/IanKulin"><img src="https://github.com/IanKulin.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/Joachim256"><img src="https://github.com/Joachim256.png" width="40px" /></a>
|
|
||||||
<a href="https://github.com/overtone1000"><img src="https://github.com/overtone1000.png" width="40px" /></a>
|
|
||||||
|
|
||||||
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
I'd also like to thank JetBrains for providing their awesome [IntelliJ IDEA](https://www.jetbrains.com/idea/) to me for free,
|
||||||
and [DigitalOcean](https://m.do.co/c/442b929528db) (*referral link*) for supporting the project:
|
and [DigitalOcean](https://www.digitalocean.com/) for supporting the project:
|
||||||
|
|
||||||
<a href="https://m.do.co/c/442b929528db"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
<a href="https://www.digitalocean.com/"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px"></a>
|
||||||
|
|
||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
|
||||||
|
|||||||
122
auth/auth.go
122
auth/auth.go
@@ -1,122 +0,0 @@
|
|||||||
// 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")
|
|
||||||
)
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/auth"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,13 +71,13 @@ func execUserAccess(c *cli.Context) error {
|
|||||||
if c.NArg() > 3 {
|
if c.NArg() > 3 {
|
||||||
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
|
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
|
||||||
}
|
}
|
||||||
manager, err := createAuthManager(c)
|
manager, err := createUserManager(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
username := c.Args().Get(0)
|
username := c.Args().Get(0)
|
||||||
if username == userEveryone {
|
if username == userEveryone {
|
||||||
username = auth.Everyone
|
username = user.Everyone
|
||||||
}
|
}
|
||||||
topic := c.Args().Get(1)
|
topic := c.Args().Get(1)
|
||||||
perms := c.Args().Get(2)
|
perms := c.Args().Get(2)
|
||||||
@@ -96,26 +96,28 @@ func execUserAccess(c *cli.Context) error {
|
|||||||
return changeAccess(c, manager, username, topic, perms)
|
return changeAccess(c, manager, username, topic, perms)
|
||||||
}
|
}
|
||||||
|
|
||||||
func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error {
|
func changeAccess(c *cli.Context, manager *user.Manager, username string, topic string, perms string) error {
|
||||||
if !util.Contains([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) {
|
if !util.Contains([]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)")
|
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.Contains([]string{"read-write", "rw", "read-only", "read", "ro"}, perms)
|
permission, err := user.ParsePermission(perms)
|
||||||
write := util.Contains([]string{"read-write", "rw", "write-only", "write", "wo"}, perms)
|
if err != nil {
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
if read && write {
|
u, err := manager.User(username)
|
||||||
|
if err == user.ErrUserNotFound {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
} else if u.Role == user.RoleAdmin {
|
||||||
|
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
|
||||||
|
}
|
||||||
|
if err := manager.AllowAccess(username, topic, permission); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if permission.IsReadWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
|
||||||
} else if read {
|
} else if permission.IsRead() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
|
||||||
} else if write {
|
} else if permission.IsWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
|
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
|
||||||
@@ -123,7 +125,7 @@ func changeAccess(c *cli.Context, manager auth.Manager, username string, topic s
|
|||||||
return showUserAccess(c, manager, username)
|
return showUserAccess(c, manager, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetAccess(c *cli.Context, manager auth.Manager, username, topic string) error {
|
func resetAccess(c *cli.Context, manager *user.Manager, username, topic string) error {
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return resetAllAccess(c, manager)
|
return resetAllAccess(c, manager)
|
||||||
} else if topic == "" {
|
} else if topic == "" {
|
||||||
@@ -132,7 +134,7 @@ func resetAccess(c *cli.Context, manager auth.Manager, username, topic string) e
|
|||||||
return resetUserTopicAccess(c, manager, username, topic)
|
return resetUserTopicAccess(c, manager, username, topic)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetAllAccess(c *cli.Context, manager auth.Manager) error {
|
func resetAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||||
if err := manager.ResetAccess("", ""); err != nil {
|
if err := manager.ResetAccess("", ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -140,7 +142,7 @@ func resetAllAccess(c *cli.Context, manager auth.Manager) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetUserAccess(c *cli.Context, manager auth.Manager, username string) error {
|
func resetUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||||
if err := manager.ResetAccess(username, ""); err != nil {
|
if err := manager.ResetAccess(username, ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -148,7 +150,7 @@ func resetUserAccess(c *cli.Context, manager auth.Manager, username string) erro
|
|||||||
return showUserAccess(c, manager, username)
|
return showUserAccess(c, manager, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string, topic string) error {
|
func resetUserTopicAccess(c *cli.Context, manager *user.Manager, username string, topic string) error {
|
||||||
if err := manager.ResetAccess(username, topic); err != nil {
|
if err := manager.ResetAccess(username, topic); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -156,14 +158,14 @@ func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string,
|
|||||||
return showUserAccess(c, manager, username)
|
return showUserAccess(c, manager, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showAccess(c *cli.Context, manager auth.Manager, username string) error {
|
func showAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return showAllAccess(c, manager)
|
return showAllAccess(c, manager)
|
||||||
}
|
}
|
||||||
return showUserAccess(c, manager, username)
|
return showUserAccess(c, manager, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showAllAccess(c *cli.Context, manager auth.Manager) error {
|
func showAllAccess(c *cli.Context, manager *user.Manager) error {
|
||||||
users, err := manager.Users()
|
users, err := manager.Users()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -171,28 +173,32 @@ func showAllAccess(c *cli.Context, manager auth.Manager) error {
|
|||||||
return showUsers(c, manager, users)
|
return showUsers(c, manager, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
func showUserAccess(c *cli.Context, manager auth.Manager, username string) error {
|
func showUserAccess(c *cli.Context, manager *user.Manager, username string) error {
|
||||||
users, err := manager.User(username)
|
users, err := manager.User(username)
|
||||||
if err == auth.ErrNotFound {
|
if err == user.ErrUserNotFound {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return showUsers(c, manager, []*auth.User{users})
|
return showUsers(c, manager, []*user.User{users})
|
||||||
}
|
}
|
||||||
|
|
||||||
func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error {
|
func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error {
|
||||||
for _, user := range users {
|
for _, u := range users {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", user.Name, user.Role)
|
grants, err := manager.Grants(u.Name)
|
||||||
if user.Role == auth.RoleAdmin {
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", u.Name, u.Role)
|
||||||
|
if u.Role == user.RoleAdmin {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
|
||||||
} else if len(user.Grants) > 0 {
|
} else if len(grants) > 0 {
|
||||||
for _, grant := range user.Grants {
|
for _, grant := range grants {
|
||||||
if grant.AllowRead && grant.AllowWrite {
|
if grant.Allow.IsReadWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
|
||||||
} else if grant.AllowRead {
|
} else if grant.Allow.IsRead() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
|
||||||
} else if grant.AllowWrite {
|
} else if grant.Allow.IsWrite() {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
|
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
|
||||||
@@ -201,13 +207,13 @@ func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error {
|
|||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
|
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
|
||||||
}
|
}
|
||||||
if user.Name == auth.Everyone {
|
if u.Name == user.Everyone {
|
||||||
defaultRead, defaultWrite := manager.DefaultAccess()
|
access := manager.DefaultAccess()
|
||||||
if defaultRead && defaultWrite {
|
if access.IsReadWrite() {
|
||||||
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
|
||||||
} else if defaultRead {
|
} else if access.IsRead() {
|
||||||
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
|
||||||
} else if defaultWrite {
|
} else if access.IsWrite() {
|
||||||
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
|
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
|
|||||||
"ntfy",
|
"ntfy",
|
||||||
"access",
|
"access",
|
||||||
"--auth-file=" + conf.AuthFile,
|
"--auth-file=" + conf.AuthFile,
|
||||||
"--auth-default-access=" + confToDefaultAccess(conf),
|
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||||
}
|
}
|
||||||
return app.Run(append(userArgs, args...))
|
return app.Run(append(userArgs, args...))
|
||||||
}
|
}
|
||||||
|
|||||||
42
cmd/serve.go
42
cmd/serve.go
@@ -5,6 +5,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
@@ -48,6 +50,7 @@ var flagsServe = append(
|
|||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "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: "auth-default-access", Aliases: []string{"auth_default_access", "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", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
|
||||||
@@ -56,6 +59,9 @@ var flagsServe = append(
|
|||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"keepalive_interval", "k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}),
|
||||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"manager_interval", "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{"manager_interval", "m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-root", Aliases: []string{"web_root"}, EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home), web app (app) or disabled (disable)"}),
|
||||||
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "allows users to sign up via the web app, or API"}),
|
||||||
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "allows users to log in via the web app, or API"}),
|
||||||
|
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reservations", Aliases: []string{"enable_reservations"}, EnvVars: []string{"NTFY_ENABLE_RESERVATIONS"}, Value: false, Usage: "allows users to reserve topics (if their tier allows it)"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "upstream-base-url", Aliases: []string{"upstream_base_url"}, EnvVars: []string{"NTFY_UPSTREAM_BASE_URL"}, Value: "", Usage: "forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", Aliases: []string{"smtp_sender_addr"}, EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||||
@@ -74,6 +80,8 @@ var flagsServe = append(
|
|||||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
|
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"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", Aliases: []string{"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.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"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{"behind_proxy", "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{"behind_proxy", "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.NewStringFlag(&cli.StringFlag{Name: "stripe-secret-key", Aliases: []string{"stripe_secret_key"}, EnvVars: []string{"NTFY_STRIPE_SECRET_KEY"}, Value: "", Usage: "key used for the Stripe API communication, this enables payments"}),
|
||||||
|
altsrc.NewStringFlag(&cli.StringFlag{Name: "stripe-webhook-key", Aliases: []string{"stripe_webhook_key"}, EnvVars: []string{"NTFY_STRIPE_WEBHOOK_KEY"}, Value: "", Usage: "key required to validate the authenticity of incoming webhooks from Stripe"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
var cmdServe = &cli.Command{
|
var cmdServe = &cli.Command{
|
||||||
@@ -115,6 +123,7 @@ func execServe(c *cli.Context) error {
|
|||||||
cacheBatchSize := c.Int("cache-batch-size")
|
cacheBatchSize := c.Int("cache-batch-size")
|
||||||
cacheBatchTimeout := c.Duration("cache-batch-timeout")
|
cacheBatchTimeout := c.Duration("cache-batch-timeout")
|
||||||
authFile := c.String("auth-file")
|
authFile := c.String("auth-file")
|
||||||
|
authStartupQueries := c.String("auth-startup-queries")
|
||||||
authDefaultAccess := c.String("auth-default-access")
|
authDefaultAccess := c.String("auth-default-access")
|
||||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||||
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
|
||||||
@@ -123,6 +132,9 @@ func execServe(c *cli.Context) error {
|
|||||||
keepaliveInterval := c.Duration("keepalive-interval")
|
keepaliveInterval := c.Duration("keepalive-interval")
|
||||||
managerInterval := c.Duration("manager-interval")
|
managerInterval := c.Duration("manager-interval")
|
||||||
webRoot := c.String("web-root")
|
webRoot := c.String("web-root")
|
||||||
|
enableSignup := c.Bool("enable-signup")
|
||||||
|
enableLogin := c.Bool("enable-login")
|
||||||
|
enableReservations := c.Bool("enable-reservations")
|
||||||
upstreamBaseURL := c.String("upstream-base-url")
|
upstreamBaseURL := c.String("upstream-base-url")
|
||||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||||
smtpSenderUser := c.String("smtp-sender-user")
|
smtpSenderUser := c.String("smtp-sender-user")
|
||||||
@@ -141,6 +153,8 @@ func execServe(c *cli.Context) error {
|
|||||||
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
|
||||||
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
|
||||||
behindProxy := c.Bool("behind-proxy")
|
behindProxy := c.Bool("behind-proxy")
|
||||||
|
stripeSecretKey := c.String("stripe-secret-key")
|
||||||
|
stripeWebhookKey := c.String("stripe-webhook-key")
|
||||||
|
|
||||||
// Check values
|
// Check values
|
||||||
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
|
||||||
@@ -167,8 +181,6 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("if set, base-url must start with http:// or https://")
|
return errors.New("if set, base-url must start with http:// or https://")
|
||||||
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
|
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
|
||||||
return errors.New("if set, base-url must not end with a slash (/)")
|
return errors.New("if set, base-url must not end with a slash (/)")
|
||||||
} else if !util.Contains([]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'")
|
|
||||||
} else if !util.Contains([]string{"app", "home", "disable"}, webRoot) {
|
} else if !util.Contains([]string{"app", "home", "disable"}, webRoot) {
|
||||||
return errors.New("if set, web-root must be 'home' or 'app'")
|
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||||
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
|
||||||
@@ -179,14 +191,22 @@ func execServe(c *cli.Context) error {
|
|||||||
return errors.New("if upstream-base-url is set, base-url must also be set")
|
return errors.New("if upstream-base-url is set, base-url must also be set")
|
||||||
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
|
||||||
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
||||||
|
} else if authFile == "" && (enableSignup || enableLogin || enableReservations || stripeSecretKey != "") {
|
||||||
|
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or stripe-secret-key if auth-file is not set")
|
||||||
|
} else if enableSignup && !enableLogin {
|
||||||
|
return errors.New("cannot set enable-signup without also setting enable-login")
|
||||||
|
} else if stripeSecretKey != "" && (stripeWebhookKey == "" || baseURL == "") {
|
||||||
|
return errors.New("if stripe-secret-key is set, stripe-webhook-key and base-url must also be set")
|
||||||
}
|
}
|
||||||
|
|
||||||
webRootIsApp := webRoot == "app"
|
webRootIsApp := webRoot == "app"
|
||||||
enableWeb := webRoot != "disable"
|
enableWeb := webRoot != "disable"
|
||||||
|
|
||||||
// Default auth permissions
|
// Default auth permissions
|
||||||
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||||
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
if err != nil {
|
||||||
|
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||||
|
}
|
||||||
|
|
||||||
// Special case: Unset default
|
// Special case: Unset default
|
||||||
if listenHTTP == "-" {
|
if listenHTTP == "-" {
|
||||||
@@ -224,6 +244,11 @@ func execServe(c *cli.Context) error {
|
|||||||
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...)
|
visitorRequestLimitExemptIPs = append(visitorRequestLimitExemptIPs, ips...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stripe things
|
||||||
|
if stripeSecretKey != "" {
|
||||||
|
stripe.Key = stripeSecretKey
|
||||||
|
}
|
||||||
|
|
||||||
// Run server
|
// Run server
|
||||||
conf := server.NewConfig()
|
conf := server.NewConfig()
|
||||||
conf.BaseURL = baseURL
|
conf.BaseURL = baseURL
|
||||||
@@ -240,8 +265,8 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.CacheBatchSize = cacheBatchSize
|
conf.CacheBatchSize = cacheBatchSize
|
||||||
conf.CacheBatchTimeout = cacheBatchTimeout
|
conf.CacheBatchTimeout = cacheBatchTimeout
|
||||||
conf.AuthFile = authFile
|
conf.AuthFile = authFile
|
||||||
conf.AuthDefaultRead = authDefaultRead
|
conf.AuthStartupQueries = authStartupQueries
|
||||||
conf.AuthDefaultWrite = authDefaultWrite
|
conf.AuthDefault = authDefault
|
||||||
conf.AttachmentCacheDir = attachmentCacheDir
|
conf.AttachmentCacheDir = attachmentCacheDir
|
||||||
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
|
||||||
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
|
||||||
@@ -267,7 +292,12 @@ func execServe(c *cli.Context) error {
|
|||||||
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
conf.VisitorEmailLimitBurst = visitorEmailLimitBurst
|
||||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||||
conf.BehindProxy = behindProxy
|
conf.BehindProxy = behindProxy
|
||||||
|
conf.StripeSecretKey = stripeSecretKey
|
||||||
|
conf.StripeWebhookKey = stripeWebhookKey
|
||||||
conf.EnableWeb = enableWeb
|
conf.EnableWeb = enableWeb
|
||||||
|
conf.EnableSignup = enableSignup
|
||||||
|
conf.EnableLogin = enableLogin
|
||||||
|
conf.EnableReservations = enableReservations
|
||||||
conf.Version = c.App.Version
|
conf.Version = c.App.Version
|
||||||
|
|
||||||
// Set up hot-reloading of config
|
// Set up hot-reloading of config
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build linux || dragonfly || freebsd || netbsd || openbsd
|
//go:build linux || dragonfly || freebsd || netbsd || openbsd
|
||||||
// +build linux dragonfly freebsd netbsd openbsd
|
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
|
|||||||
95
cmd/user.go
95
cmd/user.go
@@ -6,15 +6,20 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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/auth"
|
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tierReset = "-"
|
||||||
|
createdByCLI = "cli"
|
||||||
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
commands = append(commands, cmdUser)
|
commands = append(commands, cmdUser)
|
||||||
}
|
}
|
||||||
@@ -41,7 +46,7 @@ var cmdUser = &cli.Command{
|
|||||||
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME",
|
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME",
|
||||||
Action: execUserAdd,
|
Action: execUserAdd,
|
||||||
Flags: []cli.Flag{
|
Flags: []cli.Flag{
|
||||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
|
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(user.RoleUser), Usage: "user role"},
|
||||||
},
|
},
|
||||||
Description: `Add a new user to the ntfy user database.
|
Description: `Add a new user to the ntfy user database.
|
||||||
|
|
||||||
@@ -110,6 +115,22 @@ user are removed, since they are no longer necessary.
|
|||||||
Example:
|
Example:
|
||||||
ntfy user change-role phil admin # Make user phil an admin
|
ntfy user change-role phil admin # Make user phil an admin
|
||||||
ntfy user change-role phil user # Remove admin role from user phil
|
ntfy user change-role phil user # Remove admin role from user phil
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "change-tier",
|
||||||
|
Aliases: []string{"cht"},
|
||||||
|
Usage: "Changes the tier of a user",
|
||||||
|
UsageText: "ntfy user change-tier USERNAME (TIER|-)",
|
||||||
|
Action: execUserChangeTier,
|
||||||
|
Description: `Change the tier for the given user.
|
||||||
|
|
||||||
|
This command can be used to change the tier of a user. Tiers define usage limits, such
|
||||||
|
as messages per day, attachment file sizes, etc.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
ntfy user change-tier phil pro # Change tier to "pro" for user "phil"
|
||||||
|
ntfy user change-tier phil - # Remove tier from user "phil" entirely
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -152,16 +173,16 @@ variable to pass the new password. This is useful if you are creating/updating u
|
|||||||
|
|
||||||
func execUserAdd(c *cli.Context) error {
|
func execUserAdd(c *cli.Context) error {
|
||||||
username := c.Args().Get(0)
|
username := c.Args().Get(0)
|
||||||
role := auth.Role(c.String("role"))
|
role := user.Role(c.String("role"))
|
||||||
password := os.Getenv("NTFY_PASSWORD")
|
password := os.Getenv("NTFY_PASSWORD")
|
||||||
if username == "" {
|
if username == "" {
|
||||||
return errors.New("username expected, type 'ntfy user add --help' for help")
|
return errors.New("username expected, type 'ntfy user add --help' for help")
|
||||||
} else if username == userEveryone {
|
} else if username == userEveryone {
|
||||||
return errors.New("username not allowed")
|
return errors.New("username not allowed")
|
||||||
} else if !auth.AllowedRole(role) {
|
} else if !user.AllowedRole(role) {
|
||||||
return errors.New("role must be either 'user' or 'admin'")
|
return errors.New("role must be either 'user' or 'admin'")
|
||||||
}
|
}
|
||||||
manager, err := createAuthManager(c)
|
manager, err := createUserManager(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -176,7 +197,7 @@ func execUserAdd(c *cli.Context) error {
|
|||||||
|
|
||||||
password = p
|
password = p
|
||||||
}
|
}
|
||||||
if err := manager.AddUser(username, password, role); err != nil {
|
if err := manager.AddUser(username, password, role, createdByCLI); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
|
||||||
@@ -190,11 +211,11 @@ func execUserDel(c *cli.Context) error {
|
|||||||
} else if username == userEveryone {
|
} else if username == userEveryone {
|
||||||
return errors.New("username not allowed")
|
return errors.New("username not allowed")
|
||||||
}
|
}
|
||||||
manager, err := createAuthManager(c)
|
manager, err := createUserManager(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := manager.User(username); err == auth.ErrNotFound {
|
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if err := manager.RemoveUser(username); err != nil {
|
if err := manager.RemoveUser(username); err != nil {
|
||||||
@@ -212,11 +233,11 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
} else if username == userEveryone {
|
} else if username == userEveryone {
|
||||||
return errors.New("username not allowed")
|
return errors.New("username not allowed")
|
||||||
}
|
}
|
||||||
manager, err := createAuthManager(c)
|
manager, err := createUserManager(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := manager.User(username); err == auth.ErrNotFound {
|
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if password == "" {
|
if password == "" {
|
||||||
@@ -234,17 +255,17 @@ func execUserChangePass(c *cli.Context) error {
|
|||||||
|
|
||||||
func execUserChangeRole(c *cli.Context) error {
|
func execUserChangeRole(c *cli.Context) error {
|
||||||
username := c.Args().Get(0)
|
username := c.Args().Get(0)
|
||||||
role := auth.Role(c.Args().Get(1))
|
role := user.Role(c.Args().Get(1))
|
||||||
if username == "" || !auth.AllowedRole(role) {
|
if username == "" || !user.AllowedRole(role) {
|
||||||
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
|
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
|
||||||
} else if username == userEveryone {
|
} else if username == userEveryone {
|
||||||
return errors.New("username not allowed")
|
return errors.New("username not allowed")
|
||||||
}
|
}
|
||||||
manager, err := createAuthManager(c)
|
manager, err := createUserManager(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := manager.User(username); err == auth.ErrNotFound {
|
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||||
return fmt.Errorf("user %s does not exist", username)
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
}
|
}
|
||||||
if err := manager.ChangeRole(username, role); err != nil {
|
if err := manager.ChangeRole(username, role); err != nil {
|
||||||
@@ -254,8 +275,39 @@ func execUserChangeRole(c *cli.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execUserChangeTier(c *cli.Context) error {
|
||||||
|
username := c.Args().Get(0)
|
||||||
|
tier := c.Args().Get(1)
|
||||||
|
if username == "" {
|
||||||
|
return errors.New("username and new tier expected, type 'ntfy user change-tier --help' for help")
|
||||||
|
} else if !user.AllowedTier(tier) && tier != tierReset {
|
||||||
|
return errors.New("invalid tier, must be tier code, or - to reset")
|
||||||
|
} else if username == userEveryone {
|
||||||
|
return errors.New("username not allowed")
|
||||||
|
}
|
||||||
|
manager, err := createUserManager(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := manager.User(username); err == user.ErrUserNotFound {
|
||||||
|
return fmt.Errorf("user %s does not exist", username)
|
||||||
|
}
|
||||||
|
if tier == tierReset {
|
||||||
|
if err := manager.ResetTier(username); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "removed tier from user %s\n", username)
|
||||||
|
} else {
|
||||||
|
if err := manager.ChangeTier(username, tier); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c.App.ErrWriter, "changed tier for user %s to %s\n", username, tier)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func execUserList(c *cli.Context) error {
|
func execUserList(c *cli.Context) error {
|
||||||
manager, err := createAuthManager(c)
|
manager, err := createUserManager(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -266,19 +318,20 @@ func execUserList(c *cli.Context) error {
|
|||||||
return showUsers(c, manager, users)
|
return showUsers(c, manager, users)
|
||||||
}
|
}
|
||||||
|
|
||||||
func createAuthManager(c *cli.Context) (auth.Manager, error) {
|
func createUserManager(c *cli.Context) (*user.Manager, error) {
|
||||||
authFile := c.String("auth-file")
|
authFile := c.String("auth-file")
|
||||||
|
authStartupQueries := c.String("auth-startup-queries")
|
||||||
authDefaultAccess := c.String("auth-default-access")
|
authDefaultAccess := c.String("auth-default-access")
|
||||||
if authFile == "" {
|
if authFile == "" {
|
||||||
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
|
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
|
||||||
} else if !util.FileExists(authFile) {
|
} else if !util.FileExists(authFile) {
|
||||||
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
|
||||||
} else if !util.Contains([]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"
|
authDefault, err := user.ParsePermission(authDefaultAccess)
|
||||||
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
if err != nil {
|
||||||
return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite)
|
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||||
|
}
|
||||||
|
return user.NewManager(authFile, authStartupQueries, authDefault)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
func readPasswordAndConfirm(c *cli.Context) (string, error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/urfave/cli/v2"
|
"github.com/urfave/cli/v2"
|
||||||
"heckel.io/ntfy/server"
|
"heckel.io/ntfy/server"
|
||||||
"heckel.io/ntfy/test"
|
"heckel.io/ntfy/test"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -114,8 +115,7 @@ func TestCLI_User_Delete(t *testing.T) {
|
|||||||
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
|
||||||
conf = server.NewConfig()
|
conf = server.NewConfig()
|
||||||
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
conf.AuthDefaultRead = false
|
conf.AuthDefault = user.PermissionDenyAll
|
||||||
conf.AuthDefaultWrite = false
|
|
||||||
s, port = test.StartServerWithConfig(t, conf)
|
s, port = test.StartServerWithConfig(t, conf)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -125,21 +125,7 @@ func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
|
|||||||
"ntfy",
|
"ntfy",
|
||||||
"user",
|
"user",
|
||||||
"--auth-file=" + conf.AuthFile,
|
"--auth-file=" + conf.AuthFile,
|
||||||
"--auth-default-access=" + confToDefaultAccess(conf),
|
"--auth-default-access=" + conf.AuthDefault.String(),
|
||||||
}
|
}
|
||||||
return app.Run(append(userArgs, args...))
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1034,6 +1034,11 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
|||||||
| `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-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-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
|
||||||
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
|
| `web-root` | `NTFY_WEB_ROOT` | `app`, `home` or `disable` | `app` | Sets web root to landing page (home), web app (app) or disables the web app entirely (disable) |
|
||||||
|
| `enable-signup` | `NTFY_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
|
||||||
|
| `enable-login` | `NTFY_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
|
||||||
|
| `enable-reservations` | `NTFY_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) |
|
||||||
|
| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments |
|
||||||
|
| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe |
|
||||||
|
|
||||||
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.
|
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ sudo apt install \
|
|||||||
gcc-arm-linux-gnueabi \
|
gcc-arm-linux-gnueabi \
|
||||||
gcc-aarch64-linux-gnu \
|
gcc-aarch64-linux-gnu \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
|
upx \
|
||||||
git
|
git
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -327,76 +328,7 @@ To build your own version with Firebase, you must:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## iOS app
|
## iOS app
|
||||||
Building the iOS app is very involved. Please report any inconsistencies or issues with it. The requirements are
|
The ntfy iOS app source code is available [on GitHub](https://github.com/binwiederhier/ntfy-ios).
|
||||||
strictly based off of my development on this app. There may be other versions of macOS / XCode that work.
|
|
||||||
|
|
||||||
### Requirements
|
|
||||||
1. macOS Monterey or later
|
|
||||||
1. XCode 13.2+
|
|
||||||
1. A physical iOS device (for push notifications, Firebase does not work in the XCode simulator)
|
|
||||||
1. Firebase account
|
|
||||||
1. Apple Developer license? (I forget if it's possible to do testing without purchasing the license)
|
|
||||||
|
|
||||||
### Apple setup
|
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
Along with this step, the [PLIST Deployment](#plist-deployment-and-configuration) step is also required
|
I haven't had time to move the build instructions here. Please check out the repository instead.
|
||||||
for these changes to take effect in the iOS app.
|
|
||||||
|
|
||||||
1. [Create a new key in Apple Developer Member Center](https://developer.apple.com/account/resources/authkeys/add)
|
|
||||||
1. Select "Apple Push Notifications service (APNs)"
|
|
||||||
1. Download the newly created key (should have a file name similar to `AuthKey_ZZZZZZ.p8`, where `ZZZZZZ` is the **Key ID**)
|
|
||||||
1. Record your **Team ID** - it can be seen in the top-right corner of the page, or on your Account > Membership page
|
|
||||||
1. Next, navigate to "Project Settings" in the firebase console for your project, and select the iOS app you created. Then, click "Cloud Messaging" in the left sidebar, and scroll down to the "APNs Authentication Key" section. Click "Upload Key", and upload the key you downloaded from Apple Developer.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
If you don't do the above setups for APNS, **notifications will not post instantly or sometimes at all**. This is because of the missing APNS key, which is required for firebase to send notifications to the iOS app. See below for a snip from the firebase docs.
|
|
||||||
|
|
||||||
If you don't have an APNs authentication key, you can still send notifications to iOS devices, but they won't be delivered
|
|
||||||
instantly. Instead, they'll be delivered when the device wakes up to check for new notifications or when your application
|
|
||||||
sends a firebase request to check for them. The time to check for new notifications can vary from a few seconds to hours,
|
|
||||||
days or even weeks. Enabling APNs authentication keys ensures that notifications are delivered instantly and is strongly
|
|
||||||
recommended.
|
|
||||||
|
|
||||||
### Firebase setup
|
|
||||||
|
|
||||||
1. If you haven't already, create a Google / Firebase account
|
|
||||||
1. Visit the [Firebase console](https://console.firebase.google.com)
|
|
||||||
1. Create a new Firebase project:
|
|
||||||
1. Enter a project name
|
|
||||||
1. Disable Google Analytics (currently iOS app does not support analytics)
|
|
||||||
1. On the "Project settings" page, add an iOS app
|
|
||||||
1. Apple bundle ID - "com.copephobia.ntfy-ios" (this can be changed to match XCode's ntfy.sh target > "Bundle Identifier" value)
|
|
||||||
1. Register the app
|
|
||||||
1. Download the config file - GoogleInfo.plist (this will need to be included in the ntfy-ios repository / XCode)
|
|
||||||
1. Generate a new service account private key for the ntfy server
|
|
||||||
1. Go to "Project settings" > "Service accounts"
|
|
||||||
1. Click "Generate new private key" to generate and download a private key to use for sending messages via the ntfy server
|
|
||||||
|
|
||||||
### ntfy server
|
|
||||||
Note that the ntfy server is not officially supported on macOS. It should, however, be able to run on macOS using these
|
|
||||||
steps:
|
|
||||||
|
|
||||||
1. If not already made, make the `/etc/ntfy/` directory and move the service account private key to that folder
|
|
||||||
1. Copy the `server/server.yml` file from the ntfy repository to `/etc/ntfy/`
|
|
||||||
1. Modify the `/etc/ntfy/server.yml` file `firebase-key-file` value to the path of the private key
|
|
||||||
1. Install go: `brew install go`
|
|
||||||
1. In the ntfy repository, run `make cli-darwin-server`.
|
|
||||||
|
|
||||||
### XCode setup
|
|
||||||
|
|
||||||
1. Follow step 4 of [https://firebase.google.com/docs/ios/setup](Add Firebase to your Apple project) to install the
|
|
||||||
`firebase-ios-sdk` in XCode, if it's not already present - you can select any packages in addition to Firebase Core / Firebase Messaging
|
|
||||||
1. Similarly, install the SQLite.swift package dependency in XCode
|
|
||||||
1. When running the debug build, ensure XCode is pointed to the connected iOS device - registering for push notifications does not work in the iOS simulators
|
|
||||||
|
|
||||||
### PLIST config
|
|
||||||
To have instant notifications/better notification delivery when using firebase, you will need to add the
|
|
||||||
`GoogleService-Info.plist` file to your project. Here's how to do that:
|
|
||||||
|
|
||||||
1. In XCode, find the NTFY app target. **Not** the NSE app target.
|
|
||||||
1. Find the Asset/ folder in the project navigator
|
|
||||||
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
|
|
||||||
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
|
||||||
|
|
||||||
After that, you should be all set!
|
|
||||||
|
|||||||
@@ -413,8 +413,7 @@ alerting:
|
|||||||
|
|
||||||
## Jellyseerr/Overseerr webhook
|
## Jellyseerr/Overseerr webhook
|
||||||
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
Here is an example for [jellyseerr](https://github.com/Fallenbagel/jellyseerr)/[overseerr](https://overseerr.dev/) webhook
|
||||||
JSON payload. Remember to change the `https://request.example.com` to your URL as the value of the JSON key click.
|
JSON payload. Remember to change the `https://requests.example.com` to your jellyseerr/overseerr URL.
|
||||||
And if you're not using the request `topic`, make sure to change it in the JSON payload to your topic.
|
|
||||||
|
|
||||||
``` json
|
``` json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_x86_64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_x86_64.tar.gz
|
||||||
tar zxvf ntfy_1.31.0_linux_x86_64.tar.gz
|
tar zxvf ntfy_1.30.1_linux_x86_64.tar.gz
|
||||||
sudo cp -a ntfy_1.31.0_linux_x86_64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.31.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_armv6.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.tar.gz
|
||||||
tar zxvf ntfy_1.31.0_linux_armv6.tar.gz
|
tar zxvf ntfy_1.30.1_linux_armv6.tar.gz
|
||||||
sudo cp -a ntfy_1.31.0_linux_armv6/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.31.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_armv7.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv7.tar.gz
|
||||||
tar zxvf ntfy_1.31.0_linux_armv7.tar.gz
|
tar zxvf ntfy_1.30.1_linux_armv7.tar.gz
|
||||||
sudo cp -a ntfy_1.31.0_linux_armv7/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.31.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_arm64.tar.gz
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.tar.gz
|
||||||
tar zxvf ntfy_1.31.0_linux_arm64.tar.gz
|
tar zxvf ntfy_1.30.1_linux_arm64.tar.gz
|
||||||
sudo cp -a ntfy_1.31.0_linux_arm64/ntfy /usr/bin/ntfy
|
sudo cp -a ntfy_1.30.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.31.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
sudo mkdir /etc/ntfy && sudo cp ntfy_1.30.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||||
sudo ntfy serve
|
sudo ntfy serve
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_amd64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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
|
||||||
@@ -114,7 +114,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_armv6.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.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
|
||||||
@@ -122,7 +122,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "armv7/armhf"
|
=== "armv7/armhf"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_armv7.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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
|
||||||
@@ -130,7 +130,7 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "arm64"
|
=== "arm64"
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_arm64.deb
|
wget https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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
|
||||||
@@ -140,28 +140,28 @@ Manually installing the .deb file:
|
|||||||
|
|
||||||
=== "x86_64/amd64"
|
=== "x86_64/amd64"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_amd64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_amd64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "armv6"
|
=== "armv6"
|
||||||
```bash
|
```bash
|
||||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_linux_armv6.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_armv6.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.31.0/ntfy_1.31.0_linux_armv7.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_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.31.0/ntfy_1.31.0_linux_arm64.rpm
|
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_linux_arm64.rpm
|
||||||
sudo systemctl enable ntfy
|
sudo systemctl enable ntfy
|
||||||
sudo systemctl start ntfy
|
sudo systemctl start ntfy
|
||||||
```
|
```
|
||||||
@@ -189,18 +189,18 @@ NixOS also supports [declarative setup of the ntfy server](https://search.nixos.
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_macOS_all.tar.gz),
|
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz),
|
||||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||||
|
|
||||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_macOS_all.tar.gz > ntfy_1.31.0_macOS_all.tar.gz
|
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_macOS_all.tar.gz > ntfy_1.30.1_macOS_all.tar.gz
|
||||||
tar zxvf ntfy_1.31.0_macOS_all.tar.gz
|
tar zxvf ntfy_1.30.1_macOS_all.tar.gz
|
||||||
sudo cp -a ntfy_1.31.0_macOS_all/ntfy /usr/local/bin/ntfy
|
sudo cp -a ntfy_1.30.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||||
mkdir ~/Library/Application\ Support/ntfy
|
mkdir ~/Library/Application\ Support/ntfy
|
||||||
cp ntfy_1.31.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
cp ntfy_1.30.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||||
ntfy --help
|
ntfy --help
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ ntfy --help
|
|||||||
|
|
||||||
## Windows
|
## Windows
|
||||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.31.0/ntfy_1.31.0_windows_x86_64.zip),
|
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.30.1/ntfy_1.30.1_windows_x86_64.zip),
|
||||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||||
|
|
||||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||||
@@ -287,7 +287,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files and attachments directory to the same uid/gid.
|
If using a non-root user when running the docker version, be sure to chown the server.yml, user.db, and cache.db files to the same uid/gid.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ and uptime of third party servers, so use of each server is **at your own discre
|
|||||||
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
- [Gatus](https://gatus.io/) ⭐ - Automated service health dashboard
|
||||||
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
- [Automatisch](https://automatisch.io/) ⭐ - Open source Zapier alternative / workflow automation tool
|
||||||
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
- [FlexGet](https://flexget.com/Plugins/Notifiers/ntfysh) ⭐ - Multipurpose automation tool for all of your media
|
||||||
- [Shoutrrr](https://containrrr.dev/shoutrrr/v0.7/services/ntfy/) ⭐ - Notification library for gophers and their furry friends.
|
|
||||||
- [Scrt.link](https://scrt.link/) - Share a secret
|
- [Scrt.link](https://scrt.link/) - Share a secret
|
||||||
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
|
- [Platypush](https://docs.platypush.tech/platypush/plugins/ntfy.html) - Automation platform aimed to run on any device that can run Python
|
||||||
|
|
||||||
@@ -108,12 +107,9 @@ and uptime of third party servers, so use of each server is **at your own discre
|
|||||||
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
- [ignition-ntfy-module](https://github.com/Kyvis-Labs/ignition-ntfy-module) - Adds support for sending notifications via a ntfy server to Ignition (Java)
|
||||||
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
- [maubot-ntfy](https://gitlab.com/999eagle/maubot-ntfy) - Matrix bot to subscribe to ntfy topics and send messages to Matrix (Python)
|
||||||
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
|
- [ntfy-wrapper](https://github.com/vict0rsch/ntfy-wrapper) - Wrapper around ntfy (Python)
|
||||||
- [nodebb-plugin-ntfy](https://github.com/NodeBB/nodebb-plugin-ntfy) - Push notifications for NodeBB forums
|
|
||||||
- [n8n-ntfy](https://github.com/raghavanand98/n8n-ntfy.sh) - n8n community node that lets you use ntfy in your workflows
|
|
||||||
|
|
||||||
## Blog + forum posts
|
## Blog + forum posts
|
||||||
|
|
||||||
- [January 2023 Developer Update](https://community.nodebb.org/topic/16908/january-2023-developer-update) - nodebb.org - 1/2023
|
|
||||||
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
|
- [Comment envoyer des notifications push sur votre téléphone facilement et gratuitement?](https://korben.info/notifications-push-telephone.html) - 1/2023
|
||||||
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
- [UnifiedPush: a decentralized, open-source push notification protocol](https://f-droid.org/en/2022/12/18/unifiedpush.html) ⭐ - 12/2022
|
||||||
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
- [ntfy setup instructions](https://docs.benjamin-altpeter.de/network/vms/1001029-ntfy/) - benjamin-altpeter.de - 12/2022
|
||||||
@@ -131,7 +127,6 @@ and uptime of third party servers, so use of each server is **at your own discre
|
|||||||
- [Ntfy.sh – Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022
|
- [Ntfy.sh – Send push notifications to your phone via PUT/POST](https://news.ycombinator.com/item?id=33517944) ⭐ - news.ycombinator.com - 11/2022
|
||||||
- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022
|
- [Ntfy et Jeedom : un plugin](https://lunarok-domotique.com/2022/11/ntfy-et-jeedom/) - lunarok-domotique.com - 11/2022
|
||||||
- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022
|
- [Crea tu propio servidor de notificaciones con Ntfy](https://blog.parravidales.es/crea-tu-propio-servidor-de-notificaciones-con-ntfy/) - blog.parravidales.es - 11/2022
|
||||||
- [unRAID Notifications with ntfy.sh](https://lder.dev/posts/ntfy-Notifications-With-unRAID/) - lder.dev - 10/2022
|
|
||||||
- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022
|
- [Zero-cost push notifications to your phone or desktop via PUT/POST ](https://lobste.rs/s/41dq13/zero_cost_push_notifications_your_phone) - lobste.rs - 10/2022
|
||||||
- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022
|
- [A nifty push notification system: ntfy](https://jpmens.net/2022/10/30/a-nifty-push-notification-system-ntfy/) - jpmens.net - 10/2022
|
||||||
- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022
|
- [Alarmanlage der dritten Art (YouTube video)](https://www.youtube.com/watch?v=altb5QLHbaU&feature=youtu.be) - youtube.com - 10/2022
|
||||||
|
|||||||
@@ -2,13 +2,7 @@
|
|||||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||||
|
|
||||||
## ntfy server v1.31.0
|
## ntfy server v1.31.0 (UNRELEASED)
|
||||||
Released February 14, 2023
|
|
||||||
|
|
||||||
This is a tiny release before the really big release, and also the last before the big v2.0.0. The most interesting
|
|
||||||
things in this release are the new preliminary health endpoint to allow monitoring in K8s (and others), and the removal
|
|
||||||
of `upx` binary packing (which was causing erroneous virus flagging). Aside from that, the `go-smtp` library did a
|
|
||||||
breaking-change upgrade, which required some work to get working again.
|
|
||||||
|
|
||||||
**Features:**
|
**Features:**
|
||||||
|
|
||||||
@@ -19,19 +13,12 @@ breaking-change upgrade, which required some work to get working again.
|
|||||||
|
|
||||||
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
|
* Fix `chown` issues with RHEL-like based systems ([#566](https://github.com/binwiederhier/ntfy/issues/566)/[#565](https://github.com/binwiederhier/ntfy/pull/565), thanks to [@danieldemus](https://github.com/danieldemus))
|
||||||
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
|
* Removed `upx` (binary packing) for all builds due to false virus warnings ([#576](https://github.com/binwiederhier/ntfy/issues/576), thanks to [@shawnhwei](https://github.com/shawnhwei) for reporting)
|
||||||
* Upgraded `go-smtp` library and tests to v0.16.0 ([#569](https://github.com/binwiederhier/ntfy/issues/569))
|
|
||||||
|
|
||||||
**Documentation:**
|
**Documentation:**
|
||||||
|
|
||||||
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
|
* Add HTTP/2 and TLSv1.3 support to nginx docs ([#553](https://github.com/binwiederhier/ntfy/issues/553), thanks to [@bt90](https://github.com/bt90))
|
||||||
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
|
* Small wording change for `client.yml` ([#562](https://github.com/binwiederhier/ntfy/pull/562), thanks to [@fleopaulD](https://github.com/fleopaulD))
|
||||||
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
|
* Fix K8s install docs ([#582](https://github.com/binwiederhier/ntfy/pull/582), thanks to [@Remedan](https://github.com/Remedan))
|
||||||
* Updated Jellyseer docs ([#604](https://github.com/binwiederhier/ntfy/pull/604), thanks to [@Y0ngg4n](https://github.com/Y0ngg4n))
|
|
||||||
* Updated iOS developer docs ([#605](https://github.com/binwiederhier/ntfy/pull/605), thanks to [@SticksDev](https://github.com/SticksDev))
|
|
||||||
|
|
||||||
**Additional languages:**
|
|
||||||
|
|
||||||
* Portuguese (thanks to [@ssantos](https://hosted.weblate.org/user/ssantos/))
|
|
||||||
|
|
||||||
## ntfy server v1.30.1
|
## ntfy server v1.30.1
|
||||||
Released December 23, 2022 🎅
|
Released December 23, 2022 🎅
|
||||||
|
|||||||
4
docs/static/css/extra.css
vendored
4
docs/static/css/extra.css
vendored
@@ -8,6 +8,10 @@
|
|||||||
width: unset !important;
|
width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
.md-header__topic:first-child {
|
.md-header__topic:first-child {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ format of the message. It's very straight forward:
|
|||||||
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||||
|
| `expires` | ✔️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted |
|
||||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||||
@@ -346,6 +347,7 @@ Here's an example for each message type:
|
|||||||
{
|
{
|
||||||
"id": "sPs71M8A2T",
|
"id": "sPs71M8A2T",
|
||||||
"time": 1643935928,
|
"time": 1643935928,
|
||||||
|
"expires": 1643936928,
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"topic": "mytopic",
|
"topic": "mytopic",
|
||||||
"priority": 5,
|
"priority": 5,
|
||||||
@@ -372,6 +374,7 @@ Here's an example for each message type:
|
|||||||
{
|
{
|
||||||
"id": "wze9zgqK41",
|
"id": "wze9zgqK41",
|
||||||
"time": 1638542110,
|
"time": 1638542110,
|
||||||
|
"expires": 1638543112,
|
||||||
"event": "message",
|
"event": "message",
|
||||||
"topic": "phil_alerts",
|
"topic": "phil_alerts",
|
||||||
"message": "Remote access to phils-laptop detected. Act right away."
|
"message": "Remote access to phils-laptop detected. Act right away."
|
||||||
|
|||||||
40
go.mod
40
go.mod
@@ -4,35 +4,38 @@ go 1.18
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go/firestore v1.9.0 // indirect
|
cloud.google.com/go/firestore v1.9.0 // indirect
|
||||||
cloud.google.com/go/storage v1.29.0 // indirect
|
cloud.google.com/go/storage v1.28.1 // indirect
|
||||||
github.com/BurntSushi/toml v1.2.1 // indirect
|
github.com/BurntSushi/toml v1.2.1 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
github.com/emersion/go-smtp v0.16.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/gabriel-vasile/mimetype v1.4.1
|
github.com/gabriel-vasile/mimetype v1.4.1
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.16
|
github.com/mattn/go-sqlite3 v1.14.16
|
||||||
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
github.com/olebedev/when v0.0.0-20221205223600-4d190b02b8d8
|
||||||
github.com/stretchr/testify v1.8.1
|
github.com/stretchr/testify v1.8.1
|
||||||
github.com/urfave/cli/v2 v2.24.3
|
github.com/urfave/cli/v2 v2.23.7
|
||||||
golang.org/x/crypto v0.6.0
|
golang.org/x/crypto v0.4.0
|
||||||
golang.org/x/oauth2 v0.5.0 // indirect
|
golang.org/x/oauth2 v0.3.0 // indirect
|
||||||
golang.org/x/sync v0.1.0
|
golang.org/x/sync v0.1.0
|
||||||
golang.org/x/term v0.5.0
|
golang.org/x/term v0.3.0
|
||||||
golang.org/x/time v0.3.0
|
golang.org/x/time v0.3.0
|
||||||
google.golang.org/api v0.110.0
|
google.golang.org/api v0.105.0
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
gopkg.in/yaml.v2 v2.4.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require github.com/pkg/errors v0.9.1 // indirect
|
require github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
|
||||||
require firebase.google.com/go/v4 v4.10.0
|
require (
|
||||||
|
firebase.google.com/go/v4 v4.10.0
|
||||||
|
github.com/stripe/stripe-go/v74 v74.5.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
cloud.google.com/go v0.109.0 // indirect
|
cloud.google.com/go v0.107.0 // indirect
|
||||||
cloud.google.com/go/compute v1.18.0 // indirect
|
cloud.google.com/go/compute v1.14.0 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
cloud.google.com/go/compute/metadata v0.2.3 // indirect
|
||||||
cloud.google.com/go/iam v0.10.0 // indirect
|
cloud.google.com/go/iam v0.9.0 // indirect
|
||||||
cloud.google.com/go/longrunning v0.4.1 // indirect
|
cloud.google.com/go/longrunning v0.3.0 // indirect
|
||||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||||
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
github.com/MicahParks/keyfunc v1.9.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
@@ -42,20 +45,21 @@ require (
|
|||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.2 // indirect
|
||||||
github.com/google/go-cmp v0.5.9 // indirect
|
github.com/google/go-cmp v0.5.9 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.7.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.7.0 // 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
|
||||||
|
github.com/stretchr/objx v0.5.0 // indirect
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
golang.org/x/net v0.7.0 // indirect
|
golang.org/x/net v0.4.0 // indirect
|
||||||
golang.org/x/sys v0.5.0 // indirect
|
golang.org/x/sys v0.3.0 // indirect
|
||||||
golang.org/x/text v0.7.0 // indirect
|
golang.org/x/text v0.5.0 // indirect
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/appengine v1.6.7 // indirect
|
||||||
google.golang.org/appengine/v2 v2.0.2 // indirect
|
google.golang.org/appengine/v2 v2.0.2 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc // indirect
|
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef // indirect
|
||||||
google.golang.org/grpc v1.53.0 // indirect
|
google.golang.org/grpc v1.51.0 // indirect
|
||||||
google.golang.org/protobuf v1.28.1 // indirect
|
google.golang.org/protobuf v1.28.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
74
go.sum
74
go.sum
@@ -1,18 +1,18 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
cloud.google.com/go v0.109.0 h1:38CZoKGlCnPZjGdyj0ZfpoGae0/wgNfy5F0byyxg0Gk=
|
cloud.google.com/go v0.107.0 h1:qkj22L7bgkl6vIeZDlOY2po43Mx/TIa2Wsa7VR+PEww=
|
||||||
cloud.google.com/go v0.109.0/go.mod h1:2sYycXt75t/CSB5R9M2wPU1tJmire7AQZTPtITcGBVE=
|
cloud.google.com/go v0.107.0/go.mod h1:wpc2eNrD7hXUTy8EKS10jkxpZBjASrORK7goS+3YX2I=
|
||||||
cloud.google.com/go/compute v1.18.0 h1:FEigFqoDbys2cvFkZ9Fjq4gnHBP55anJ0yQyau2f9oY=
|
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
||||||
cloud.google.com/go/compute v1.18.0/go.mod h1:1X7yHxec2Ga+Ss6jPyjxRxpu2uu7PLgsOVXvgU0yacs=
|
cloud.google.com/go/compute v1.14.0/go.mod h1:YfLtxrj9sU4Yxv+sXzZkyPjEyPBZfXHUvjxega5vAdo=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||||
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
cloud.google.com/go/firestore v1.9.0 h1:IBlRyxgGySXu5VuW0RgGFlTtLukSnNkpDiEOMkQkmpA=
|
||||||
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
cloud.google.com/go/firestore v1.9.0/go.mod h1:HMkjKHNTtRyZNiMzu7YAsLr9K3X2udY2AMwDaMEQiiE=
|
||||||
cloud.google.com/go/iam v0.10.0 h1:fpP/gByFs6US1ma53v7VxhvbJpO2Aapng6wabJ99MuI=
|
cloud.google.com/go/iam v0.9.0 h1:bK6Or6mxhuL8lnj1i9j0yMo2wE/IeTO2cWlfUrf/TZs=
|
||||||
cloud.google.com/go/iam v0.10.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
|
cloud.google.com/go/iam v0.9.0/go.mod h1:nXAECrMt2qHpF6RZUZseteD6QyanL68reN4OXPw0UWM=
|
||||||
cloud.google.com/go/longrunning v0.4.1 h1:v+yFJOfKC3yZdY6ZUI933pIYdhyhV8S3NpWrXWmg7jM=
|
cloud.google.com/go/longrunning v0.3.0 h1:NjljC+FYPV3uh5/OwWT6pVU+doBqMg2x/rZlE+CamDs=
|
||||||
cloud.google.com/go/longrunning v0.4.1/go.mod h1:4iWDqhBZ70CvZ6BfETbvam3T8FMvLK+eFj0E6AaRQTo=
|
cloud.google.com/go/longrunning v0.3.0/go.mod h1:qth9Y41RRSUE69rDcOn6DdK3HfQfsUI0YSmW3iIlLJc=
|
||||||
cloud.google.com/go/storage v1.29.0 h1:6weCgzRvMg7lzuUurI4697AqIRPU1SvzHhynwpW31jI=
|
cloud.google.com/go/storage v1.28.1 h1:F5QDG5ChchaAVQhINh24U99OWHURqrW8OmQcGKXcbgI=
|
||||||
cloud.google.com/go/storage v1.29.0/go.mod h1:4puEjyTKnku6gfKoTfNOU/W+a9JyuVNxjpS5GBrB8h4=
|
cloud.google.com/go/storage v1.28.1/go.mod h1:Qnisd4CqDdo6BGs2AD5LLnEsmSQ80wQ5ogcBBKhU86Y=
|
||||||
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
|
firebase.google.com/go/v4 v4.10.0 h1:dgK/8uwfJbzc5LZK/GyRRfIkZEDObN9q0kgEXsjlXN4=
|
||||||
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
|
firebase.google.com/go/v4 v4.10.0/go.mod h1:m0gLwPY9fxKggizzglgCNWOGnFnVPifLpqZzo5u3e/A=
|
||||||
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
|
||||||
@@ -33,8 +33,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
|
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||||
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
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=
|
||||||
@@ -75,8 +75,8 @@ github.com/google/martian/v3 v3.2.1 h1:d8MncMlErDFTwQGBK1xhv026j9kqhvw1Qv9IbWT1V
|
|||||||
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/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1 h1:RY7tHKZcRlk788d5WSo/e83gOyyy742E8GSs771ySpg=
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
|
||||||
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
|
github.com/googleapis/gax-go/v2 v2.7.0 h1:IcsPKeInNvYi7eqSaDjiZqDDKu5rsmunY0Y1YupQSSQ=
|
||||||
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
github.com/googleapis/gax-go/v2 v2.7.0/go.mod h1:TEop28CZZQ2y+c0VxMUmu1lV+fQx57QpBWsYpwqHJx8=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
@@ -94,21 +94,25 @@ 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/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/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/urfave/cli/v2 v2.24.3 h1:7Q1w8VN8yE0MJEHP06bv89PjYsN4IHWED2s1v/Zlfm0=
|
github.com/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
|
||||||
github.com/urfave/cli/v2 v2.24.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||||
|
github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY=
|
||||||
|
github.com/urfave/cli/v2 v2.23.7/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
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-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
|
golang.org/x/crypto v0.4.0 h1:UVQgzMY87xqpKNgb+kDsll2Igd33HszWHFLmpaRMq/8=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.4.0/go.mod h1:3quD/ATkf6oY+rnes5c3ExXTbLc8mueNue5/DoinL80=
|
||||||
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/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
@@ -119,14 +123,15 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
|
|||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
golang.org/x/net v0.4.0 h1:Q5QPcMlvfxFTAPV0+07Xz/MpK9NTXu2VDUuy0FeMfaU=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.4.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
|
||||||
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=
|
||||||
golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s=
|
golang.org/x/oauth2 v0.3.0 h1:6l90koy8/LaBLmLu8jpHeHexzMwEita0zFfYlggy2F8=
|
||||||
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
|
golang.org/x/oauth2 v0.3.0/go.mod h1:rQrIauxkUhJ6CuwEXwymO2/eh4xz2ZWF1nBkcxS+tGk=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -135,20 +140,21 @@ golang.org/x/sync v0.1.0/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=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
|
golang.org/x/term v0.3.0 h1:qoo4akIqOcDME5bhc/NgxUdovd6BSS2uMsVjB56q1xI=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
|
||||||
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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
golang.org/x/text v0.5.0 h1:OLmvp0KP+FVG99Ct/qFiL/Fhk4zp4QQnZ7b2U+5piUM=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -159,8 +165,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
|
|||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk=
|
||||||
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||||
google.golang.org/api v0.110.0 h1:l+rh0KYUooe9JGbGVx71tbFo4SMbMTXK3I3ia2QSEeU=
|
google.golang.org/api v0.105.0 h1:t6P9Jj+6XTn4U9I2wycQai6Q/Kz7iOT+QzjJ3G2V4x8=
|
||||||
google.golang.org/api v0.110.0/go.mod h1:7FC4Vvx1Mooxh8C5HWjzZHcavuS2f6pmJpZx60ca7iI=
|
google.golang.org/api v0.105.0/go.mod h1:qh7eD5FJks5+BcE+cjBIm6Gz8vioK7EHvnlniqXBnqI=
|
||||||
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.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||||
@@ -170,15 +176,15 @@ google.golang.org/appengine/v2 v2.0.2/go.mod h1:PkgRUWz4o1XOvbqtWTkBtCitEJ5Tp4Ho
|
|||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc h1:ijGwO+0vL2hJt5gaygqP2j6PfflOBrRot0IczKbmtio=
|
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef h1:uQ2vjV/sHTsWSqdKeLqmwitzgvjMl7o4IdtHwUDXSJY=
|
||||||
google.golang.org/genproto v0.0.0-20230209215440-0dfe4f8abfcc/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
google.golang.org/genproto v0.0.0-20221227171554-f9683d7f8bef/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
|
||||||
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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
|
google.golang.org/grpc v1.51.0 h1:E1eGv1FTqoLIdnBCZufiSHgKjlqG6fKFf6pPWtMTh8U=
|
||||||
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
|
google.golang.org/grpc v1.51.0/go.mod h1:wgNDFcnuBGmxLKI/qn4T+m5BtEBYXJPvibbUPsAIPww=
|
||||||
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=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,6 +19,7 @@ const (
|
|||||||
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
|
||||||
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
|
||||||
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
|
||||||
|
DefaultStripePriceCacheDuration = time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines all global and per-visitor limits
|
// Defines all global and per-visitor limits
|
||||||
@@ -44,10 +46,17 @@ const (
|
|||||||
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
DefaultVisitorRequestLimitReplenish = 5 * time.Second
|
||||||
DefaultVisitorEmailLimitBurst = 16
|
DefaultVisitorEmailLimitBurst = 16
|
||||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||||
|
DefaultVisitorAccountCreateLimitBurst = 3
|
||||||
|
DefaultVisitorAccountCreateLimitReplenish = 24 * time.Hour
|
||||||
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB
|
||||||
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// DefaultVisitorStatsResetTime defines the time at which visitor stats are reset (wall clock only)
|
||||||
|
DefaultVisitorStatsResetTime = time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC)
|
||||||
|
)
|
||||||
|
|
||||||
// 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 {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
@@ -64,8 +73,8 @@ type Config struct {
|
|||||||
CacheBatchSize int
|
CacheBatchSize int
|
||||||
CacheBatchTimeout time.Duration
|
CacheBatchTimeout time.Duration
|
||||||
AuthFile string
|
AuthFile string
|
||||||
AuthDefaultRead bool
|
AuthStartupQueries string
|
||||||
AuthDefaultWrite bool
|
AuthDefault user.Permission
|
||||||
AttachmentCacheDir string
|
AttachmentCacheDir string
|
||||||
AttachmentTotalSizeLimit int64
|
AttachmentTotalSizeLimit int64
|
||||||
AttachmentFileSizeLimit int64
|
AttachmentFileSizeLimit int64
|
||||||
@@ -98,8 +107,18 @@ type Config struct {
|
|||||||
VisitorRequestExemptIPAddrs []netip.Prefix
|
VisitorRequestExemptIPAddrs []netip.Prefix
|
||||||
VisitorEmailLimitBurst int
|
VisitorEmailLimitBurst int
|
||||||
VisitorEmailLimitReplenish time.Duration
|
VisitorEmailLimitReplenish time.Duration
|
||||||
|
VisitorAccountCreateLimitBurst int
|
||||||
|
VisitorAccountCreateLimitReplenish time.Duration
|
||||||
|
VisitorStatsResetTime time.Time // Time of the day at which to reset visitor stats
|
||||||
BehindProxy bool
|
BehindProxy bool
|
||||||
|
StripeSecretKey string
|
||||||
|
StripeWebhookKey string
|
||||||
|
StripePriceCacheDuration time.Duration
|
||||||
EnableWeb bool
|
EnableWeb bool
|
||||||
|
EnableSignup bool // Enable creation of accounts via API and UI
|
||||||
|
EnableLogin bool
|
||||||
|
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
||||||
|
AccessControlAllowOrigin string // CORS header field to restrict access from web clients
|
||||||
Version string // injected by App
|
Version string // injected by App
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,25 +135,36 @@ func NewConfig() *Config {
|
|||||||
FirebaseKeyFile: "",
|
FirebaseKeyFile: "",
|
||||||
CacheFile: "",
|
CacheFile: "",
|
||||||
CacheDuration: DefaultCacheDuration,
|
CacheDuration: DefaultCacheDuration,
|
||||||
|
CacheStartupQueries: "",
|
||||||
CacheBatchSize: 0,
|
CacheBatchSize: 0,
|
||||||
CacheBatchTimeout: 0,
|
CacheBatchTimeout: 0,
|
||||||
AuthFile: "",
|
AuthFile: "",
|
||||||
AuthDefaultRead: true,
|
AuthStartupQueries: "",
|
||||||
AuthDefaultWrite: true,
|
AuthDefault: user.NewPermission(true, true),
|
||||||
AttachmentCacheDir: "",
|
AttachmentCacheDir: "",
|
||||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
ManagerInterval: DefaultManagerInterval,
|
ManagerInterval: DefaultManagerInterval,
|
||||||
MessageLimit: DefaultMessageLengthLimit,
|
WebRootIsApp: false,
|
||||||
MinDelay: DefaultMinDelay,
|
|
||||||
MaxDelay: DefaultMaxDelay,
|
|
||||||
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
||||||
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval,
|
||||||
FirebasePollInterval: DefaultFirebasePollInterval,
|
FirebasePollInterval: DefaultFirebasePollInterval,
|
||||||
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
|
FirebaseQuotaExceededPenaltyDuration: DefaultFirebaseQuotaExceededPenaltyDuration,
|
||||||
|
UpstreamBaseURL: "",
|
||||||
|
SMTPSenderAddr: "",
|
||||||
|
SMTPSenderUser: "",
|
||||||
|
SMTPSenderPass: "",
|
||||||
|
SMTPSenderFrom: "",
|
||||||
|
SMTPServerListen: "",
|
||||||
|
SMTPServerDomain: "",
|
||||||
|
SMTPServerAddrPrefix: "",
|
||||||
|
MessageLimit: DefaultMessageLengthLimit,
|
||||||
|
MinDelay: DefaultMinDelay,
|
||||||
|
MaxDelay: DefaultMaxDelay,
|
||||||
TotalTopicLimit: DefaultTotalTopicLimit,
|
TotalTopicLimit: DefaultTotalTopicLimit,
|
||||||
|
TotalAttachmentSizeLimit: 0,
|
||||||
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,
|
||||||
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit,
|
||||||
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit,
|
||||||
@@ -143,8 +173,18 @@ func NewConfig() *Config {
|
|||||||
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
|
VisitorRequestExemptIPAddrs: make([]netip.Prefix, 0),
|
||||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||||
|
VisitorAccountCreateLimitBurst: DefaultVisitorAccountCreateLimitBurst,
|
||||||
|
VisitorAccountCreateLimitReplenish: DefaultVisitorAccountCreateLimitReplenish,
|
||||||
|
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||||
BehindProxy: false,
|
BehindProxy: false,
|
||||||
|
StripeSecretKey: "",
|
||||||
|
StripeWebhookKey: "",
|
||||||
|
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||||
EnableWeb: true,
|
EnableWeb: true,
|
||||||
|
EnableSignup: false,
|
||||||
|
EnableLogin: false,
|
||||||
|
EnableReservations: false,
|
||||||
|
AccessControlAllowOrigin: "*",
|
||||||
Version: "",
|
Version: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,29 +41,43 @@ var (
|
|||||||
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, 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"}
|
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"}
|
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", ""}
|
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid request: topic invalid", ""}
|
||||||
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
|
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid request: topic name is disallowed", ""}
|
||||||
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
|
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
|
||||||
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
|
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", "https://ntfy.sh/docs/publish/#attachments"}
|
||||||
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
|
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", "https://ntfy.sh/docs/config/#attachments"}
|
||||||
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
|
||||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||||
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
errHTTPBadRequestMessageJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||||
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
|
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
|
||||||
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||||
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
|
||||||
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
|
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
|
||||||
|
errHTTPBadRequestSignupNotEnabled = &errHTTP{40022, http.StatusBadRequest, "invalid request: signup not enabled", "https://ntfy.sh/docs/config"}
|
||||||
|
errHTTPBadRequestNoTokenProvided = &errHTTP{40023, http.StatusBadRequest, "invalid request: no token provided", ""}
|
||||||
|
errHTTPBadRequestJSONInvalid = &errHTTP{40024, http.StatusBadRequest, "invalid request: request body must be valid JSON", ""}
|
||||||
|
errHTTPBadRequestPermissionInvalid = &errHTTP{40025, http.StatusBadRequest, "invalid request: incorrect permission string", ""}
|
||||||
|
errHTTPBadRequestMakesNoSenseForAdmin = &errHTTP{40026, http.StatusBadRequest, "invalid request: this makes no sense for admins", ""}
|
||||||
|
errHTTPBadRequestNotAPaidUser = &errHTTP{40027, http.StatusBadRequest, "invalid request: not a paid user", ""}
|
||||||
|
errHTTPBadRequestBillingRequestInvalid = &errHTTP{40028, http.StatusBadRequest, "invalid request: not a valid billing request", ""}
|
||||||
|
errHTTPBadRequestBillingSubscriptionExists = &errHTTP{40029, http.StatusBadRequest, "invalid request: billing subscription already exists", ""}
|
||||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
|
||||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
|
||||||
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
errHTTPConflictUserExists = &errHTTP{40901, http.StatusConflict, "conflict: user already exists", ""}
|
||||||
errHTTPEntityTooLargeMatrixRequestTooLarge = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
|
errHTTPConflictTopicReserved = &errHTTP{40902, http.StatusConflict, "conflict: access control entry for topic or topic pattern already exists", ""}
|
||||||
|
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
|
||||||
|
errHTTPEntityTooLargeJSONBody = &errHTTP{41303, http.StatusRequestEntityTooLarge, "JSON body too large", ""}
|
||||||
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
|
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"}
|
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"}
|
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"}
|
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"}
|
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
|
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations"} // FIXME document limit
|
||||||
|
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", ""}
|
||||||
|
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: too many messages", "https://ntfy.sh/docs/publish/#limitations"}
|
||||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||||
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
|
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", ""}
|
||||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ package server
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -22,11 +22,10 @@ type fileCache struct {
|
|||||||
dir string
|
dir string
|
||||||
totalSizeCurrent int64
|
totalSizeCurrent int64
|
||||||
totalSizeLimit int64
|
totalSizeLimit int64
|
||||||
fileSizeLimit int64
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileCache, error) {
|
func newFileCache(dir string, totalSizeLimit int64) (*fileCache, error) {
|
||||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -38,7 +37,6 @@ func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileC
|
|||||||
dir: dir,
|
dir: dir,
|
||||||
totalSizeCurrent: size,
|
totalSizeCurrent: size,
|
||||||
totalSizeLimit: totalSizeLimit,
|
totalSizeLimit: totalSizeLimit,
|
||||||
fileSizeLimit: fileSizeLimit,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
limiters = append(limiters, util.NewFixedLimiter(c.Remaining()), util.NewFixedLimiter(c.fileSizeLimit))
|
limiters = append(limiters, util.NewFixedLimiter(c.Remaining()))
|
||||||
limitWriter := util.NewLimitWriter(f, limiters...)
|
limitWriter := util.NewLimitWriter(f, limiters...)
|
||||||
size, err := io.Copy(limitWriter, in)
|
size, err := io.Copy(limitWriter, in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,8 +75,11 @@ func (c *fileCache) Remove(ids ...string) error {
|
|||||||
if !fileIDRegex.MatchString(id) {
|
if !fileIDRegex.MatchString(id) {
|
||||||
return errInvalidFileID
|
return errInvalidFileID
|
||||||
}
|
}
|
||||||
|
log.Debug("File Cache: Deleting attachment %s", id)
|
||||||
file := filepath.Join(c.dir, id)
|
file := filepath.Join(c.dir, id)
|
||||||
_ = os.Remove(file) // Best effort delete
|
if err := os.Remove(file); err != nil {
|
||||||
|
log.Debug("File Cache: Error deleting attachment %s: %s", id, err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
size, err := dirSize(c.dir)
|
size, err := dirSize(c.dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -90,25 +91,6 @@ func (c *fileCache) Remove(ids ...string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expired returns a list of file IDs for expired files
|
|
||||||
func (c *fileCache) Expired(olderThan time.Time) ([]string, error) {
|
|
||||||
entries, err := os.ReadDir(c.dir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var ids []string
|
|
||||||
for _, e := range entries {
|
|
||||||
info, err := e.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if info.ModTime().Before(olderThan) && fileIDRegex.MatchString(e.Name()) {
|
|
||||||
ids = append(ids, e.Name())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *fileCache) Size() int64 {
|
func (c *fileCache) Size() int64 {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -56,13 +55,6 @@ func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {
|
|||||||
require.NoFileExists(t, dir+"/abcdefghijkX")
|
require.NoFileExists(t, dir+"/abcdefghijkX")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) {
|
|
||||||
dir, c := newTestFileCache(t)
|
|
||||||
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1025)))
|
|
||||||
require.Equal(t, util.ErrLimitReached, err)
|
|
||||||
require.NoFileExists(t, dir+"/abcdefghijkl")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
|
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
|
||||||
dir, c := newTestFileCache(t)
|
dir, c := newTestFileCache(t)
|
||||||
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
|
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
|
||||||
@@ -70,32 +62,9 @@ func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
|
|||||||
require.NoFileExists(t, dir+"/abcdefghijkl")
|
require.NoFileExists(t, dir+"/abcdefghijkl")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFileCache_RemoveExpired(t *testing.T) {
|
|
||||||
dir, c := newTestFileCache(t)
|
|
||||||
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)))
|
|
||||||
require.Nil(t, err)
|
|
||||||
_, err = c.Write("notdeleted12", bytes.NewReader(make([]byte, 1001)))
|
|
||||||
require.Nil(t, err)
|
|
||||||
|
|
||||||
modTime := time.Now().Add(-1 * 4 * time.Hour)
|
|
||||||
require.Nil(t, os.Chtimes(dir+"/abcdefghijkl", modTime, modTime))
|
|
||||||
|
|
||||||
olderThan := time.Now().Add(-1 * 3 * time.Hour)
|
|
||||||
ids, err := c.Expired(olderThan)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Equal(t, []string{"abcdefghijkl"}, ids)
|
|
||||||
require.Nil(t, c.Remove(ids...))
|
|
||||||
require.NoFileExists(t, dir+"/abcdefghijkl")
|
|
||||||
require.FileExists(t, dir+"/notdeleted12")
|
|
||||||
|
|
||||||
ids, err = c.Expired(olderThan)
|
|
||||||
require.Nil(t, err)
|
|
||||||
require.Empty(t, ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {
|
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {
|
||||||
dir = t.TempDir()
|
dir = t.TempDir()
|
||||||
cache, err := newFileCache(dir, 10*1024, 1*1024)
|
cache, err := newFileCache(dir, 10*1024)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
return dir, cache
|
return dir, cache
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
mid TEXT NOT NULL,
|
mid TEXT NOT NULL,
|
||||||
time INT NOT NULL,
|
time INT NOT NULL,
|
||||||
|
expires INT NOT NULL,
|
||||||
topic TEXT NOT NULL,
|
topic TEXT NOT NULL,
|
||||||
message TEXT NOT NULL,
|
message TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -39,61 +40,71 @@ const (
|
|||||||
attachment_size INT NOT NULL,
|
attachment_size INT NOT NULL,
|
||||||
attachment_expires INT NOT NULL,
|
attachment_expires INT NOT NULL,
|
||||||
attachment_url TEXT NOT NULL,
|
attachment_url TEXT NOT NULL,
|
||||||
|
attachment_deleted INT NOT NULL,
|
||||||
sender TEXT NOT NULL,
|
sender TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
encoding TEXT NOT NULL,
|
encoding TEXT NOT NULL,
|
||||||
published INT NOT NULL
|
published INT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
`
|
`
|
||||||
insertMessageQuery = `
|
insertMessageQuery = `
|
||||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, encoding, published)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||||
selectMessagesSinceTimeQuery = `
|
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
selectMessagesSinceTimeQuery = `
|
||||||
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ? AND published = 1
|
WHERE topic = ? AND time >= ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND time >= ?
|
WHERE topic = ? AND time >= ?
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDQuery = `
|
selectMessagesSinceIDQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND id > ? AND published = 1
|
WHERE topic = ? AND id > ? AND published = 1
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE topic = ? AND (id > ? OR published = 0)
|
WHERE topic = ? AND (id > ? OR published = 0)
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
selectMessagesDueQuery = `
|
selectMessagesDueQuery = `
|
||||||
SELECT mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, encoding
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE time <= ? AND published = 0
|
WHERE time <= ? AND published = 0
|
||||||
ORDER BY time, id
|
ORDER BY time, id
|
||||||
`
|
`
|
||||||
|
selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
|
||||||
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
||||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||||
selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
|
selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY 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 sender = ? AND attachment_expires >= ?`
|
|
||||||
|
updateAttachmentDeleted = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?`
|
||||||
|
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0`
|
||||||
|
selectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?`
|
||||||
|
selectAttachmentsSizeByUserQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Schema management queries
|
// Schema management queries
|
||||||
const (
|
const (
|
||||||
currentSchemaVersion = 9
|
currentSchemaVersion = 10
|
||||||
createSchemaVersionTableQuery = `
|
createSchemaVersionTableQuery = `
|
||||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
@@ -191,6 +202,31 @@ const (
|
|||||||
migrate8To9AlterMessagesTableQuery = `
|
migrate8To9AlterMessagesTableQuery = `
|
||||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
`
|
`
|
||||||
|
|
||||||
|
// 9 -> 10
|
||||||
|
migrate9To10AlterMessagesTableQuery = `
|
||||||
|
ALTER TABLE messages ADD COLUMN user TEXT NOT NULL DEFAULT('');
|
||||||
|
ALTER TABLE messages ADD COLUMN attachment_deleted INT NOT NULL DEFAULT('0');
|
||||||
|
ALTER TABLE messages ADD COLUMN expires INT NOT NULL DEFAULT('0');
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachment_expires ON messages (attachment_expires);
|
||||||
|
`
|
||||||
|
migrate9To10UpdateMessageExpiryQuery = `UPDATE messages SET expires = time + ?`
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
migrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{
|
||||||
|
0: migrateFrom0,
|
||||||
|
1: migrateFrom1,
|
||||||
|
2: migrateFrom2,
|
||||||
|
3: migrateFrom3,
|
||||||
|
4: migrateFrom4,
|
||||||
|
5: migrateFrom5,
|
||||||
|
6: migrateFrom6,
|
||||||
|
7: migrateFrom7,
|
||||||
|
8: migrateFrom8,
|
||||||
|
9: migrateFrom9,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type messageCache struct {
|
type messageCache struct {
|
||||||
@@ -200,12 +236,12 @@ type messageCache struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newSqliteCache creates a SQLite file-backed cache
|
// newSqliteCache creates a SQLite file-backed cache
|
||||||
func newSqliteCache(filename, startupQueries string, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
func newSqliteCache(filename, startupQueries string, cacheDuration time.Duration, batchSize int, batchTimeout time.Duration, nop bool) (*messageCache, error) {
|
||||||
db, err := sql.Open("sqlite3", filename)
|
db, err := sql.Open("sqlite3", filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setupCacheDB(db, startupQueries); err != nil {
|
if err := setupDB(db, startupQueries, cacheDuration); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var queue *util.BatchingQueue[*message]
|
var queue *util.BatchingQueue[*message]
|
||||||
@@ -223,13 +259,13 @@ func newSqliteCache(filename, startupQueries string, batchSize int, batchTimeout
|
|||||||
|
|
||||||
// newMemCache creates an in-memory cache
|
// newMemCache creates an in-memory cache
|
||||||
func newMemCache() (*messageCache, error) {
|
func newMemCache() (*messageCache, error) {
|
||||||
return newSqliteCache(createMemoryFilename(), "", 0, 0, false)
|
return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newNopCache creates an in-memory cache that discards all messages;
|
// newNopCache creates an in-memory cache that discards all messages;
|
||||||
// it is always empty and can be used if caching is entirely disabled
|
// it is always empty and can be used if caching is entirely disabled
|
||||||
func newNopCache() (*messageCache, error) {
|
func newNopCache() (*messageCache, error) {
|
||||||
return newSqliteCache(createMemoryFilename(), "", 0, 0, true)
|
return newSqliteCache(createMemoryFilename(), "", 0, 0, 0, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
||||||
@@ -279,7 +315,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
published := m.Time <= time.Now().Unix()
|
published := m.Time <= time.Now().Unix()
|
||||||
tags := strings.Join(m.Tags, ",")
|
tags := strings.Join(m.Tags, ",")
|
||||||
var attachmentName, attachmentType, attachmentURL string
|
var attachmentName, attachmentType, attachmentURL string
|
||||||
var attachmentSize, attachmentExpires int64
|
var attachmentSize, attachmentExpires, attachmentDeleted int64
|
||||||
if m.Attachment != nil {
|
if m.Attachment != nil {
|
||||||
attachmentName = m.Attachment.Name
|
attachmentName = m.Attachment.Name
|
||||||
attachmentType = m.Attachment.Type
|
attachmentType = m.Attachment.Type
|
||||||
@@ -302,6 +338,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
_, err := stmt.Exec(
|
_, err := stmt.Exec(
|
||||||
m.ID,
|
m.ID,
|
||||||
m.Time,
|
m.Time,
|
||||||
|
m.Expires,
|
||||||
m.Topic,
|
m.Topic,
|
||||||
m.Message,
|
m.Message,
|
||||||
m.Title,
|
m.Title,
|
||||||
@@ -315,7 +352,9 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
attachmentSize,
|
attachmentSize,
|
||||||
attachmentExpires,
|
attachmentExpires,
|
||||||
attachmentURL,
|
attachmentURL,
|
||||||
|
attachmentDeleted, // Always zero
|
||||||
sender,
|
sender,
|
||||||
|
m.User,
|
||||||
m.Encoding,
|
m.Encoding,
|
||||||
published,
|
published,
|
||||||
)
|
)
|
||||||
@@ -324,10 +363,10 @@ func (c *messageCache) addMessages(ms []*message) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
log.Error("Cache: Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
|
log.Error("Message Cache: Writing %d message(s) failed (took %v)", len(ms), time.Since(start))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log.Debug("Cache: Wrote %d message(s) in %v", len(ms), time.Since(start))
|
log.Debug("Message Cache: Wrote %d message(s) in %v", len(ms), time.Since(start))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +427,27 @@ func (c *messageCache) MessagesDue() ([]*message, error) {
|
|||||||
return readMessages(rows)
|
return readMessages(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MessagesExpired returns a list of IDs for messages that have expires (should be deleted)
|
||||||
|
func (c *messageCache) MessagesExpired() ([]string, error) {
|
||||||
|
rows, err := c.db.Query(selectMessagesExpiredQuery, 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 (c *messageCache) MarkPublished(m *message) error {
|
func (c *messageCache) MarkPublished(m *message) error {
|
||||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||||
return err
|
return err
|
||||||
@@ -433,20 +493,85 @@ func (c *messageCache) Topics() (map[string]*topic, error) {
|
|||||||
return topics, nil
|
return topics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) Prune(olderThan time.Time) error {
|
func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||||
start := time.Now()
|
tx, err := c.db.Begin()
|
||||||
if _, err := c.db.Exec(pruneMessagesQuery, olderThan.Unix()); err != nil {
|
if err != nil {
|
||||||
log.Warn("Cache: Pruning failed (after %v): %s", time.Since(start), err.Error())
|
return err
|
||||||
}
|
}
|
||||||
log.Debug("Cache: Pruning successful (took %v)", time.Since(start))
|
defer tx.Rollback()
|
||||||
return nil
|
for _, id := range ids {
|
||||||
|
if _, err := tx.Exec(deleteMessageQuery, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *messageCache) AttachmentBytesUsed(sender string) (int64, error) {
|
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||||
rows, err := c.db.Query(selectAttachmentsSizeQuery, sender, time.Now().Unix())
|
tx, err := c.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
for _, t := range topics {
|
||||||
|
if _, err := tx.Exec(updateMessagesForTopicExpiryQuery, time.Now().Unix(), t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) 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 (c *messageCache) MarkAttachmentsDeleted(ids ...string) error {
|
||||||
|
tx, err := c.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, err := tx.Exec(updateAttachmentDeleted, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) AttachmentBytesUsedBySender(sender string) (int64, error) {
|
||||||
|
rows, err := c.db.Query(selectAttachmentsSizeBySenderQuery, sender, time.Now().Unix())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
return c.readAttachmentBytesUsed(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) AttachmentBytesUsedByUser(user string) (int64, error) {
|
||||||
|
rows, err := c.db.Query(selectAttachmentsSizeByUserQuery, user, time.Now().Unix())
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return c.readAttachmentBytesUsed(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *messageCache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var size int64
|
var size int64
|
||||||
if !rows.Next() {
|
if !rows.Next() {
|
||||||
@@ -466,7 +591,7 @@ func (c *messageCache) processMessageBatches() {
|
|||||||
}
|
}
|
||||||
for messages := range c.queue.Dequeue() {
|
for messages := range c.queue.Dequeue() {
|
||||||
if err := c.addMessages(messages); err != nil {
|
if err := c.addMessages(messages); err != nil {
|
||||||
log.Error("Cache: %s", err.Error())
|
log.Error("Message Cache: %s", err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,12 +600,13 @@ 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, attachmentSize, attachmentExpires int64
|
var timestamp, expires, attachmentSize, attachmentExpires int64
|
||||||
var priority int
|
var priority int
|
||||||
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, encoding string
|
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, encoding string
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&id,
|
&id,
|
||||||
×tamp,
|
×tamp,
|
||||||
|
&expires,
|
||||||
&topic,
|
&topic,
|
||||||
&msg,
|
&msg,
|
||||||
&title,
|
&title,
|
||||||
@@ -495,6 +621,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
&attachmentExpires,
|
&attachmentExpires,
|
||||||
&attachmentURL,
|
&attachmentURL,
|
||||||
&sender,
|
&sender,
|
||||||
|
&user,
|
||||||
&encoding,
|
&encoding,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -527,6 +654,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
messages = append(messages, &message{
|
messages = append(messages, &message{
|
||||||
ID: id,
|
ID: id,
|
||||||
Time: timestamp,
|
Time: timestamp,
|
||||||
|
Expires: expires,
|
||||||
Event: messageEvent,
|
Event: messageEvent,
|
||||||
Topic: topic,
|
Topic: topic,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
@@ -538,6 +666,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
Actions: actions,
|
Actions: actions,
|
||||||
Attachment: att,
|
Attachment: att,
|
||||||
Sender: senderIP, // Must parse assuming database must be correct
|
Sender: senderIP, // Must parse assuming database must be correct
|
||||||
|
User: user,
|
||||||
Encoding: encoding,
|
Encoding: encoding,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -547,7 +676,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
|||||||
return messages, nil
|
return messages, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupCacheDB(db *sql.DB, startupQueries string) error {
|
func setupDB(db *sql.DB, startupQueries string, cacheDuration time.Duration) error {
|
||||||
// Run startup queries
|
// Run startup queries
|
||||||
if startupQueries != "" {
|
if startupQueries != "" {
|
||||||
if _, err := db.Exec(startupQueries); err != nil {
|
if _, err := db.Exec(startupQueries); err != nil {
|
||||||
@@ -579,26 +708,18 @@ func setupCacheDB(db *sql.DB, startupQueries string) error {
|
|||||||
// Do migrations
|
// Do migrations
|
||||||
if schemaVersion == currentSchemaVersion {
|
if schemaVersion == currentSchemaVersion {
|
||||||
return nil
|
return nil
|
||||||
} else if schemaVersion == 0 {
|
} else if schemaVersion > currentSchemaVersion {
|
||||||
return migrateFrom0(db)
|
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, currentSchemaVersion)
|
||||||
} else if schemaVersion == 1 {
|
|
||||||
return migrateFrom1(db)
|
|
||||||
} else if schemaVersion == 2 {
|
|
||||||
return migrateFrom2(db)
|
|
||||||
} else if schemaVersion == 3 {
|
|
||||||
return migrateFrom3(db)
|
|
||||||
} else if schemaVersion == 4 {
|
|
||||||
return migrateFrom4(db)
|
|
||||||
} else if schemaVersion == 5 {
|
|
||||||
return migrateFrom5(db)
|
|
||||||
} else if schemaVersion == 6 {
|
|
||||||
return migrateFrom6(db)
|
|
||||||
} else if schemaVersion == 7 {
|
|
||||||
return migrateFrom7(db)
|
|
||||||
} else if schemaVersion == 8 {
|
|
||||||
return migrateFrom8(db)
|
|
||||||
}
|
}
|
||||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
for i := schemaVersion; i < currentSchemaVersion; i++ {
|
||||||
|
fn, ok := migrations[i]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
|
||||||
|
} else if err := fn(db, cacheDuration); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupNewCacheDB(db *sql.DB) error {
|
func setupNewCacheDB(db *sql.DB) error {
|
||||||
@@ -614,7 +735,7 @@ func setupNewCacheDB(db *sql.DB) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom0(db *sql.DB) error {
|
func migrateFrom0(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 0 to 1")
|
log.Info("Migrating cache database schema: from 0 to 1")
|
||||||
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -625,10 +746,10 @@ func migrateFrom0(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
|
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return migrateFrom1(db)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom1(db *sql.DB) error {
|
func migrateFrom1(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 1 to 2")
|
log.Info("Migrating cache database schema: from 1 to 2")
|
||||||
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate1To2AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -636,10 +757,10 @@ 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)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom2(db *sql.DB) error {
|
func migrateFrom2(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 2 to 3")
|
log.Info("Migrating cache database schema: from 2 to 3")
|
||||||
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -647,10 +768,10 @@ func migrateFrom2(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return migrateFrom3(db)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom3(db *sql.DB) error {
|
func migrateFrom3(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 3 to 4")
|
log.Info("Migrating cache database schema: from 3 to 4")
|
||||||
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -658,10 +779,10 @@ func migrateFrom3(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return migrateFrom4(db)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom4(db *sql.DB) error {
|
func migrateFrom4(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 4 to 5")
|
log.Info("Migrating cache database schema: from 4 to 5")
|
||||||
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -669,10 +790,10 @@ func migrateFrom4(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return migrateFrom5(db)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom5(db *sql.DB) error {
|
func migrateFrom5(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 5 to 6")
|
log.Info("Migrating cache database schema: from 5 to 6")
|
||||||
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate5To6AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -680,10 +801,10 @@ func migrateFrom5(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 6); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return migrateFrom6(db)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom6(db *sql.DB) error {
|
func migrateFrom6(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 6 to 7")
|
log.Info("Migrating cache database schema: from 6 to 7")
|
||||||
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate6To7AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -691,10 +812,10 @@ func migrateFrom6(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 7); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return migrateFrom7(db)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom7(db *sql.DB) error {
|
func migrateFrom7(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 7 to 8")
|
log.Info("Migrating cache database schema: from 7 to 8")
|
||||||
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate7To8AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -702,10 +823,10 @@ func migrateFrom7(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 8); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return migrateFrom8(db)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateFrom8(db *sql.DB) error {
|
func migrateFrom8(db *sql.DB, _ time.Duration) error {
|
||||||
log.Info("Migrating cache database schema: from 8 to 9")
|
log.Info("Migrating cache database schema: from 8 to 9")
|
||||||
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
if _, err := db.Exec(migrate8To9AlterMessagesTableQuery); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -713,5 +834,27 @@ func migrateFrom8(db *sql.DB) error {
|
|||||||
if _, err := db.Exec(updateSchemaVersion, 9); err != nil {
|
if _, err := db.Exec(updateSchemaVersion, 9); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateFrom9(db *sql.DB, cacheDuration time.Duration) error {
|
||||||
|
log.Info("Migrating cache database schema: from 9 to 10")
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(migrate9To10AlterMessagesTableQuery); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(migrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(updateSchemaVersion, 10); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil // Update this when a new version is added
|
return nil // Update this when a new version is added
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,26 +247,36 @@ func TestMemCache_Prune(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCachePrune(t *testing.T, c *messageCache) {
|
func testCachePrune(t *testing.T, c *messageCache) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
m1 := newDefaultMessage("mytopic", "my message")
|
m1 := newDefaultMessage("mytopic", "my message")
|
||||||
m1.Time = 1
|
m1.Time = now - 10
|
||||||
|
m1.Expires = now - 5
|
||||||
|
|
||||||
m2 := newDefaultMessage("mytopic", "my other message")
|
m2 := newDefaultMessage("mytopic", "my other message")
|
||||||
m2.Time = 2
|
m2.Time = now - 5
|
||||||
|
m2.Expires = now + 5 // In the future
|
||||||
|
|
||||||
m3 := newDefaultMessage("another_topic", "and another one")
|
m3 := newDefaultMessage("another_topic", "and another one")
|
||||||
m3.Time = 1
|
m3.Time = now - 12
|
||||||
|
m3.Expires = now - 2
|
||||||
|
|
||||||
require.Nil(t, c.AddMessage(m1))
|
require.Nil(t, c.AddMessage(m1))
|
||||||
require.Nil(t, c.AddMessage(m2))
|
require.Nil(t, c.AddMessage(m2))
|
||||||
require.Nil(t, c.AddMessage(m3))
|
require.Nil(t, c.AddMessage(m3))
|
||||||
require.Nil(t, c.Prune(time.Unix(2, 0)))
|
|
||||||
|
|
||||||
counts, err := c.MessageCounts()
|
counts, err := c.MessageCounts()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, 1, counts["mytopic"])
|
require.Equal(t, 2, counts["mytopic"])
|
||||||
|
require.Equal(t, 1, counts["another_topic"])
|
||||||
|
|
||||||
|
expiredMessageIDs, err := c.MessagesExpired()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, c.DeleteMessages(expiredMessageIDs...))
|
||||||
|
|
||||||
counts, err = c.MessageCounts()
|
counts, err = c.MessageCounts()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, counts["mytopic"])
|
||||||
require.Equal(t, 0, counts["another_topic"])
|
require.Equal(t, 0, counts["another_topic"])
|
||||||
|
|
||||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
@@ -343,15 +353,70 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
|||||||
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||||
require.Equal(t, "1.2.3.4", messages[1].Sender.String())
|
require.Equal(t, "1.2.3.4", messages[1].Sender.String())
|
||||||
|
|
||||||
size, err := c.AttachmentBytesUsed("1.2.3.4")
|
size, err := c.AttachmentBytesUsedBySender("1.2.3.4")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(30000), size)
|
require.Equal(t, int64(30000), size)
|
||||||
|
|
||||||
size, err = c.AttachmentBytesUsed("5.6.7.8")
|
size, err = c.AttachmentBytesUsedBySender("5.6.7.8")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(0), size)
|
require.Equal(t, int64(0), size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Attachments_Expired(t *testing.T) {
|
||||||
|
testCacheAttachmentsExpired(t, newSqliteTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemCache_Attachments_Expired(t *testing.T) {
|
||||||
|
testCacheAttachmentsExpired(t, newMemTestCache(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
||||||
|
m := newDefaultMessage("mytopic", "flower for you")
|
||||||
|
m.ID = "m1"
|
||||||
|
m.Expires = time.Now().Add(time.Hour).Unix()
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
m = newDefaultMessage("mytopic", "message with attachment")
|
||||||
|
m.ID = "m2"
|
||||||
|
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "car.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 10000,
|
||||||
|
Expires: time.Now().Add(2 * time.Hour).Unix(),
|
||||||
|
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
m = newDefaultMessage("mytopic", "message with external attachment")
|
||||||
|
m.ID = "m3"
|
||||||
|
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "car.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Expires: 0, // Unknown!
|
||||||
|
URL: "https://somedomain.com/car.jpg",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
m = newDefaultMessage("mytopic2", "message with expired attachment")
|
||||||
|
m.ID = "m4"
|
||||||
|
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||||
|
m.Attachment = &attachment{
|
||||||
|
Name: "expired-car.jpg",
|
||||||
|
Type: "image/jpeg",
|
||||||
|
Size: 20000,
|
||||||
|
Expires: time.Now().Add(-1 * time.Hour).Unix(),
|
||||||
|
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||||
|
}
|
||||||
|
require.Nil(t, c.AddMessage(m))
|
||||||
|
|
||||||
|
ids, err := c.AttachmentsExpired()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(ids))
|
||||||
|
require.Equal(t, "m4", ids[0])
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -445,12 +510,109 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
|||||||
require.Equal(t, 11, len(messages))
|
require.Equal(t, 11, len(messages))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Migration_From9(t *testing.T) {
|
||||||
|
// This primarily tests the awkward migration that introduces the "expires" column.
|
||||||
|
// The migration logic has to update the column, using the existing "cache-duration" value.
|
||||||
|
|
||||||
|
filename := newSqliteTestCacheFile(t)
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create "version 8" schema
|
||||||
|
_, err = db.Exec(`
|
||||||
|
BEGIN;
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mid TEXT NOT NULL,
|
||||||
|
time INT NOT NULL,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
priority INT NOT NULL,
|
||||||
|
tags TEXT NOT NULL,
|
||||||
|
click TEXT NOT NULL,
|
||||||
|
icon TEXT NOT NULL,
|
||||||
|
actions 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,
|
||||||
|
sender TEXT NOT NULL,
|
||||||
|
encoding TEXT NOT NULL,
|
||||||
|
published INT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||||
|
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||||
|
id INT PRIMARY KEY,
|
||||||
|
version INT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO schemaVersion (id, version) VALUES (1, 9);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Insert a bunch of messages
|
||||||
|
insertQuery := `
|
||||||
|
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err = db.Exec(
|
||||||
|
insertQuery,
|
||||||
|
fmt.Sprintf("abcd%d", i),
|
||||||
|
time.Now().Unix(),
|
||||||
|
"mytopic",
|
||||||
|
fmt.Sprintf("some message %d", i),
|
||||||
|
"", // title
|
||||||
|
0, // priority
|
||||||
|
"", // tags
|
||||||
|
"", // click
|
||||||
|
"", // icon
|
||||||
|
"", // actions
|
||||||
|
"", // attachment_name
|
||||||
|
"", // attachment_type
|
||||||
|
0, // attachment_size
|
||||||
|
0, // attachment_type
|
||||||
|
"", // attachment_url
|
||||||
|
"9.9.9.9", // sender
|
||||||
|
"", // encoding
|
||||||
|
1, // published
|
||||||
|
)
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cache to trigger migration
|
||||||
|
cacheDuration := 17 * time.Hour
|
||||||
|
c, err := newSqliteCache(filename, "", cacheDuration, 0, 0, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
checkSchemaVersion(t, c.db)
|
||||||
|
|
||||||
|
// Check version
|
||||||
|
rows, err := db.Query(`SELECT version FROM main.schemaVersion WHERE id = 1`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, rows.Next())
|
||||||
|
var version int
|
||||||
|
require.Nil(t, rows.Scan(&version))
|
||||||
|
require.Equal(t, currentSchemaVersion, version)
|
||||||
|
|
||||||
|
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 10, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())
|
||||||
|
require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
|
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
|
||||||
filename := newSqliteTestCacheFile(t)
|
filename := newSqliteTestCacheFile(t)
|
||||||
startupQueries := `pragma journal_mode = WAL;
|
startupQueries := `pragma journal_mode = WAL;
|
||||||
pragma synchronous = normal;
|
pragma synchronous = normal;
|
||||||
pragma temp_store = memory;`
|
pragma temp_store = memory;`
|
||||||
db, err := newSqliteCache(filename, startupQueries, 0, 0, false)
|
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||||
require.FileExists(t, filename)
|
require.FileExists(t, filename)
|
||||||
@@ -461,7 +623,7 @@ pragma temp_store = memory;`
|
|||||||
func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
||||||
filename := newSqliteTestCacheFile(t)
|
filename := newSqliteTestCacheFile(t)
|
||||||
startupQueries := ""
|
startupQueries := ""
|
||||||
db, err := newSqliteCache(filename, startupQueries, 0, 0, false)
|
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||||
require.FileExists(t, filename)
|
require.FileExists(t, filename)
|
||||||
@@ -472,7 +634,7 @@ func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
|||||||
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
|
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
|
||||||
filename := newSqliteTestCacheFile(t)
|
filename := newSqliteTestCacheFile(t)
|
||||||
startupQueries := `xx error`
|
startupQueries := `xx error`
|
||||||
_, err := newSqliteCache(filename, startupQueries, 0, 0, false)
|
_, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,7 +686,7 @@ func TestMemCache_NopCache(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newSqliteTestCache(t *testing.T) *messageCache {
|
func newSqliteTestCache(t *testing.T) *messageCache {
|
||||||
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", 0, 0, false)
|
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", time.Hour, 0, 0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -536,7 +698,7 @@ func newSqliteTestCacheFile(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
||||||
c, err := newSqliteCache(filename, startupQueries, 0, 0, false)
|
c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
536
server/server.go
536
server/server.go
@@ -7,7 +7,9 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -29,10 +31,39 @@ import (
|
|||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
"heckel.io/ntfy/auth"
|
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO
|
||||||
|
races:
|
||||||
|
- v.user --> see publishSyncEventAsync() test
|
||||||
|
|
||||||
|
payments:
|
||||||
|
- delete messages + reserved topics on ResetTier delete attachments in access.go
|
||||||
|
- reconciliation
|
||||||
|
|
||||||
|
Limits & rate limiting:
|
||||||
|
users without tier: should the stats be persisted? are they meaningful? -> test that the visitor is based on the IP address!
|
||||||
|
login/account endpoints
|
||||||
|
when ResetStats() is run, reset messagesLimiter (and others)?
|
||||||
|
Delete visitor when tier is changed to refresh rate limiters
|
||||||
|
|
||||||
|
Make sure account endpoints make sense for admins
|
||||||
|
|
||||||
|
UI:
|
||||||
|
- revert home page change
|
||||||
|
- flicker of upgrade banner
|
||||||
|
- JS constants
|
||||||
|
Sync:
|
||||||
|
- sync problems with "deleteAfter=0" and "displayName="
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Payment endpoints (make mocks)
|
||||||
|
- Message rate limiting and reset tests
|
||||||
|
- test that the visitor is based on the IP address when a user has no tier
|
||||||
|
*/
|
||||||
|
|
||||||
// Server is the main server, providing the UI and API for ntfy
|
// Server is the main server, providing the UI and API for ntfy
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *Config
|
config *Config
|
||||||
@@ -43,12 +74,14 @@ type Server struct {
|
|||||||
smtpServerBackend *smtpBackend
|
smtpServerBackend *smtpBackend
|
||||||
smtpSender mailer
|
smtpSender mailer
|
||||||
topics map[string]*topic
|
topics map[string]*topic
|
||||||
visitors map[netip.Addr]*visitor
|
visitors map[string]*visitor // ip:<ip> or user:<user>
|
||||||
firebaseClient *firebaseClient
|
firebaseClient *firebaseClient
|
||||||
messages int64
|
messages int64
|
||||||
auth auth.Auther
|
userManager *user.Manager // Might be nil!
|
||||||
messageCache *messageCache
|
messageCache *messageCache // Database that stores the messages
|
||||||
fileCache *fileCache
|
fileCache *fileCache // File system based cache that stores attachments
|
||||||
|
stripe stripeAPI // Stripe API, can be replaced with a mock
|
||||||
|
priceCache *util.LookupCache[map[string]string] // Stripe price ID -> formatted price
|
||||||
closeChan chan bool
|
closeChan chan bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -68,15 +101,29 @@ var (
|
|||||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||||
|
|
||||||
healthPath = "/v1/health"
|
webConfigPath = "/config.js"
|
||||||
webConfigPath = "/config.js"
|
accountPath = "/account"
|
||||||
userStatsPath = "/user/stats"
|
matrixPushPath = "/_matrix/push/v1/notify"
|
||||||
matrixPushPath = "/_matrix/push/v1/notify"
|
apiHealthPath = "/v1/health"
|
||||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
apiTiers = "/v1/tiers"
|
||||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
apiAccountPath = "/v1/account"
|
||||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
apiAccountTokenPath = "/v1/account/token"
|
||||||
disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
|
apiAccountPasswordPath = "/v1/account/password"
|
||||||
urlRegex = regexp.MustCompile(`^https?://`)
|
apiAccountSettingsPath = "/v1/account/settings"
|
||||||
|
apiAccountSubscriptionPath = "/v1/account/subscription"
|
||||||
|
apiAccountReservationPath = "/v1/account/reservation"
|
||||||
|
apiAccountBillingPortalPath = "/v1/account/billing/portal"
|
||||||
|
apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
|
||||||
|
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
|
||||||
|
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
||||||
|
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
||||||
|
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
||||||
|
apiAccountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`)
|
||||||
|
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||||
|
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||||
|
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||||
|
disallowedTopics = []string{"docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"} // If updated, also update in Android and web app
|
||||||
|
urlRegex = regexp.MustCompile(`^https?://`)
|
||||||
|
|
||||||
//go:embed site
|
//go:embed site
|
||||||
webFs embed.FS
|
webFs embed.FS
|
||||||
@@ -96,7 +143,8 @@ const (
|
|||||||
emptyMessageBody = "triggered" // Used if message body is empty
|
emptyMessageBody = "triggered" // Used if message body is empty
|
||||||
newMessageBody = "New message" // Used in poll requests as generic message
|
newMessageBody = "New message" // Used in poll requests as generic message
|
||||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||||
encodingBase64 = "base64"
|
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||||
|
jsonBodyBytesLimit = 16384
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebSocket constants
|
// WebSocket constants
|
||||||
@@ -114,6 +162,10 @@ func New(conf *Config) (*Server, error) {
|
|||||||
if conf.SMTPSenderAddr != "" {
|
if conf.SMTPSenderAddr != "" {
|
||||||
mailer = &smtpSender{config: conf}
|
mailer = &smtpSender{config: conf}
|
||||||
}
|
}
|
||||||
|
var stripe stripeAPI
|
||||||
|
if conf.StripeSecretKey != "" {
|
||||||
|
stripe = newStripeAPI()
|
||||||
|
}
|
||||||
messageCache, err := createMessageCache(conf)
|
messageCache, err := createMessageCache(conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -124,14 +176,14 @@ func New(conf *Config) (*Server, error) {
|
|||||||
}
|
}
|
||||||
var fileCache *fileCache
|
var fileCache *fileCache
|
||||||
if conf.AttachmentCacheDir != "" {
|
if conf.AttachmentCacheDir != "" {
|
||||||
fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, conf.AttachmentFileSizeLimit)
|
fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var auther auth.Auther
|
var userManager *user.Manager
|
||||||
if conf.AuthFile != "" {
|
if conf.AuthFile != "" {
|
||||||
auther, err = auth.NewSQLiteAuth(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite)
|
userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -142,25 +194,28 @@ func New(conf *Config) (*Server, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
firebaseClient = newFirebaseClient(sender, auther)
|
firebaseClient = newFirebaseClient(sender, userManager)
|
||||||
}
|
}
|
||||||
return &Server{
|
s := &Server{
|
||||||
config: conf,
|
config: conf,
|
||||||
messageCache: messageCache,
|
messageCache: messageCache,
|
||||||
fileCache: fileCache,
|
fileCache: fileCache,
|
||||||
firebaseClient: firebaseClient,
|
firebaseClient: firebaseClient,
|
||||||
smtpSender: mailer,
|
smtpSender: mailer,
|
||||||
topics: topics,
|
topics: topics,
|
||||||
auth: auther,
|
userManager: userManager,
|
||||||
visitors: make(map[netip.Addr]*visitor),
|
visitors: make(map[string]*visitor),
|
||||||
}, nil
|
stripe: stripe,
|
||||||
|
}
|
||||||
|
s.priceCache = util.NewLookupCache(s.fetchStripePrices, conf.StripePriceCacheDuration)
|
||||||
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMessageCache(conf *Config) (*messageCache, error) {
|
func createMessageCache(conf *Config) (*messageCache, error) {
|
||||||
if conf.CacheDuration == 0 {
|
if conf.CacheDuration == 0 {
|
||||||
return newNopCache()
|
return newNopCache()
|
||||||
} else if conf.CacheFile != "" {
|
} else if conf.CacheFile != "" {
|
||||||
return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
|
return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, conf.CacheDuration, conf.CacheBatchSize, conf.CacheBatchTimeout, false)
|
||||||
}
|
}
|
||||||
return newMemCache()
|
return newMemCache()
|
||||||
}
|
}
|
||||||
@@ -230,6 +285,7 @@ func (s *Server) Run() error {
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
go s.runManager()
|
go s.runManager()
|
||||||
|
go s.runStatsResetter()
|
||||||
go s.runDelayedSender()
|
go s.runDelayedSender()
|
||||||
go s.runFirebaseKeepaliver()
|
go s.runFirebaseKeepaliver()
|
||||||
|
|
||||||
@@ -256,12 +312,15 @@ func (s *Server) Stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
v := s.visitor(r)
|
v, err := s.visitor(r) // Note: Always returns v, even when error is returned
|
||||||
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
|
if err == nil {
|
||||||
if log.IsTrace() {
|
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
|
||||||
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
|
if log.IsTrace() {
|
||||||
|
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
|
||||||
|
}
|
||||||
|
err = s.handleInternal(w, r, v)
|
||||||
}
|
}
|
||||||
if err := s.handleInternal(w, r, v); err != nil {
|
if err != nil {
|
||||||
if websocket.IsWebSocketUpgrade(r) {
|
if websocket.IsWebSocketUpgrade(r) {
|
||||||
isNormalError := strings.Contains(err.Error(), "i/o timeout")
|
isNormalError := strings.Contains(err.Error(), "i/o timeout")
|
||||||
if isNormalError {
|
if isNormalError {
|
||||||
@@ -297,12 +356,50 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
return s.ensureWebEnabled(s.handleHome)(w, r, v)
|
return s.ensureWebEnabled(s.handleHome)(w, r, v)
|
||||||
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
||||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == healthPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||||
return s.handleHealth(w, r, v)
|
return s.handleHealth(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPath {
|
||||||
return s.handleUserStats(w, r, v)
|
return s.ensureUserManager(s.handleAccountCreate)(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
|
||||||
|
return s.ensureUser(s.handleAccountTokenIssue)(w, r, v)
|
||||||
|
} else if r.Method == http.MethodGet && r.URL.Path == apiAccountPath {
|
||||||
|
return s.handleAccountGet(w, r, v) // Allowed by anonymous
|
||||||
|
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPath {
|
||||||
|
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
|
||||||
|
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {
|
||||||
|
return s.ensureUser(s.handleAccountTokenExtend)(w, r, v)
|
||||||
|
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountTokenPath {
|
||||||
|
return s.ensureUser(s.handleAccountTokenDelete)(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountSettingsPath {
|
||||||
|
return s.ensureUser(s.withAccountSync(s.handleAccountSettingsChange))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountSubscriptionPath {
|
||||||
|
return s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionAdd))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPatch && apiAccountSubscriptionSingleRegex.MatchString(r.URL.Path) {
|
||||||
|
return s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionChange))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodDelete && apiAccountSubscriptionSingleRegex.MatchString(r.URL.Path) {
|
||||||
|
return s.ensureUser(s.withAccountSync(s.handleAccountSubscriptionDelete))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountReservationPath {
|
||||||
|
return s.ensureUser(s.withAccountSync(s.handleAccountReservationAdd))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodDelete && apiAccountReservationSingleRegex.MatchString(r.URL.Path) {
|
||||||
|
return s.ensureUser(s.withAccountSync(s.handleAccountReservationDelete))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingSubscriptionPath {
|
||||||
|
return s.ensurePaymentsEnabled(s.ensureUser(s.handleAccountBillingSubscriptionCreate))(w, r, v) // Account sync via incoming Stripe webhook
|
||||||
|
} else if r.Method == http.MethodGet && apiAccountBillingSubscriptionCheckoutSuccessRegex.MatchString(r.URL.Path) {
|
||||||
|
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingSubscriptionCreateSuccess))(w, r, v) // No user context!
|
||||||
|
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountBillingSubscriptionPath {
|
||||||
|
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingSubscriptionUpdate))(w, r, v) // Account sync via incoming Stripe webhook
|
||||||
|
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountBillingSubscriptionPath {
|
||||||
|
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingSubscriptionDelete))(w, r, v) // Account sync via incoming Stripe webhook
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingPortalPath {
|
||||||
|
return s.ensurePaymentsEnabled(s.ensureStripeCustomer(s.handleAccountBillingPortalSessionCreate))(w, r, v)
|
||||||
|
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountBillingWebhookPath {
|
||||||
|
return s.ensurePaymentsEnabled(s.ensureUserManager(s.handleAccountBillingWebhook))(w, r, v) // This request comes from Stripe!
|
||||||
|
} else if r.Method == http.MethodGet && r.URL.Path == apiTiers {
|
||||||
|
return s.ensurePaymentsEnabled(s.handleBillingTiersGet)(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
||||||
return s.handleMatrixDiscovery(w)
|
return s.handleMatrixDiscovery(w)
|
||||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||||
@@ -314,23 +411,23 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
|||||||
} else if r.Method == http.MethodOptions {
|
} else if r.Method == http.MethodOptions {
|
||||||
return s.ensureWebEnabled(s.handleOptions)(w, r, v)
|
return s.ensureWebEnabled(s.handleOptions)(w, r, v)
|
||||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
|
||||||
return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v)
|
return s.limitRequests(s.transformBodyJSON(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||||
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
||||||
return s.limitRequests(s.transformMatrixJSON(s.authWrite(s.handlePublishMatrix)))(w, r, v)
|
return s.limitRequests(s.transformMatrixJSON(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
return s.limitRequests(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
return s.limitRequests(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authRead(s.handleSubscribeJSON))(w, r, v)
|
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeJSON))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authRead(s.handleSubscribeSSE))(w, r, v)
|
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeSSE))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && rawPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && rawPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authRead(s.handleSubscribeRaw))(w, r, v)
|
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeRaw))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && wsPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && wsPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v)
|
return s.limitRequests(s.authorizeTopicRead(s.handleSubscribeWS))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||||
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
|
return s.limitRequests(s.authorizeTopicRead(s.handleTopicAuth))(w, r, v)
|
||||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || externalTopicPathRegex.MatchString(r.URL.Path)) {
|
||||||
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
return s.ensureWebEnabled(s.handleTopic)(w, r, v)
|
||||||
}
|
}
|
||||||
@@ -363,22 +460,14 @@ func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
|
||||||
_, err := io.WriteString(w, `{"success":true}`+"\n")
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
response := &apiHealthResponse{
|
response := &apiHealthResponse{
|
||||||
Healthy: true,
|
Healthy: true,
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/json")
|
return s.writeJSON(w, response)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
@@ -386,27 +475,22 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
|||||||
if !s.config.WebRootIsApp {
|
if !s.config.WebRootIsApp {
|
||||||
appRoot = "/app"
|
appRoot = "/app"
|
||||||
}
|
}
|
||||||
disallowedTopicsStr := `"` + strings.Join(disallowedTopics, `", "`) + `"`
|
response := &apiConfigResponse{
|
||||||
w.Header().Set("Content-Type", "text/javascript")
|
BaseURL: "", // Will translate to window.location.origin
|
||||||
_, err := io.WriteString(w, fmt.Sprintf(`// Generated server configuration
|
AppRoot: appRoot,
|
||||||
var config = {
|
EnableLogin: s.config.EnableLogin,
|
||||||
appRoot: "%s",
|
EnableSignup: s.config.EnableSignup,
|
||||||
disallowedTopics: [%s]
|
EnablePayments: s.config.StripeSecretKey != "",
|
||||||
};`, appRoot, disallowedTopicsStr))
|
EnableReservations: s.config.EnableReservations,
|
||||||
return err
|
DisallowedTopics: disallowedTopics,
|
||||||
}
|
}
|
||||||
|
b, err := json.MarshalIndent(response, "", " ")
|
||||||
func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
stats, err := v.Stats()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/json")
|
w.Header().Set("Content-Type", "text/javascript")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
||||||
if err := json.NewEncoder(w).Encode(stats); err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||||
@@ -426,7 +510,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
|||||||
}
|
}
|
||||||
matches := fileRegex.FindStringSubmatch(r.URL.Path)
|
matches := fileRegex.FindStringSubmatch(r.URL.Path)
|
||||||
if len(matches) != 2 {
|
if len(matches) != 2 {
|
||||||
return errHTTPInternalErrorInvalidFilePath
|
return errHTTPInternalErrorInvalidPath
|
||||||
}
|
}
|
||||||
messageID := matches[1]
|
messageID := matches[1]
|
||||||
file := filepath.Join(s.config.AttachmentCacheDir, messageID)
|
file := filepath.Join(s.config.AttachmentCacheDir, messageID)
|
||||||
@@ -436,7 +520,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
|||||||
}
|
}
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil {
|
if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil {
|
||||||
return errHTTPTooManyRequestsAttachmentBandwidthLimit
|
return errHTTPTooManyRequestsLimitAttachmentBandwidth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||||
@@ -465,6 +549,9 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := v.MessageAllowed(); err != nil {
|
||||||
|
return nil, errHTTPTooManyRequestsLimitMessages
|
||||||
|
}
|
||||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -477,6 +564,10 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
|||||||
if m.PollID != "" {
|
if m.PollID != "" {
|
||||||
m = newPollRequestMessage(t.ID, m.PollID)
|
m = newPollRequestMessage(t.ID, m.PollID)
|
||||||
}
|
}
|
||||||
|
if v.user != nil {
|
||||||
|
m.User = v.user.Name
|
||||||
|
}
|
||||||
|
m.Expires = time.Now().Add(v.Limits().MessagesExpiryDuration).Unix()
|
||||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -484,8 +575,8 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
|||||||
m.Message = emptyMessageBody
|
m.Message = emptyMessageBody
|
||||||
}
|
}
|
||||||
delayed := m.Time > time.Now().Unix()
|
delayed := m.Time > time.Now().Unix()
|
||||||
log.Debug("%s Received message: event=%s, body=%d byte(s), delayed=%t, firebase=%t, cache=%t, up=%t, email=%s",
|
log.Debug("%s Received message: event=%s, user=%s, body=%d byte(s), delayed=%t, firebase=%t, cache=%t, up=%t, email=%s",
|
||||||
logMessagePrefix(v, m), m.Event, len(m.Message), delayed, firebase, cache, unifiedpush, email)
|
logMessagePrefix(v, m), m.Event, m.User, len(m.Message), delayed, firebase, cache, unifiedpush, email)
|
||||||
if log.IsTrace() {
|
if log.IsTrace() {
|
||||||
log.Trace("%s Message body: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(m))
|
log.Trace("%s Message body: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(m))
|
||||||
}
|
}
|
||||||
@@ -497,6 +588,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
|||||||
go s.sendToFirebase(v, m)
|
go s.sendToFirebase(v, m)
|
||||||
}
|
}
|
||||||
if s.smtpSender != nil && email != "" {
|
if s.smtpSender != nil && email != "" {
|
||||||
|
v.IncrementEmails()
|
||||||
go s.sendEmail(v, m, email)
|
go s.sendEmail(v, m, email)
|
||||||
}
|
}
|
||||||
if s.config.UpstreamBaseURL != "" {
|
if s.config.UpstreamBaseURL != "" {
|
||||||
@@ -511,6 +603,10 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
v.IncrementMessages()
|
||||||
|
if s.userManager != nil && v.user != nil {
|
||||||
|
s.userManager.EnqueueStats(v.user)
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.messages++
|
s.messages++
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -522,12 +618,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, m)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
|
||||||
if err := json.NewEncoder(w).Encode(m); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
@@ -743,18 +834,20 @@ func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeekedReadCloser
|
|||||||
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeekedReadCloser) error {
|
||||||
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" {
|
||||||
return errHTTPBadRequestAttachmentsDisallowed
|
return errHTTPBadRequestAttachmentsDisallowed
|
||||||
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
|
|
||||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
|
||||||
}
|
}
|
||||||
visitorStats, err := v.Stats()
|
vinfo, err := v.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
|
||||||
|
if m.Time > attachmentExpiry {
|
||||||
|
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||||
|
}
|
||||||
contentLengthStr := r.Header.Get("Content-Length")
|
contentLengthStr := r.Header.Get("Content-Length")
|
||||||
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below
|
||||||
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64)
|
||||||
if err == nil && (contentLength > visitorStats.VisitorAttachmentBytesRemaining || contentLength > s.config.AttachmentFileSizeLimit) {
|
if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) {
|
||||||
return errHTTPEntityTooLargeAttachmentTooLarge
|
return errHTTPEntityTooLargeAttachment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.Attachment == nil {
|
if m.Attachment == nil {
|
||||||
@@ -762,7 +855,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|||||||
}
|
}
|
||||||
var ext string
|
var ext string
|
||||||
m.Sender = v.ip // Important for attachment rate limiting
|
m.Sender = v.ip // Important for attachment rate limiting
|
||||||
m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix()
|
m.Attachment.Expires = attachmentExpiry
|
||||||
m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)
|
m.Attachment.Type, ext = util.DetectContentType(body.PeekedBytes, m.Attachment.Name)
|
||||||
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
|
m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext)
|
||||||
if m.Attachment.Name == "" {
|
if m.Attachment.Name == "" {
|
||||||
@@ -771,9 +864,14 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
|||||||
if m.Message == "" {
|
if m.Message == "" {
|
||||||
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name)
|
||||||
}
|
}
|
||||||
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(visitorStats.VisitorAttachmentBytesRemaining))
|
limiters := []util.Limiter{
|
||||||
|
v.BandwidthLimiter(),
|
||||||
|
util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit),
|
||||||
|
util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining),
|
||||||
|
}
|
||||||
|
m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...)
|
||||||
if err == util.ErrLimitReached {
|
if err == util.ErrLimitReached {
|
||||||
return errHTTPEntityTooLargeAttachmentTooLarge
|
return errHTTPEntityTooLargeAttachment
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1069,9 +1167,9 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, PATCH, DELETE")
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
|
w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,7 +1217,7 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
|||||||
return topics, nil
|
return topics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) updateStatsAndPrune() {
|
func (s *Server) execManager() {
|
||||||
log.Debug("Manager: Starting")
|
log.Debug("Manager: Starting")
|
||||||
defer log.Debug("Manager: Finished")
|
defer log.Debug("Manager: Finished")
|
||||||
|
|
||||||
@@ -1139,27 +1237,47 @@ func (s *Server) updateStatsAndPrune() {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors)
|
log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors)
|
||||||
|
|
||||||
|
// Delete expired user tokens
|
||||||
|
if s.userManager != nil {
|
||||||
|
if err := s.userManager.RemoveExpiredTokens(); err != nil {
|
||||||
|
log.Warn("Error expiring user tokens: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete expired attachments
|
// Delete expired attachments
|
||||||
if s.fileCache != nil && s.config.AttachmentExpiryDuration > 0 {
|
if s.fileCache != nil {
|
||||||
olderThan := time.Now().Add(-1 * s.config.AttachmentExpiryDuration)
|
ids, err := s.messageCache.AttachmentsExpired()
|
||||||
ids, err := s.fileCache.Expired(olderThan)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Error retrieving expired attachments: %s", err.Error())
|
log.Warn("Manager: Error retrieving expired attachments: %s", err.Error())
|
||||||
} else if len(ids) > 0 {
|
} else if len(ids) > 0 {
|
||||||
log.Debug("Manager: Deleting expired attachments: %v", ids)
|
if log.IsDebug() {
|
||||||
|
log.Debug("Manager: Deleting attachments %s", strings.Join(ids, ", "))
|
||||||
|
}
|
||||||
if err := s.fileCache.Remove(ids...); err != nil {
|
if err := s.fileCache.Remove(ids...); err != nil {
|
||||||
log.Warn("Error deleting attachments: %s", err.Error())
|
log.Warn("Manager: Error deleting attachments: %s", err.Error())
|
||||||
|
}
|
||||||
|
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
|
||||||
|
log.Warn("Manager: Error marking attachments deleted: %s", err.Error())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Debug("Manager: No expired attachments to delete")
|
log.Debug("Manager: No expired attachments to delete")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune message cache
|
// DeleteMessages message cache
|
||||||
olderThan := time.Now().Add(-1 * s.config.CacheDuration)
|
log.Debug("Manager: Pruning messages")
|
||||||
log.Debug("Manager: Pruning messages older than %s", olderThan.Format("2006-01-02 15:04:05"))
|
expiredMessageIDs, err := s.messageCache.MessagesExpired()
|
||||||
if err := s.messageCache.Prune(olderThan); err != nil {
|
if err != nil {
|
||||||
log.Warn("Manager: Error pruning cache: %s", err.Error())
|
log.Warn("Manager: Error retrieving expired messages: %s", err.Error())
|
||||||
|
} else if len(expiredMessageIDs) > 0 {
|
||||||
|
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
|
||||||
|
log.Warn("Manager: Error deleting attachments for expired messages: %s", err.Error())
|
||||||
|
}
|
||||||
|
if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
|
||||||
|
log.Warn("Manager: Error marking attachments deleted: %s", err.Error())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Debug("Manager: No expired messages to delete")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message count per topic
|
// Message count per topic
|
||||||
@@ -1225,18 +1343,49 @@ func (s *Server) runManager() {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-time.After(s.config.ManagerInterval):
|
case <-time.After(s.config.ManagerInterval):
|
||||||
s.updateStatsAndPrune()
|
s.execManager()
|
||||||
case <-s.closeChan:
|
case <-s.closeChan:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runStatsResetter runs once a day (usually midnight UTC) to reset all the visitor's message and
|
||||||
|
// email counters. The stats are used to display the counters in the web app, as well as for rate limiting.
|
||||||
|
func (s *Server) runStatsResetter() {
|
||||||
|
for {
|
||||||
|
runAt := util.NextOccurrenceUTC(s.config.VisitorStatsResetTime, time.Now())
|
||||||
|
timer := time.NewTimer(time.Until(runAt))
|
||||||
|
log.Debug("Stats resetter: Waiting until %v to reset visitor stats", runAt)
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
s.resetStats()
|
||||||
|
case <-s.closeChan:
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) resetStats() {
|
||||||
|
log.Info("Resetting all visitor stats (daily task)")
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock() // Includes the database query to avoid races with other processes
|
||||||
|
for _, v := range s.visitors {
|
||||||
|
v.ResetStats()
|
||||||
|
}
|
||||||
|
if s.userManager != nil {
|
||||||
|
if err := s.userManager.ResetStats(); err != nil {
|
||||||
|
log.Warn("Failed to write to database: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) runFirebaseKeepaliver() {
|
func (s *Server) runFirebaseKeepaliver() {
|
||||||
if s.firebaseClient == nil {
|
if s.firebaseClient == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v := newVisitor(s.config, s.messageCache, netip.IPv4Unspecified()) // Background process, not a real visitor, uses IP 0.0.0.0
|
v := newVisitor(s.config, s.messageCache, s.userManager, netip.IPv4Unspecified(), nil) // Background process, not a real visitor, uses IP 0.0.0.0
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
case <-time.After(s.config.FirebaseKeepaliveInterval):
|
||||||
@@ -1268,7 +1417,17 @@ func (s *Server) sendDelayedMessages() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
v := s.visitorFromIP(m.Sender)
|
var v *visitor
|
||||||
|
if s.userManager != nil && m.User != "" {
|
||||||
|
u, err := s.userManager.User(m.User)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = s.visitorFromUser(u, m.Sender)
|
||||||
|
} else {
|
||||||
|
v = s.visitorFromIP(m.Sender)
|
||||||
|
}
|
||||||
if err := s.sendDelayedMessage(v, m); err != nil {
|
if err := s.sendDelayedMessage(v, m); err != nil {
|
||||||
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
|
log.Warn("%s Error sending delayed message: %s", logMessagePrefix(v, m), err.Error())
|
||||||
}
|
}
|
||||||
@@ -1312,28 +1471,14 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
|
||||||
if !s.config.EnableWeb {
|
|
||||||
return errHTTPNotFound
|
|
||||||
}
|
|
||||||
return next(w, r, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
|
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
|
||||||
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
|
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
|
||||||
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2) // 2x to account for JSON format overhead
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer r.Body.Close()
|
|
||||||
var m publishMessage
|
|
||||||
if err := json.NewDecoder(body).Decode(&m); err != nil {
|
|
||||||
return errHTTPBadRequestJSONInvalid
|
|
||||||
}
|
|
||||||
if !topicRegex.MatchString(m.Topic) {
|
if !topicRegex.MatchString(m.Topic) {
|
||||||
return errHTTPBadRequestTopicInvalid
|
return errHTTPBadRequestTopicInvalid
|
||||||
}
|
}
|
||||||
@@ -1366,7 +1511,7 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
|||||||
if len(m.Actions) > 0 {
|
if len(m.Actions) > 0 {
|
||||||
actionsStr, err := json.Marshal(m.Actions)
|
actionsStr, err := json.Marshal(m.Actions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errHTTPBadRequestJSONInvalid
|
return errHTTPBadRequestMessageJSONInvalid
|
||||||
}
|
}
|
||||||
r.Header.Set("X-Actions", string(actionsStr))
|
r.Header.Set("X-Actions", string(actionsStr))
|
||||||
}
|
}
|
||||||
@@ -1393,33 +1538,25 @@ func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) authWrite(next handleFunc) handleFunc {
|
func (s *Server) authorizeTopicWrite(next handleFunc) handleFunc {
|
||||||
return s.withAuth(next, auth.PermissionWrite)
|
return s.autorizeTopic(next, user.PermissionWrite)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) authRead(next handleFunc) handleFunc {
|
func (s *Server) authorizeTopicRead(next handleFunc) handleFunc {
|
||||||
return s.withAuth(next, auth.PermissionRead)
|
return s.autorizeTopic(next, user.PermissionRead)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
|
func (s *Server) autorizeTopic(next handleFunc, perm user.Permission) handleFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
if s.auth == nil {
|
if s.userManager == nil {
|
||||||
return next(w, r, v)
|
return next(w, r, v)
|
||||||
}
|
}
|
||||||
topics, _, err := s.topicsFromPath(r.URL.Path)
|
topics, _, err := s.topicsFromPath(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var user *auth.User // may stay nil if no auth header!
|
|
||||||
username, password, ok := extractUserPass(r)
|
|
||||||
if ok {
|
|
||||||
if user, err = s.auth.Authenticate(username, password); err != nil {
|
|
||||||
log.Info("authentication failed: %s", err.Error())
|
|
||||||
return errHTTPUnauthorized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, t := range topics {
|
for _, t := range topics {
|
||||||
if err := s.auth.Authorize(user, t.ID, perm); err != nil {
|
if err := s.userManager.Authorize(v.user, t.ID, perm); err != nil {
|
||||||
log.Info("unauthorized: %s", err.Error())
|
log.Info("unauthorized: %s", err.Error())
|
||||||
return errHTTPForbidden
|
return errHTTPForbidden
|
||||||
}
|
}
|
||||||
@@ -1428,67 +1565,90 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractUserPass reads the username/password from the basic auth header (Authorization: Basic ...),
|
|
||||||
// or from the ?auth=... query param. The latter is required only to support the WebSocket JavaScript
|
|
||||||
// class, which does not support passing headers during the initial request. The auth query param
|
|
||||||
// is effectively double base64 encoded. Its format is base64(Basic base64(user:pass)).
|
|
||||||
func extractUserPass(r *http.Request) (username string, password string, ok bool) {
|
|
||||||
username, password, ok = r.BasicAuth()
|
|
||||||
if ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authParam := readQueryParam(r, "authorization", "auth")
|
|
||||||
if authParam != "" {
|
|
||||||
a, err := base64.RawURLEncoding.DecodeString(authParam)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Header.Set("Authorization", string(a))
|
|
||||||
return r.BasicAuth()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// visitor creates or retrieves a rate.Limiter for the given visitor.
|
// visitor creates or retrieves a rate.Limiter for the given visitor.
|
||||||
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
|
// Note that this function will always return a visitor, even if an error occurs.
|
||||||
func (s *Server) visitor(r *http.Request) *visitor {
|
func (s *Server) visitor(r *http.Request) (v *visitor, err error) {
|
||||||
remoteAddr := r.RemoteAddr
|
ip := extractIPAddress(r, s.config.BehindProxy)
|
||||||
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
var u *user.User // may stay nil if no auth header!
|
||||||
ip := addrPort.Addr()
|
if u, err = s.authenticate(r); err != nil {
|
||||||
if err != nil {
|
log.Debug("authentication failed: %s", err.Error())
|
||||||
// This should not happen in real life; only in tests. So, using falling back to 0.0.0.0 if address unspecified
|
err = errHTTPUnauthorized // Always return visitor, even when error occurs!
|
||||||
ip, err = netip.ParseAddr(remoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
ip = netip.IPv4Unspecified()
|
|
||||||
if remoteAddr != "@" || !s.config.BehindProxy { // RemoteAddr is @ when unix socket is used
|
|
||||||
log.Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created %s", remoteAddr, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if s.config.BehindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" {
|
if u != nil {
|
||||||
// X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy,
|
v = s.visitorFromUser(u, ip)
|
||||||
// only the right-most address can be trusted (as this is the one added by our proxy server).
|
} else {
|
||||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
|
v = s.visitorFromIP(ip)
|
||||||
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
|
|
||||||
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
|
|
||||||
if err != nil {
|
|
||||||
log.Error("invalid IP address %s received in X-Forwarded-For header: %s", ip, err.Error())
|
|
||||||
// Fall back to regular remote address if X-Forwarded-For is damaged
|
|
||||||
} else {
|
|
||||||
ip = realIP
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return s.visitorFromIP(ip)
|
v.mu.Lock()
|
||||||
|
v.user = u
|
||||||
|
v.mu.Unlock()
|
||||||
|
return v, err // Always return visitor, even when error occurs!
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) visitorFromIP(ip netip.Addr) *visitor {
|
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
|
||||||
|
// The Authorization header can be passed as a header or the ?auth=... query param. The latter is required only to
|
||||||
|
// support the WebSocket JavaScript class, which does not support passing headers during the initial request. The auth
|
||||||
|
// query param is effectively double base64 encoded. Its format is base64(Basic base64(user:pass)).
|
||||||
|
func (s *Server) authenticate(r *http.Request) (user *user.User, err error) {
|
||||||
|
value := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
queryParam := readQueryParam(r, "authorization", "auth")
|
||||||
|
if queryParam != "" {
|
||||||
|
a, err := base64.RawURLEncoding.DecodeString(queryParam)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(string(a))
|
||||||
|
}
|
||||||
|
if value == "" {
|
||||||
|
return nil, nil
|
||||||
|
} else if s.userManager == nil {
|
||||||
|
return nil, errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "Bearer") {
|
||||||
|
return s.authenticateBearerAuth(value)
|
||||||
|
}
|
||||||
|
return s.authenticateBasicAuth(r, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authenticateBasicAuth(r *http.Request, value string) (user *user.User, err error) {
|
||||||
|
r.Header.Set("Authorization", value)
|
||||||
|
username, password, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("invalid basic auth")
|
||||||
|
}
|
||||||
|
return s.userManager.Authenticate(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authenticateBearerAuth(value string) (user *user.User, err error) {
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(value, "Bearer"))
|
||||||
|
return s.userManager.AuthenticateToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) visitorFromID(visitorID string, ip netip.Addr, user *user.User) *visitor {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
v, exists := s.visitors[ip]
|
v, exists := s.visitors[visitorID]
|
||||||
if !exists {
|
if !exists {
|
||||||
s.visitors[ip] = newVisitor(s.config, s.messageCache, ip)
|
s.visitors[visitorID] = newVisitor(s.config, s.messageCache, s.userManager, ip, user)
|
||||||
return s.visitors[ip]
|
return s.visitors[visitorID]
|
||||||
}
|
}
|
||||||
v.Keepalive()
|
v.Keepalive()
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) visitorFromIP(ip netip.Addr) *visitor {
|
||||||
|
return s.visitorFromID(fmt.Sprintf("ip:%s", ip.String()), ip, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) visitorFromUser(user *user.User, ip netip.Addr) *visitor {
|
||||||
|
return s.visitorFromID(fmt.Sprintf("user:%s", user.Name), ip, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeJSON(w http.ResponseWriter, v any) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,6 +158,17 @@
|
|||||||
#
|
#
|
||||||
# web-root: app
|
# web-root: app
|
||||||
|
|
||||||
|
# Various feature flags used to control the web app, and API access, mainly around user and
|
||||||
|
# account management.
|
||||||
|
#
|
||||||
|
# - enable-signup allows users to sign up via the web app, or API
|
||||||
|
# - enable-login allows users to log in via the web app, or API
|
||||||
|
# - enable-reservations allows users to reserve topics (if their tier allows it)
|
||||||
|
#
|
||||||
|
# enable-signup: false
|
||||||
|
# enable-login: false
|
||||||
|
# enable-reservations: false
|
||||||
|
|
||||||
# Server URL of a Firebase/APNS-connected ntfy server (likely "https://ntfy.sh").
|
# Server URL of a Firebase/APNS-connected ntfy server (likely "https://ntfy.sh").
|
||||||
#
|
#
|
||||||
# iOS users:
|
# iOS users:
|
||||||
@@ -203,6 +214,16 @@
|
|||||||
# visitor-attachment-total-size-limit: "100M"
|
# visitor-attachment-total-size-limit: "100M"
|
||||||
# visitor-attachment-daily-bandwidth-limit: "500M"
|
# visitor-attachment-daily-bandwidth-limit: "500M"
|
||||||
|
|
||||||
|
# Payments integration via Stripe
|
||||||
|
#
|
||||||
|
# - stripe-secret-key is the key used for the Stripe API communication. Setting this values
|
||||||
|
# enables payments in the ntfy web app (e.g. Upgrade dialog). See https://dashboard.stripe.com/apikeys.
|
||||||
|
# - stripe-webhook-key is the key required to validate the authenticity of incoming webhooks from Stripe.
|
||||||
|
# Webhooks are essential up keep the local database in sync with the payment provider. See https://dashboard.stripe.com/webhooks.
|
||||||
|
#
|
||||||
|
# stripe-secret-key:
|
||||||
|
# stripe-webhook-key:
|
||||||
|
|
||||||
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
|
# Log level, can be TRACE, DEBUG, INFO, WARN or ERROR
|
||||||
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
# This option can be hot-reloaded by calling "kill -HUP $pid" or "systemctl reload ntfy".
|
||||||
#
|
#
|
||||||
|
|||||||
394
server/server_account.go
Normal file
394
server/server_account.go
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subscriptionIDLength = 16
|
||||||
|
createdByAPI = "api"
|
||||||
|
syncTopicAccountSyncEvent = "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
admin := v.user != nil && v.user.Role == user.RoleAdmin
|
||||||
|
if !admin {
|
||||||
|
if !s.config.EnableSignup {
|
||||||
|
return errHTTPBadRequestSignupNotEnabled
|
||||||
|
} else if v.user != nil {
|
||||||
|
return errHTTPUnauthorized // Cannot create account from user context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||||
|
return errHTTPConflictUserExists
|
||||||
|
}
|
||||||
|
if v.accountLimiter != nil && !v.accountLimiter.Allow() {
|
||||||
|
return errHTTPTooManyRequestsLimitAccountCreation
|
||||||
|
}
|
||||||
|
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, createdByAPI); err != nil { // TODO this should return a User
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
info, err := v.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
limits, stats := info.Limits, info.Stats
|
||||||
|
response := &apiAccountResponse{
|
||||||
|
Limits: &apiAccountLimits{
|
||||||
|
Basis: string(limits.Basis),
|
||||||
|
Messages: limits.MessagesLimit,
|
||||||
|
MessagesExpiryDuration: int64(limits.MessagesExpiryDuration.Seconds()),
|
||||||
|
Emails: limits.EmailsLimit,
|
||||||
|
Reservations: limits.ReservationsLimit,
|
||||||
|
AttachmentTotalSize: limits.AttachmentTotalSizeLimit,
|
||||||
|
AttachmentFileSize: limits.AttachmentFileSizeLimit,
|
||||||
|
AttachmentExpiryDuration: int64(limits.AttachmentExpiryDuration.Seconds()),
|
||||||
|
},
|
||||||
|
Stats: &apiAccountStats{
|
||||||
|
Messages: stats.Messages,
|
||||||
|
MessagesRemaining: stats.MessagesRemaining,
|
||||||
|
Emails: stats.Emails,
|
||||||
|
EmailsRemaining: stats.EmailsRemaining,
|
||||||
|
Reservations: stats.Reservations,
|
||||||
|
ReservationsRemaining: stats.ReservationsRemaining,
|
||||||
|
AttachmentTotalSize: stats.AttachmentTotalSize,
|
||||||
|
AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if v.user != nil {
|
||||||
|
response.Username = v.user.Name
|
||||||
|
response.Role = string(v.user.Role)
|
||||||
|
response.SyncTopic = v.user.SyncTopic
|
||||||
|
if v.user.Prefs != nil {
|
||||||
|
if v.user.Prefs.Language != "" {
|
||||||
|
response.Language = v.user.Prefs.Language
|
||||||
|
}
|
||||||
|
if v.user.Prefs.Notification != nil {
|
||||||
|
response.Notification = v.user.Prefs.Notification
|
||||||
|
}
|
||||||
|
if v.user.Prefs.Subscriptions != nil {
|
||||||
|
response.Subscriptions = v.user.Prefs.Subscriptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.user.Tier != nil {
|
||||||
|
response.Tier = &apiAccountTier{
|
||||||
|
Code: v.user.Tier.Code,
|
||||||
|
Name: v.user.Tier.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v.user.Billing.StripeCustomerID != "" {
|
||||||
|
response.Billing = &apiAccountBilling{
|
||||||
|
Customer: true,
|
||||||
|
Subscription: v.user.Billing.StripeSubscriptionID != "",
|
||||||
|
Status: string(v.user.Billing.StripeSubscriptionStatus),
|
||||||
|
PaidUntil: v.user.Billing.StripeSubscriptionPaidUntil.Unix(),
|
||||||
|
CancelAt: v.user.Billing.StripeSubscriptionCancelAt.Unix(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reservations, err := s.userManager.Reservations(v.user.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(reservations) > 0 {
|
||||||
|
response.Reservations = make([]*apiAccountReservation, 0)
|
||||||
|
for _, r := range reservations {
|
||||||
|
response.Reservations = append(response.Reservations, &apiAccountReservation{
|
||||||
|
Topic: r.Topic,
|
||||||
|
Everyone: r.Everyone.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response.Username = user.Everyone
|
||||||
|
response.Role = string(user.RoleAnonymous)
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
if v.user.Billing.StripeSubscriptionID != "" {
|
||||||
|
log.Info("Deleting user %s (billing customer: %s, billing subscription: %s)", v.user.Name, v.user.Billing.StripeCustomerID, v.user.Billing.StripeSubscriptionID)
|
||||||
|
if v.user.Billing.StripeSubscriptionID != "" {
|
||||||
|
if _, err := s.stripe.CancelSubscription(v.user.Billing.StripeSubscriptionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Info("Deleting user %s", v.user.Name)
|
||||||
|
}
|
||||||
|
if err := s.userManager.RemoveUser(v.user.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
newPassword, err := readJSONWithLimit[apiAccountPasswordChangeRequest](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.userManager.ChangePassword(v.user.Name, newPassword.Password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
// TODO rate limit
|
||||||
|
token, err := s.userManager.CreateToken(v.user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response := &apiAccountTokenResponse{
|
||||||
|
Token: token.Value,
|
||||||
|
Expires: token.Expires.Unix(),
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
// TODO rate limit
|
||||||
|
if v.user == nil {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
} else if v.user.Token == "" {
|
||||||
|
return errHTTPBadRequestNoTokenProvided
|
||||||
|
}
|
||||||
|
token, err := s.userManager.ExtendToken(v.user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response := &apiAccountTokenResponse{
|
||||||
|
Token: token.Value,
|
||||||
|
Expires: token.Expires.Unix(),
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
// TODO rate limit
|
||||||
|
if v.user.Token == "" {
|
||||||
|
return errHTTPBadRequestNoTokenProvided
|
||||||
|
}
|
||||||
|
if err := s.userManager.RemoveToken(v.user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
newPrefs, err := readJSONWithLimit[user.Prefs](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v.user.Prefs == nil {
|
||||||
|
v.user.Prefs = &user.Prefs{}
|
||||||
|
}
|
||||||
|
prefs := v.user.Prefs
|
||||||
|
if newPrefs.Language != "" {
|
||||||
|
prefs.Language = newPrefs.Language
|
||||||
|
}
|
||||||
|
if newPrefs.Notification != nil {
|
||||||
|
if prefs.Notification == nil {
|
||||||
|
prefs.Notification = &user.NotificationPrefs{}
|
||||||
|
}
|
||||||
|
if newPrefs.Notification.DeleteAfter > 0 {
|
||||||
|
prefs.Notification.DeleteAfter = newPrefs.Notification.DeleteAfter
|
||||||
|
}
|
||||||
|
if newPrefs.Notification.Sound != "" {
|
||||||
|
prefs.Notification.Sound = newPrefs.Notification.Sound
|
||||||
|
}
|
||||||
|
if newPrefs.Notification.MinPriority > 0 {
|
||||||
|
prefs.Notification.MinPriority = newPrefs.Notification.MinPriority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
newSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v.user.Prefs == nil {
|
||||||
|
v.user.Prefs = &user.Prefs{}
|
||||||
|
}
|
||||||
|
newSubscription.ID = "" // Client cannot set ID
|
||||||
|
for _, subscription := range v.user.Prefs.Subscriptions {
|
||||||
|
if newSubscription.BaseURL == subscription.BaseURL && newSubscription.Topic == subscription.Topic {
|
||||||
|
newSubscription = subscription
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if newSubscription.ID == "" {
|
||||||
|
newSubscription.ID = util.RandomString(subscriptionIDLength)
|
||||||
|
v.user.Prefs.Subscriptions = append(v.user.Prefs.Subscriptions, newSubscription)
|
||||||
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSubscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return errHTTPInternalErrorInvalidPath
|
||||||
|
}
|
||||||
|
subscriptionID := matches[1]
|
||||||
|
updatedSubscription, err := readJSONWithLimit[user.Subscription](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
var subscription *user.Subscription
|
||||||
|
for _, sub := range v.user.Prefs.Subscriptions {
|
||||||
|
if sub.ID == subscriptionID {
|
||||||
|
sub.DisplayName = updatedSubscription.DisplayName
|
||||||
|
subscription = sub
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subscription == nil {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
matches := apiAccountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return errHTTPInternalErrorInvalidPath
|
||||||
|
}
|
||||||
|
subscriptionID := matches[1]
|
||||||
|
if v.user.Prefs == nil || v.user.Prefs.Subscriptions == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
newSubscriptions := make([]*user.Subscription, 0)
|
||||||
|
for _, subscription := range v.user.Prefs.Subscriptions {
|
||||||
|
if subscription.ID != subscriptionID {
|
||||||
|
newSubscriptions = append(newSubscriptions, subscription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(newSubscriptions) < len(v.user.Prefs.Subscriptions) {
|
||||||
|
v.user.Prefs.Subscriptions = newSubscriptions
|
||||||
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user != nil && v.user.Role == user.RoleAdmin {
|
||||||
|
return errHTTPBadRequestMakesNoSenseForAdmin
|
||||||
|
}
|
||||||
|
req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !topicRegex.MatchString(req.Topic) {
|
||||||
|
return errHTTPBadRequestTopicInvalid
|
||||||
|
}
|
||||||
|
everyone, err := user.ParsePermission(req.Everyone)
|
||||||
|
if err != nil {
|
||||||
|
return errHTTPBadRequestPermissionInvalid
|
||||||
|
}
|
||||||
|
if v.user.Tier == nil {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
if err := s.userManager.CheckAllowAccess(v.user.Name, req.Topic); err != nil {
|
||||||
|
return errHTTPConflictTopicReserved
|
||||||
|
}
|
||||||
|
hasReservation, err := s.userManager.HasReservation(v.user.Name, req.Topic)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasReservation {
|
||||||
|
reservations, err := s.userManager.ReservationsCount(v.user.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if reservations >= v.user.Tier.ReservationsLimit {
|
||||||
|
return errHTTPTooManyRequestsLimitReservations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.userManager.ReserveAccess(v.user.Name, req.Topic, everyone); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
matches := apiAccountReservationSingleRegex.FindStringSubmatch(r.URL.Path)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return errHTTPInternalErrorInvalidPath
|
||||||
|
}
|
||||||
|
topic := matches[1]
|
||||||
|
if !topicRegex.MatchString(topic) {
|
||||||
|
return errHTTPBadRequestTopicInvalid
|
||||||
|
}
|
||||||
|
authorized, err := s.userManager.HasReservation(v.user.Name, topic)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if !authorized {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
if err := s.userManager.RemoveReservations(v.user.Name, topic); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||||
|
if v.user == nil || v.user.SyncTopic == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Trace("Publishing sync event to user %s's sync topic %s", v.user.Name, v.user.SyncTopic)
|
||||||
|
topics, err := s.topicsFromIDs(v.user.SyncTopic)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if len(topics) == 0 {
|
||||||
|
return errors.New("cannot retrieve sync topic")
|
||||||
|
}
|
||||||
|
syncTopic := topics[0]
|
||||||
|
messageBytes, err := json.Marshal(&apiAccountSyncTopicResponse{Event: syncTopicAccountSyncEvent})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := newDefaultMessage(syncTopic.ID, string(messageBytes))
|
||||||
|
if err := syncTopic.Publish(v, m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||||
|
go func() {
|
||||||
|
if v.user == nil || v.user.SyncTopic == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.publishSyncEvent(v); err != nil {
|
||||||
|
log.Trace("Error publishing to user %s's sync topic %s: %s", v.user.Name, v.user.SyncTopic, err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
485
server/server_account_test.go
Normal file
485
server/server_account_test.go
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAccount_Signup_Success(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
token, _ := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||||
|
require.NotEmpty(t, token.Token)
|
||||||
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BearerAuth(token.Token),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, "phil", account.Username)
|
||||||
|
require.Equal(t, "user", account.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Signup_UserExists(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 409, rr.Code)
|
||||||
|
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Signup_LimitReached(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
rr := request(t, s, "POST", "/v1/account", fmt.Sprintf(`{"username":"phil%d", "password":"mypass"}`, i), nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
}
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"thiswontwork", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 429, rr.Code)
|
||||||
|
require.Equal(t, 42906, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Signup_AsUser(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
||||||
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"emma", "password":"emma"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "POST", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Signup_Disabled(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = false
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40022, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Get_Anonymous(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.VisitorRequestLimitReplenish = 86 * time.Second
|
||||||
|
conf.VisitorEmailLimitReplenish = time.Hour
|
||||||
|
conf.VisitorAttachmentTotalSizeLimit = 5123
|
||||||
|
conf.AttachmentFileSizeLimit = 512
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
s.smtpSender = &testMailer{}
|
||||||
|
|
||||||
|
rr := request(t, s, "GET", "/v1/account", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, "*", account.Username)
|
||||||
|
require.Equal(t, string(user.RoleAnonymous), account.Role)
|
||||||
|
require.Equal(t, "ip", account.Limits.Basis)
|
||||||
|
require.Equal(t, int64(1004), account.Limits.Messages) // I hate this
|
||||||
|
require.Equal(t, int64(24), account.Limits.Emails) // I hate this
|
||||||
|
require.Equal(t, int64(5123), account.Limits.AttachmentTotalSize)
|
||||||
|
require.Equal(t, int64(512), account.Limits.AttachmentFileSize)
|
||||||
|
require.Equal(t, int64(0), account.Stats.Messages)
|
||||||
|
require.Equal(t, int64(1004), account.Stats.MessagesRemaining)
|
||||||
|
require.Equal(t, int64(0), account.Stats.Emails)
|
||||||
|
require.Equal(t, int64(24), account.Stats.EmailsRemaining)
|
||||||
|
|
||||||
|
rr = request(t, s, "POST", "/mytopic", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
rr = request(t, s, "POST", "/mytopic", "", map[string]string{
|
||||||
|
"Email": "phil@ntfy.sh",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, int64(2), account.Stats.Messages)
|
||||||
|
require.Equal(t, int64(1002), account.Stats.MessagesRemaining)
|
||||||
|
require.Equal(t, int64(1), account.Stats.Emails)
|
||||||
|
require.Equal(t, int64(23), account.Stats.EmailsRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_ChangeSettings(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
user, _ := s.userManager.User("phil")
|
||||||
|
token, _ := s.userManager.CreateToken(user)
|
||||||
|
|
||||||
|
rr := request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"sound": "juntos"},"ignored": true}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "PATCH", "/v1/account/settings", `{"notification": {"delete_after": 86400}, "language": "de"}`, map[string]string{
|
||||||
|
"Authorization": util.BearerAuth(token.Value),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", `{"username":"marian", "password":"marian"}`, map[string]string{
|
||||||
|
"Authorization": util.BearerAuth(token.Value),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, "de", account.Language)
|
||||||
|
require.Equal(t, 86400, account.Notification.DeleteAfter)
|
||||||
|
require.Equal(t, "juntos", account.Notification.Sound)
|
||||||
|
require.Equal(t, 0, account.Notification.MinPriority) // Not set
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Subscription_AddUpdateDelete(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url": "http://abc.com", "topic": "def"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, 1, len(account.Subscriptions))
|
||||||
|
require.NotEmpty(t, account.Subscriptions[0].ID)
|
||||||
|
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
||||||
|
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
||||||
|
require.Equal(t, "", account.Subscriptions[0].DisplayName)
|
||||||
|
|
||||||
|
subscriptionID := account.Subscriptions[0].ID
|
||||||
|
rr = request(t, s, "PATCH", "/v1/account/subscription/"+subscriptionID, `{"display_name": "ding dong"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, 1, len(account.Subscriptions))
|
||||||
|
require.Equal(t, subscriptionID, account.Subscriptions[0].ID)
|
||||||
|
require.Equal(t, "http://abc.com", account.Subscriptions[0].BaseURL)
|
||||||
|
require.Equal(t, "def", account.Subscriptions[0].Topic)
|
||||||
|
require.Equal(t, "ding dong", account.Subscriptions[0].DisplayName)
|
||||||
|
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account/subscription/"+subscriptionID, "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, 0, len(account.Subscriptions))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_ChangePassword(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "new password"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_ChangePassword_NoAccount(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account/password", `{"password": "new password"}`, nil)
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_ExtendToken(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
|
||||||
|
rr = request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BearerAuth(token.Token),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
extendedToken, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, token.Token, extendedToken.Token)
|
||||||
|
require.True(t, token.Expires < extendedToken.Expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_ExtendToken_NoTokenProvided(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
rr := request(t, s, "PATCH", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_DeleteToken(t *testing.T) {
|
||||||
|
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
token, err := util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Delete token failure (using basic auth)
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"), // Not Bearer!
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40023, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
|
||||||
|
// Delete token with wrong token
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BearerAuth("invalidtoken"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
|
||||||
|
// Delete token with correct token
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account/token", "", map[string]string{
|
||||||
|
"Authorization": util.BearerAuth(token.Token),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Cannot get account anymore
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BearerAuth(token.Token),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Delete_Success(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Delete_Not_Allowed(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account", "", nil)
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Reservation_AddWithoutTierFails(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "adminpass", user.RoleAdmin, "unit-test"))
|
||||||
|
|
||||||
|
rr := request(t, s, "POST", "/v1/account/reservation", `{"topic":"mytopic","everyone":"deny-all"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "adminpass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 400, rr.Code)
|
||||||
|
require.Equal(t, 40026, toHTTPError(t, rr.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Reservation_AddRemoveUserWithTierSuccess(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Create a tier
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "pro",
|
||||||
|
Paid: false,
|
||||||
|
MessagesLimit: 123,
|
||||||
|
MessagesExpiryDuration: 86400 * time.Second,
|
||||||
|
EmailsLimit: 32,
|
||||||
|
ReservationsLimit: 2,
|
||||||
|
AttachmentFileSizeLimit: 1231231,
|
||||||
|
AttachmentTotalSizeLimit: 123123,
|
||||||
|
AttachmentExpiryDuration: 10800 * time.Second,
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
|
// Reserve two topics
|
||||||
|
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "another", "everyone":"read-only"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Trying to reserve a third should fail
|
||||||
|
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "yet-another", "everyone":"deny-all"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, rr.Code)
|
||||||
|
|
||||||
|
// Modify existing should still work
|
||||||
|
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "another", "everyone":"write-only"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Check account result
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, "pro", account.Tier.Code)
|
||||||
|
require.Equal(t, int64(123), account.Limits.Messages)
|
||||||
|
require.Equal(t, int64(86400), account.Limits.MessagesExpiryDuration)
|
||||||
|
require.Equal(t, int64(32), account.Limits.Emails)
|
||||||
|
require.Equal(t, int64(2), account.Limits.Reservations)
|
||||||
|
require.Equal(t, int64(1231231), account.Limits.AttachmentFileSize)
|
||||||
|
require.Equal(t, int64(123123), account.Limits.AttachmentTotalSize)
|
||||||
|
require.Equal(t, int64(10800), account.Limits.AttachmentExpiryDuration)
|
||||||
|
require.Equal(t, 2, len(account.Reservations))
|
||||||
|
require.Equal(t, "another", account.Reservations[0].Topic)
|
||||||
|
require.Equal(t, "write-only", account.Reservations[0].Everyone)
|
||||||
|
require.Equal(t, "mytopic", account.Reservations[1].Topic)
|
||||||
|
require.Equal(t, "deny-all", account.Reservations[1].Everyone)
|
||||||
|
|
||||||
|
// Delete and re-check
|
||||||
|
rr = request(t, s, "DELETE", "/v1/account/reservation/another", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
account, _ = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||||
|
require.Equal(t, 1, len(account.Reservations))
|
||||||
|
require.Equal(t, "mytopic", account.Reservations[0].Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccount_Reservation_PublishByAnonymousFails(t *testing.T) {
|
||||||
|
conf := newTestConfigWithAuthFile(t)
|
||||||
|
conf.AuthDefault = user.PermissionReadWrite
|
||||||
|
conf.EnableSignup = true
|
||||||
|
s := newTestServer(t, conf)
|
||||||
|
|
||||||
|
// Create user with tier
|
||||||
|
rr := request(t, s, "POST", "/v1/account", `{"username":"phil", "password":"mypass"}`, nil)
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "pro",
|
||||||
|
MessagesLimit: 20,
|
||||||
|
ReservationsLimit: 2,
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
|
||||||
|
// Reserve a topic
|
||||||
|
rr = request(t, s, "POST", "/v1/account/reservation", `{"topic": "mytopic", "everyone":"deny-all"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Publish a message
|
||||||
|
rr = request(t, s, "POST", "/mytopic", `Howdy`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Publish a message (as anonymous)
|
||||||
|
rr = request(t, s, "POST", "/mytopic", `Howdy`, nil)
|
||||||
|
require.Equal(t, 403, rr.Code)
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"firebase.google.com/go/v4/messaging"
|
"firebase.google.com/go/v4/messaging"
|
||||||
"fmt"
|
"fmt"
|
||||||
"google.golang.org/api/option"
|
"google.golang.org/api/option"
|
||||||
"heckel.io/ntfy/auth"
|
|
||||||
"heckel.io/ntfy/log"
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -28,10 +28,10 @@ var (
|
|||||||
// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
|
// The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
|
||||||
type firebaseClient struct {
|
type firebaseClient struct {
|
||||||
sender firebaseSender
|
sender firebaseSender
|
||||||
auther auth.Auther
|
auther user.Auther
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClient {
|
func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClient {
|
||||||
return &firebaseClient{
|
return &firebaseClient{
|
||||||
sender: sender,
|
sender: sender,
|
||||||
auther: auther,
|
auther: auther,
|
||||||
@@ -112,7 +112,7 @@ func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
|
|||||||
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
|
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
|
||||||
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
|
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
|
||||||
// to Firebase here. This is mainly for iOS to support self-hosted servers.
|
// to Firebase here. This is mainly for iOS to support self-hosted servers.
|
||||||
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
|
func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, error) {
|
||||||
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
||||||
var apnsConfig *messaging.APNSConfig
|
var apnsConfig *messaging.APNSConfig
|
||||||
switch m.Event {
|
switch m.Event {
|
||||||
@@ -137,7 +137,7 @@ func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, erro
|
|||||||
case messageEvent:
|
case messageEvent:
|
||||||
allowForward := true
|
allowForward := true
|
||||||
if auther != nil {
|
if auther != nil {
|
||||||
allowForward = auther.Authorize(nil, m.Topic, auth.PermissionRead) == nil
|
allowForward = auther.Authorize(nil, m.Topic, user.PermissionRead) == nil
|
||||||
}
|
}
|
||||||
if allowForward {
|
if allowForward {
|
||||||
data = map[string]string{
|
data = map[string]string{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -11,18 +12,19 @@ import (
|
|||||||
|
|
||||||
"firebase.google.com/go/v4/messaging"
|
"firebase.google.com/go/v4/messaging"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/auth"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type testAuther struct {
|
type testAuther struct {
|
||||||
Allow bool
|
Allow bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t testAuther) Authenticate(_, _ string) (*auth.User, error) {
|
var _ user.Auther = (*testAuther)(nil)
|
||||||
|
|
||||||
|
func (t testAuther) Authenticate(_, _ string) (*user.User, error) {
|
||||||
return nil, errors.New("not used")
|
return nil, errors.New("not used")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error {
|
func (t testAuther) Authorize(_ *user.User, _ string, _ user.Permission) error {
|
||||||
if t.Allow {
|
if t.Allow {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -324,7 +326,7 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
|
|||||||
func TestToFirebaseSender_Abuse(t *testing.T) {
|
func TestToFirebaseSender_Abuse(t *testing.T) {
|
||||||
sender := &testFirebaseSender{allowed: 2}
|
sender := &testFirebaseSender{allowed: 2}
|
||||||
client := newFirebaseClient(sender, &testAuther{})
|
client := newFirebaseClient(sender, &testAuther{})
|
||||||
visitor := newVisitor(newTestConfig(t), newMemTestCache(t), netip.MustParseAddr("1.2.3.4"))
|
visitor := newVisitor(newTestConfig(t), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||||
|
|
||||||
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||||
require.Equal(t, 1, len(sender.Messages()))
|
require.Equal(t, 1, len(sender.Messages()))
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int)
|
|||||||
}
|
}
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
if body.LimitReached {
|
if body.LimitReached {
|
||||||
return nil, errHTTPEntityTooLargeMatrixRequestTooLarge
|
return nil, errHTTPEntityTooLargeMatrixRequest
|
||||||
}
|
}
|
||||||
var m matrixRequest
|
var m matrixRequest
|
||||||
if err := json.Unmarshal(body.PeekedBytes, &m); err != nil {
|
if err := json.Unmarshal(body.PeekedBytes, &m); err != nil {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func TestMatrix_NewRequestFromMatrixJSON_TooLarge(t *testing.T) {
|
|||||||
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||||
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||||
require.Equal(t, errHTTPEntityTooLargeMatrixRequestTooLarge, err)
|
require.Equal(t, errHTTPEntityTooLargeMatrixRequest, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) {
|
func TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) {
|
||||||
@@ -72,7 +72,7 @@ func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
|
|||||||
func TestMatrix_WriteMatrixError(t *testing.T) {
|
func TestMatrix_WriteMatrixError(t *testing.T) {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil)
|
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil)
|
||||||
v := newVisitor(newTestConfig(t), nil, netip.MustParseAddr("1.2.3.4"))
|
v := newVisitor(newTestConfig(t), nil, nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||||
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
|
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
|
||||||
require.Equal(t, 200, w.Result().StatusCode)
|
require.Equal(t, 200, w.Result().StatusCode)
|
||||||
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
|
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
|
||||||
|
|||||||
63
server/server_middleware.go
Normal file
63
server/server_middleware.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if !s.config.EnableWeb {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
return next(w, r, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureUserManager(next handleFunc) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if s.userManager == nil {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
return next(w, r, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureUser(next handleFunc) handleFunc {
|
||||||
|
return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user == nil {
|
||||||
|
return errHTTPUnauthorized
|
||||||
|
}
|
||||||
|
return next(w, r, v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if s.config.StripeSecretKey == "" || s.stripe == nil {
|
||||||
|
return errHTTPNotFound
|
||||||
|
}
|
||||||
|
return next(w, r, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc {
|
||||||
|
return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user.Billing.StripeCustomerID == "" {
|
||||||
|
return errHTTPBadRequestNotAPaidUser
|
||||||
|
}
|
||||||
|
return next(w, r, v)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) withAccountSync(next handleFunc) handleFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user == nil {
|
||||||
|
return next(w, r, v)
|
||||||
|
}
|
||||||
|
err := next(w, r, v)
|
||||||
|
if err == nil {
|
||||||
|
s.publishSyncEventAsync(v)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
469
server/server_payments.go
Normal file
469
server/server_payments.go
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
portalsession "github.com/stripe/stripe-go/v74/billingportal/session"
|
||||||
|
"github.com/stripe/stripe-go/v74/checkout/session"
|
||||||
|
"github.com/stripe/stripe-go/v74/customer"
|
||||||
|
"github.com/stripe/stripe-go/v74/price"
|
||||||
|
"github.com/stripe/stripe-go/v74/subscription"
|
||||||
|
"github.com/stripe/stripe-go/v74/webhook"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNotAPaidTier = errors.New("tier does not have billing price identifier")
|
||||||
|
errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions")
|
||||||
|
errNoBillingSubscription = errors.New("user does not have an active billing subscription")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Payments in ntfy are done via Stripe.
|
||||||
|
//
|
||||||
|
// Pretty much all payments related things are in this file. The following processes
|
||||||
|
// handle payments:
|
||||||
|
//
|
||||||
|
// - Checkout:
|
||||||
|
// Creating a Stripe customer and subscription via the Checkout flow. This flow is only used if the
|
||||||
|
// ntfy user is not already a Stripe customer. This requires redirecting to the Stripe checkout page.
|
||||||
|
// It is implemented in handleAccountBillingSubscriptionCreate and the success callback
|
||||||
|
// handleAccountBillingSubscriptionCreateSuccess.
|
||||||
|
// - Update subscription:
|
||||||
|
// Switching between Stripe subscriptions (upgrade/downgrade) is handled via
|
||||||
|
// handleAccountBillingSubscriptionUpdate. This also handles proration.
|
||||||
|
// - Cancel subscription (at period end):
|
||||||
|
// Users can cancel the Stripe subscription via the web app at the end of the billing period. This
|
||||||
|
// simply updates the subscription and Stripe will cancel it. Users cannot immediately cancel the
|
||||||
|
// subscription.
|
||||||
|
// - Webhooks:
|
||||||
|
// Whenever a subscription changes (updated, deleted), Stripe sends us a request via a webhook.
|
||||||
|
// This is used to keep the local user database fields up to date. Stripe is the source of truth.
|
||||||
|
// What Stripe says is mirrored and not questioned.
|
||||||
|
|
||||||
|
// handleBillingTiersGet returns all available paid tiers, and the free tier. This is to populate the upgrade dialog
|
||||||
|
// in the UI. Note that this endpoint does NOT have a user context (no v.user!).
|
||||||
|
func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||||
|
tiers, err := s.userManager.Tiers()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
freeTier := defaultVisitorLimits(s.config)
|
||||||
|
response := []*apiAccountBillingTier{
|
||||||
|
{
|
||||||
|
// This is a bit of a hack: This is the "Free" tier. It has no tier code, name or price.
|
||||||
|
Limits: &apiAccountLimits{
|
||||||
|
Messages: freeTier.MessagesLimit,
|
||||||
|
MessagesExpiryDuration: int64(freeTier.MessagesExpiryDuration.Seconds()),
|
||||||
|
Emails: freeTier.EmailsLimit,
|
||||||
|
Reservations: freeTier.ReservationsLimit,
|
||||||
|
AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit,
|
||||||
|
AttachmentFileSize: freeTier.AttachmentFileSizeLimit,
|
||||||
|
AttachmentExpiryDuration: int64(freeTier.AttachmentExpiryDuration.Seconds()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
prices, err := s.priceCache.Value()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, tier := range tiers {
|
||||||
|
priceStr, ok := prices[tier.StripePriceID]
|
||||||
|
if tier.StripePriceID == "" || !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
response = append(response, &apiAccountBillingTier{
|
||||||
|
Code: tier.Code,
|
||||||
|
Name: tier.Name,
|
||||||
|
Price: priceStr,
|
||||||
|
Limits: &apiAccountLimits{
|
||||||
|
Messages: tier.MessagesLimit,
|
||||||
|
MessagesExpiryDuration: int64(tier.MessagesExpiryDuration.Seconds()),
|
||||||
|
Emails: tier.EmailsLimit,
|
||||||
|
Reservations: tier.ReservationsLimit,
|
||||||
|
AttachmentTotalSize: tier.AttachmentTotalSizeLimit,
|
||||||
|
AttachmentFileSize: tier.AttachmentFileSizeLimit,
|
||||||
|
AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier
|
||||||
|
// will be updated by a subsequent webhook from Stripe, once the subscription becomes active.
|
||||||
|
func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user.Billing.StripeSubscriptionID != "" {
|
||||||
|
return errHTTPBadRequestBillingSubscriptionExists
|
||||||
|
}
|
||||||
|
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tier, err := s.userManager.Tier(req.Tier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if tier.StripePriceID == "" {
|
||||||
|
return errNotAPaidTier
|
||||||
|
}
|
||||||
|
log.Info("Stripe: No existing subscription, creating checkout flow")
|
||||||
|
var stripeCustomerID *string
|
||||||
|
if v.user.Billing.StripeCustomerID != "" {
|
||||||
|
stripeCustomerID = &v.user.Billing.StripeCustomerID
|
||||||
|
stripeCustomer, err := s.stripe.GetCustomer(v.user.Billing.StripeCustomerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if stripeCustomer.Subscriptions != nil && len(stripeCustomer.Subscriptions.Data) > 0 {
|
||||||
|
return errMultipleBillingSubscriptions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
Customer: stripeCustomerID, // A user may have previously deleted their subscription
|
||||||
|
ClientReferenceID: &v.user.Name,
|
||||||
|
SuccessURL: &successURL,
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
|
AllowPromotionCodes: stripe.Bool(true),
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||||
|
{
|
||||||
|
Price: stripe.String(tier.StripePriceID),
|
||||||
|
Quantity: stripe.Int64(1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/*AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
|
||||||
|
Enabled: stripe.Bool(true),
|
||||||
|
},*/
|
||||||
|
}
|
||||||
|
sess, err := s.stripe.NewCheckoutSession(params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response := &apiAccountBillingSubscriptionCreateResponse{
|
||||||
|
RedirectURL: sess.URL,
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountBillingSubscriptionCreateSuccess is called after the Stripe checkout session has succeeded. We use
|
||||||
|
// the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first
|
||||||
|
// and only time we can map the local username with the Stripe customer ID.
|
||||||
|
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||||
|
// We don't have a v.user in this endpoint, only a userManager!
|
||||||
|
matches := apiAccountBillingSubscriptionCheckoutSuccessRegex.FindStringSubmatch(r.URL.Path)
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return errHTTPInternalErrorInvalidPath
|
||||||
|
}
|
||||||
|
sessionID := matches[1]
|
||||||
|
sess, err := s.stripe.GetSession(sessionID) // FIXME How do we rate limit this?
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if sess.Customer == nil || sess.Subscription == nil || sess.ClientReferenceID == "" {
|
||||||
|
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "customer or subscription not found")
|
||||||
|
}
|
||||||
|
sub, err := s.stripe.GetSubscription(sess.Subscription.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if sub.Items == nil || len(sub.Items.Data) != 1 || sub.Items.Data[0].Price == nil {
|
||||||
|
return wrapErrHTTP(errHTTPBadRequestBillingRequestInvalid, "more than one line item in existing subscription")
|
||||||
|
}
|
||||||
|
tier, err := s.userManager.TierByStripePrice(sub.Items.Data[0].Price.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u, err := s.userManager.User(sess.ClientReferenceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.updateSubscriptionAndTier(u, tier, sess.Customer.ID, sub.ID, string(sub.Status), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
||||||
|
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
|
||||||
|
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user.Billing.StripeSubscriptionID == "" {
|
||||||
|
return errNoBillingSubscription
|
||||||
|
}
|
||||||
|
req, err := readJSONWithLimit[apiAccountBillingSubscriptionChangeRequest](r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tier, err := s.userManager.Tier(req.Tier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Info("Stripe: Changing tier and subscription to %s", tier.Code)
|
||||||
|
sub, err := s.stripe.GetSubscription(v.user.Billing.StripeSubscriptionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
params := &stripe.SubscriptionParams{
|
||||||
|
CancelAtPeriodEnd: stripe.Bool(false),
|
||||||
|
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
||||||
|
Items: []*stripe.SubscriptionItemsParams{
|
||||||
|
{
|
||||||
|
ID: stripe.String(sub.Items.Data[0].ID),
|
||||||
|
Price: stripe.String(tier.StripePriceID),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err = s.stripe.UpdateSubscription(sub.ID, params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user,
|
||||||
|
// and cancelling the Stripe subscription entirely
|
||||||
|
func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user.Billing.StripeSubscriptionID != "" {
|
||||||
|
params := &stripe.SubscriptionParams{
|
||||||
|
CancelAtPeriodEnd: stripe.Bool(true),
|
||||||
|
}
|
||||||
|
_, err := s.stripe.UpdateSubscription(v.user.Billing.StripeSubscriptionID, params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountBillingPortalSessionCreate creates a session to the customer billing portal, and returns the
|
||||||
|
// redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription.
|
||||||
|
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
if v.user.Billing.StripeCustomerID == "" {
|
||||||
|
return errHTTPBadRequestNotAPaidUser
|
||||||
|
}
|
||||||
|
params := &stripe.BillingPortalSessionParams{
|
||||||
|
Customer: stripe.String(v.user.Billing.StripeCustomerID),
|
||||||
|
ReturnURL: stripe.String(s.config.BaseURL),
|
||||||
|
}
|
||||||
|
ps, err := s.stripe.NewPortalSession(params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
response := &apiAccountBillingPortalRedirectResponse{
|
||||||
|
RedirectURL: ps.URL,
|
||||||
|
}
|
||||||
|
return s.writeJSON(w, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync
|
||||||
|
// with the Stripe view of the world. This endpoint is authorized via the Stripe webhook secret. Note that the
|
||||||
|
// visitor (v) in this endpoint is the Stripe API, so we don't have v.user available.
|
||||||
|
func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||||
|
stripeSignature := r.Header.Get("Stripe-Signature")
|
||||||
|
if stripeSignature == "" {
|
||||||
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
|
}
|
||||||
|
body, err := util.Peek(r.Body, jsonBodyBytesLimit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if body.LimitReached {
|
||||||
|
return errHTTPEntityTooLargeJSONBody
|
||||||
|
}
|
||||||
|
event, err := s.stripe.ConstructWebhookEvent(body.PeekedBytes, stripeSignature, s.config.StripeWebhookKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if event.Data == nil || event.Data.Raw == nil {
|
||||||
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Stripe: webhook event %s received", event.Type)
|
||||||
|
switch event.Type {
|
||||||
|
case "customer.subscription.updated":
|
||||||
|
return s.handleAccountBillingWebhookSubscriptionUpdated(event.Data.Raw)
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
return s.handleAccountBillingWebhookSubscriptionDeleted(event.Data.Raw)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMessage) error {
|
||||||
|
r, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if r.ID == "" || r.Customer == "" || r.Status == "" || r.CurrentPeriodEnd == 0 || r.Items == nil || len(r.Items.Data) != 1 || r.Items.Data[0].Price == nil || r.Items.Data[0].Price.ID == "" {
|
||||||
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
|
}
|
||||||
|
subscriptionID, priceID := r.ID, r.Items.Data[0].Price.ID
|
||||||
|
log.Info("Stripe: customer %s: Updating subscription to status %s, with price %s", r.Customer, r.Status, priceID)
|
||||||
|
u, err := s.userManager.UserByStripeCustomer(r.Customer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tier, err := s.userManager.TierByStripePrice(priceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.updateSubscriptionAndTier(u, tier, r.Customer, subscriptionID, r.Status, r.CurrentPeriodEnd, r.CancelAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(event json.RawMessage) error {
|
||||||
|
r, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if r.Customer == "" {
|
||||||
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
|
}
|
||||||
|
log.Info("Stripe: customer %s: subscription deleted, downgrading to unpaid tier", r.Customer)
|
||||||
|
u, err := s.userManager.UserByStripeCustomer(r.Customer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.updateSubscriptionAndTier(u, nil, r.Customer, "", "", 0, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateSubscriptionAndTier(u *user.User, tier *user.Tier, customerID, subscriptionID, status string, paidUntil, cancelAt int64) error {
|
||||||
|
// Remove excess reservations (if too many for tier), and mark associated messages deleted
|
||||||
|
reservations, err := s.userManager.Reservations(u.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reservationsLimit := visitorDefaultReservationsLimit
|
||||||
|
if tier != nil {
|
||||||
|
reservationsLimit = tier.ReservationsLimit
|
||||||
|
}
|
||||||
|
if int64(len(reservations)) > reservationsLimit {
|
||||||
|
topics := make([]string, 0)
|
||||||
|
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
|
||||||
|
topics = append(topics, reservations[i].Topic)
|
||||||
|
}
|
||||||
|
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Change or remove tier
|
||||||
|
if tier == nil {
|
||||||
|
if err := s.userManager.ResetTier(u.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.userManager.ChangeTier(u.Name, tier.Code); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update billing fields
|
||||||
|
billing := &user.Billing{
|
||||||
|
StripeCustomerID: customerID,
|
||||||
|
StripeSubscriptionID: subscriptionID,
|
||||||
|
StripeSubscriptionStatus: stripe.SubscriptionStatus(status),
|
||||||
|
StripeSubscriptionPaidUntil: time.Unix(paidUntil, 0),
|
||||||
|
StripeSubscriptionCancelAt: time.Unix(cancelAt, 0),
|
||||||
|
}
|
||||||
|
if err := s.userManager.ChangeBilling(u.Name, billing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchStripePrices contacts the Stripe API to retrieve all prices. This is used by the server to cache the prices
|
||||||
|
// in memory, and ultimately for the web app to display the price table.
|
||||||
|
func (s *Server) fetchStripePrices() (map[string]string, error) {
|
||||||
|
log.Debug("Caching prices from Stripe API")
|
||||||
|
priceMap := make(map[string]string)
|
||||||
|
prices, err := s.stripe.ListPrices(&stripe.PriceListParams{Active: stripe.Bool(true)})
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Fetching Stripe prices failed: %s", err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, p := range prices {
|
||||||
|
if p.UnitAmount%100 == 0 {
|
||||||
|
priceMap[p.ID] = fmt.Sprintf("$%d", p.UnitAmount/100)
|
||||||
|
} else {
|
||||||
|
priceMap[p.ID] = fmt.Sprintf("$%.2f", float64(p.UnitAmount)/100)
|
||||||
|
}
|
||||||
|
log.Trace("- Caching price %s = %v", p.ID, priceMap[p.ID])
|
||||||
|
}
|
||||||
|
return priceMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripeAPI is a small interface to facilitate mocking of the Stripe API
|
||||||
|
type stripeAPI interface {
|
||||||
|
NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error)
|
||||||
|
NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error)
|
||||||
|
ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error)
|
||||||
|
GetCustomer(id string) (*stripe.Customer, error)
|
||||||
|
GetSession(id string) (*stripe.CheckoutSession, error)
|
||||||
|
GetSubscription(id string) (*stripe.Subscription, error)
|
||||||
|
UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error)
|
||||||
|
CancelSubscription(id string) (*stripe.Subscription, error)
|
||||||
|
ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// realStripeAPI is a thin shim around the Stripe functions to facilitate mocking
|
||||||
|
type realStripeAPI struct{}
|
||||||
|
|
||||||
|
var _ stripeAPI = (*realStripeAPI)(nil)
|
||||||
|
|
||||||
|
func newStripeAPI() stripeAPI {
|
||||||
|
return &realStripeAPI{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||||
|
return session.New(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
|
||||||
|
return portalsession.New(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) {
|
||||||
|
prices := make([]*stripe.Price, 0)
|
||||||
|
iter := price.List(params)
|
||||||
|
for iter.Next() {
|
||||||
|
prices = append(prices, iter.Price())
|
||||||
|
}
|
||||||
|
if iter.Err() != nil {
|
||||||
|
return nil, iter.Err()
|
||||||
|
}
|
||||||
|
return prices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) GetCustomer(id string) (*stripe.Customer, error) {
|
||||||
|
return customer.Get(id, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) GetSession(id string) (*stripe.CheckoutSession, error) {
|
||||||
|
return session.Get(id, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) GetSubscription(id string) (*stripe.Subscription, error) {
|
||||||
|
return subscription.Get(id, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
||||||
|
return subscription.Update(id, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) CancelSubscription(id string) (*stripe.Subscription, error) {
|
||||||
|
return subscription.Cancel(id, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *realStripeAPI) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) {
|
||||||
|
return webhook.ConstructEvent(payload, header, secret)
|
||||||
|
}
|
||||||
337
server/server_payments_test.go
Normal file
337
server/server_payments_test.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPayments_SubscriptionCreate_NotAStripeCustomer_Success(t *testing.T) {
|
||||||
|
stripeMock := &testStripeAPI{}
|
||||||
|
defer stripeMock.AssertExpectations(t)
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.StripeSecretKey = "secret key"
|
||||||
|
c.StripeWebhookKey = "webhook key"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
s.stripe = stripeMock
|
||||||
|
|
||||||
|
// Define how the mock should react
|
||||||
|
stripeMock.
|
||||||
|
On("NewCheckoutSession", mock.Anything).
|
||||||
|
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
|
||||||
|
|
||||||
|
// Create tier and user
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "pro",
|
||||||
|
StripePriceID: "price_123",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
redirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "https://billing.stripe.com/abc/def", redirectResponse.RedirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayments_SubscriptionCreate_StripeCustomer_Success(t *testing.T) {
|
||||||
|
stripeMock := &testStripeAPI{}
|
||||||
|
defer stripeMock.AssertExpectations(t)
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.StripeSecretKey = "secret key"
|
||||||
|
c.StripeWebhookKey = "webhook key"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
s.stripe = stripeMock
|
||||||
|
|
||||||
|
// Define how the mock should react
|
||||||
|
stripeMock.
|
||||||
|
On("GetCustomer", "acct_123").
|
||||||
|
Return(&stripe.Customer{Subscriptions: &stripe.SubscriptionList{}}, nil)
|
||||||
|
stripeMock.
|
||||||
|
On("NewCheckoutSession", mock.Anything).
|
||||||
|
Return(&stripe.CheckoutSession{URL: "https://billing.stripe.com/abc/def"}, nil)
|
||||||
|
|
||||||
|
// Create tier and user
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "pro",
|
||||||
|
StripePriceID: "price_123",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
billing := &user.Billing{
|
||||||
|
StripeCustomerID: "acct_123",
|
||||||
|
}
|
||||||
|
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
response := request(t, s, "POST", "/v1/account/billing/subscription", `{"tier": "pro"}`, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
redirectResponse, err := util.UnmarshalJSON[apiAccountBillingSubscriptionCreateResponse](io.NopCloser(response.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "https://billing.stripe.com/abc/def", redirectResponse.RedirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayments_AccountDelete_Cancels_Subscription(t *testing.T) {
|
||||||
|
stripeMock := &testStripeAPI{}
|
||||||
|
defer stripeMock.AssertExpectations(t)
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.EnableSignup = true
|
||||||
|
c.StripeSecretKey = "secret key"
|
||||||
|
c.StripeWebhookKey = "webhook key"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
s.stripe = stripeMock
|
||||||
|
|
||||||
|
// Define how the mock should react
|
||||||
|
stripeMock.
|
||||||
|
On("CancelSubscription", "sub_123").
|
||||||
|
Return(&stripe.Subscription{}, nil)
|
||||||
|
|
||||||
|
// Create tier and user
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "pro",
|
||||||
|
StripePriceID: "price_123",
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
billing := &user.Billing{
|
||||||
|
StripeCustomerID: "acct_123",
|
||||||
|
StripeSubscriptionID: "sub_123",
|
||||||
|
}
|
||||||
|
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
||||||
|
|
||||||
|
// Delete account
|
||||||
|
rr := request(t, s, "DELETE", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "mypass"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 401, rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(t *testing.T) {
|
||||||
|
// This tests incoming webhooks from Stripe to update a subscription:
|
||||||
|
// - All Stripe columns are updated in the user table
|
||||||
|
// - When downgrading, excess reservations are deleted, including messages and attachments in
|
||||||
|
// the corresponding topics
|
||||||
|
|
||||||
|
stripeMock := &testStripeAPI{}
|
||||||
|
defer stripeMock.AssertExpectations(t)
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.StripeSecretKey = "secret key"
|
||||||
|
c.StripeWebhookKey = "webhook key"
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
s.stripe = stripeMock
|
||||||
|
|
||||||
|
// Define how the mock should react
|
||||||
|
stripeMock.
|
||||||
|
On("ConstructWebhookEvent", mock.Anything, "stripe signature", "webhook key").
|
||||||
|
Return(jsonToStripeEvent(t, subscriptionUpdatedEventJSON), nil)
|
||||||
|
|
||||||
|
// Create a user with a Stripe subscription and 3 reservations
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "starter",
|
||||||
|
StripePriceID: "price_1234", // !
|
||||||
|
ReservationsLimit: 1, // !
|
||||||
|
MessagesLimit: 100,
|
||||||
|
MessagesExpiryDuration: time.Hour,
|
||||||
|
AttachmentExpiryDuration: time.Hour,
|
||||||
|
AttachmentFileSizeLimit: 1000000,
|
||||||
|
AttachmentTotalSizeLimit: 1000000,
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "pro",
|
||||||
|
StripePriceID: "price_1111", // !
|
||||||
|
ReservationsLimit: 3, // !
|
||||||
|
MessagesLimit: 200,
|
||||||
|
MessagesExpiryDuration: time.Hour,
|
||||||
|
AttachmentExpiryDuration: time.Hour,
|
||||||
|
AttachmentFileSizeLimit: 1000000,
|
||||||
|
AttachmentTotalSizeLimit: 1000000,
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||||
|
require.Nil(t, s.userManager.ReserveAccess("phil", "atopic", user.PermissionDenyAll))
|
||||||
|
require.Nil(t, s.userManager.ReserveAccess("phil", "ztopic", user.PermissionDenyAll))
|
||||||
|
|
||||||
|
// Add billing details
|
||||||
|
u, err := s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
billing := &user.Billing{
|
||||||
|
StripeCustomerID: "acct_5555",
|
||||||
|
StripeSubscriptionID: "sub_1234",
|
||||||
|
StripeSubscriptionStatus: stripe.SubscriptionStatusPastDue,
|
||||||
|
StripeSubscriptionPaidUntil: time.Unix(123, 0),
|
||||||
|
StripeSubscriptionCancelAt: time.Unix(456, 0),
|
||||||
|
}
|
||||||
|
require.Nil(t, s.userManager.ChangeBilling(u.Name, billing))
|
||||||
|
|
||||||
|
// Add some messages to "atopic" and "ztopic", everything in "ztopic" will be deleted
|
||||||
|
rr := request(t, s, "PUT", "/atopic", "some aaa message", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "PUT", "/atopic", strings.Repeat("a", 5000), map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
a2 := toMessage(t, rr.Body.String())
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID))
|
||||||
|
|
||||||
|
rr = request(t, s, "PUT", "/ztopic", "some zzz message", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
rr = request(t, s, "PUT", "/ztopic", strings.Repeat("z", 5000), map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
z2 := toMessage(t, rr.Body.String())
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID))
|
||||||
|
|
||||||
|
// Call the webhook: This does all the magic
|
||||||
|
rr = request(t, s, "POST", "/v1/account/billing/webhook", "dummy", map[string]string{
|
||||||
|
"Stripe-Signature": "stripe signature",
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, rr.Code)
|
||||||
|
|
||||||
|
// Verify that database columns were updated
|
||||||
|
u, err = s.userManager.User("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "starter", u.Tier.Code) // Not "pro"
|
||||||
|
require.Equal(t, "acct_5555", u.Billing.StripeCustomerID)
|
||||||
|
require.Equal(t, "sub_1234", u.Billing.StripeSubscriptionID)
|
||||||
|
require.Equal(t, stripe.SubscriptionStatusActive, u.Billing.StripeSubscriptionStatus) // Not "past_due"
|
||||||
|
require.Equal(t, int64(1674268231), u.Billing.StripeSubscriptionPaidUntil.Unix()) // Updated
|
||||||
|
require.Equal(t, int64(1674299999), u.Billing.StripeSubscriptionCancelAt.Unix()) // Updated
|
||||||
|
|
||||||
|
// Verify that reservations were deleted
|
||||||
|
r, err := s.userManager.Reservations("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(r)) // "ztopic" reservation was deleted
|
||||||
|
require.Equal(t, "atopic", r[0].Topic)
|
||||||
|
|
||||||
|
// Verify that messages and attachments were deleted
|
||||||
|
time.Sleep(time.Second)
|
||||||
|
s.execManager()
|
||||||
|
|
||||||
|
ms, err := s.messageCache.Messages("atopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(ms))
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, a2.ID))
|
||||||
|
|
||||||
|
ms, err = s.messageCache.Messages("ztopic", sinceAllMessages, false)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(ms))
|
||||||
|
require.NoFileExists(t, filepath.Join(s.config.AttachmentCacheDir, z2.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
type testStripeAPI struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) NewCheckoutSession(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) {
|
||||||
|
args := s.Called(params)
|
||||||
|
return args.Get(0).(*stripe.CheckoutSession), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) NewPortalSession(params *stripe.BillingPortalSessionParams) (*stripe.BillingPortalSession, error) {
|
||||||
|
args := s.Called(params)
|
||||||
|
return args.Get(0).(*stripe.BillingPortalSession), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) ListPrices(params *stripe.PriceListParams) ([]*stripe.Price, error) {
|
||||||
|
args := s.Called(params)
|
||||||
|
return args.Get(0).([]*stripe.Price), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) GetCustomer(id string) (*stripe.Customer, error) {
|
||||||
|
args := s.Called(id)
|
||||||
|
return args.Get(0).(*stripe.Customer), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) GetSession(id string) (*stripe.CheckoutSession, error) {
|
||||||
|
args := s.Called(id)
|
||||||
|
return args.Get(0).(*stripe.CheckoutSession), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) GetSubscription(id string) (*stripe.Subscription, error) {
|
||||||
|
args := s.Called(id)
|
||||||
|
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) UpdateSubscription(id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) {
|
||||||
|
args := s.Called(id)
|
||||||
|
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) CancelSubscription(id string) (*stripe.Subscription, error) {
|
||||||
|
args := s.Called(id)
|
||||||
|
return args.Get(0).(*stripe.Subscription), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *testStripeAPI) ConstructWebhookEvent(payload []byte, header string, secret string) (stripe.Event, error) {
|
||||||
|
args := s.Called(payload, header, secret)
|
||||||
|
return args.Get(0).(stripe.Event), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ stripeAPI = (*testStripeAPI)(nil)
|
||||||
|
|
||||||
|
func jsonToStripeEvent(t *testing.T, v string) stripe.Event {
|
||||||
|
var e stripe.Event
|
||||||
|
if err := json.Unmarshal([]byte(v), &e); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionUpdatedEventJSON = `
|
||||||
|
{
|
||||||
|
"type": "customer.subscription.updated",
|
||||||
|
"data": {
|
||||||
|
"object": {
|
||||||
|
"id": "sub_1234",
|
||||||
|
"customer": "acct_5555",
|
||||||
|
"status": "active",
|
||||||
|
"current_period_end": 1674268231,
|
||||||
|
"cancel_at": 1674299999,
|
||||||
|
"items": {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"price": {
|
||||||
|
"id": "price_1234"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
@@ -21,7 +22,6 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"heckel.io/ntfy/auth"
|
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ func TestServer_StaticSites(t *testing.T) {
|
|||||||
|
|
||||||
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
||||||
require.Equal(t, 200, rr.Code)
|
require.Equal(t, 200, rr.Code)
|
||||||
require.Contains(t, rr.Body.String(), `html, body {`)
|
require.Contains(t, rr.Body.String(), `/* general styling */`)
|
||||||
|
|
||||||
rr = request(t, s, "GET", "/docs", "", nil)
|
rr = request(t, s, "GET", "/docs", "", nil)
|
||||||
require.Equal(t, 301, rr.Code)
|
require.Equal(t, 301, rr.Code)
|
||||||
@@ -354,7 +354,7 @@ func TestServer_PublishAtAndPrune(t *testing.T) {
|
|||||||
"In": "1h",
|
"In": "1h",
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
s.updateStatsAndPrune() // Fire pruning
|
s.execManager() // Fire pruning
|
||||||
|
|
||||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||||
messages := toMessages(t, response.Body.String())
|
messages := toMessages(t, response.Body.String())
|
||||||
@@ -622,56 +622,48 @@ func TestServer_SubscribeWithQueryFilters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Auth_Success_Admin(t *testing.T) {
|
func TestServer_Auth_Success_Admin(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
manager := s.auth.(auth.Manager)
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
||||||
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
|
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
"Authorization": basicAuth("phil:phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Auth_Success_User(t *testing.T) {
|
func TestServer_Auth_Success_User(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
c.AuthDefaultRead = false
|
|
||||||
c.AuthDefaultWrite = false
|
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
manager := s.auth.(auth.Manager)
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
||||||
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
|
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
|
||||||
require.Nil(t, manager.AllowAccess("ben", "mytopic", true, true))
|
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
"Authorization": basicAuth("ben:ben"),
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {
|
func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
c.AuthDefaultRead = false
|
|
||||||
c.AuthDefaultWrite = false
|
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
manager := s.auth.(auth.Manager)
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
||||||
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
|
require.Nil(t, s.userManager.AllowAccess("ben", "mytopic", user.PermissionReadWrite))
|
||||||
require.Nil(t, manager.AllowAccess("ben", "mytopic", true, true))
|
require.Nil(t, s.userManager.AllowAccess("ben", "anothertopic", user.PermissionReadWrite))
|
||||||
require.Nil(t, manager.AllowAccess("ben", "anothertopic", true, true))
|
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic,anothertopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic,anothertopic/auth", "", map[string]string{
|
||||||
"Authorization": basicAuth("ben:ben"),
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
response = request(t, s, "GET", "/mytopic,anothertopic,NOT-THIS-ONE/auth", "", map[string]string{
|
response = request(t, s, "GET", "/mytopic,anothertopic,NOT-THIS-ONE/auth", "", map[string]string{
|
||||||
"Authorization": basicAuth("ben:ben"),
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 403, response.Code)
|
require.Equal(t, 403, response.Code)
|
||||||
}
|
}
|
||||||
@@ -679,47 +671,39 @@ func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {
|
|||||||
func TestServer_Auth_Fail_InvalidPass(t *testing.T) {
|
func TestServer_Auth_Fail_InvalidPass(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
c.AuthDefaultRead = false
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
c.AuthDefaultWrite = false
|
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
manager := s.auth.(auth.Manager)
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
||||||
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
|
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
"Authorization": basicAuth("phil:INVALID"),
|
"Authorization": util.BasicAuth("phil", "INVALID"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 401, response.Code)
|
require.Equal(t, 401, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Auth_Fail_Unauthorized(t *testing.T) {
|
func TestServer_Auth_Fail_Unauthorized(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
c.AuthDefaultRead = false
|
|
||||||
c.AuthDefaultWrite = false
|
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
manager := s.auth.(auth.Manager)
|
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, "unit-test"))
|
||||||
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
|
require.Nil(t, s.userManager.AllowAccess("ben", "sometopic", user.PermissionReadWrite)) // Not mytopic!
|
||||||
require.Nil(t, manager.AllowAccess("ben", "sometopic", true, true)) // Not mytopic!
|
|
||||||
|
|
||||||
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
|
||||||
"Authorization": basicAuth("ben:ben"),
|
"Authorization": util.BasicAuth("ben", "ben"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 403, response.Code)
|
require.Equal(t, 403, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
c.AuthDefault = user.PermissionReadWrite // Open by default
|
||||||
c.AuthDefaultRead = true // Open by default
|
|
||||||
c.AuthDefaultWrite = true // Open by default
|
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
manager := s.auth.(auth.Manager)
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, "unit-test"))
|
||||||
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
|
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "private", user.PermissionDenyAll))
|
||||||
require.Nil(t, manager.AllowAccess(auth.Everyone, "private", false, false))
|
require.Nil(t, s.userManager.AllowAccess(user.Everyone, "announcements", user.PermissionRead))
|
||||||
require.Nil(t, manager.AllowAccess(auth.Everyone, "announcements", true, false))
|
|
||||||
|
|
||||||
response := request(t, s, "PUT", "/mytopic", "test", nil)
|
response := request(t, s, "PUT", "/mytopic", "test", nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
@@ -731,7 +715,7 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
|||||||
require.Equal(t, 403, response.Code) // Cannot write as anonymous
|
require.Equal(t, 403, response.Code) // Cannot write as anonymous
|
||||||
|
|
||||||
response = request(t, s, "PUT", "/announcements", "test", map[string]string{
|
response = request(t, s, "PUT", "/announcements", "test", map[string]string{
|
||||||
"Authorization": basicAuth("phil:phil"),
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
})
|
})
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
@@ -743,44 +727,63 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Auth_ViaQuery(t *testing.T) {
|
func TestServer_Auth_ViaQuery(t *testing.T) {
|
||||||
c := newTestConfig(t)
|
c := newTestConfigWithAuthFile(t)
|
||||||
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
c.AuthDefaultRead = false
|
|
||||||
c.AuthDefaultWrite = false
|
|
||||||
s := newTestServer(t, c)
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
manager := s.auth.(auth.Manager)
|
require.Nil(t, s.userManager.AddUser("ben", "some pass", user.RoleAdmin, "unit-test"))
|
||||||
require.Nil(t, manager.AddUser("ben", "some pass", auth.RoleAdmin))
|
|
||||||
|
|
||||||
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:some pass"))))
|
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "some pass"))))
|
||||||
response := request(t, s, "GET", u, "", nil)
|
response := request(t, s, "GET", u, "", nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:WRONNNGGGG"))))
|
u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(util.BasicAuth("ben", "WRONNNGGGG"))))
|
||||||
response = request(t, s, "GET", u, "", nil)
|
response = request(t, s, "GET", u, "", nil)
|
||||||
require.Equal(t, 401, response.Code)
|
require.Equal(t, 401, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
func TestServer_StatsResetter(t *testing.T) {
|
||||||
func TestServer_Curl_Publish_Poll(t *testing.T) {
|
c := newTestConfigWithAuthFile(t)
|
||||||
s, port := test.StartServer(t)
|
c.AuthDefault = user.PermissionDenyAll
|
||||||
defer test.StopServer(t, s, port)
|
c.VisitorStatsResetTime = time.Now().Add(2 * time.Second)
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
go s.runStatsResetter()
|
||||||
|
|
||||||
cmd := exec.Command("sh", "-c", fmt.Sprintf(`curl -sd "This is a test" localhost:%d/mytopic`, port))
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
require.Nil(t, cmd.Run())
|
require.Nil(t, s.userManager.AllowAccess("phil", "mytopic", user.PermissionReadWrite))
|
||||||
b, err := cmd.CombinedOutput()
|
|
||||||
require.Nil(t, err)
|
|
||||||
msg := toMessage(t, string(b))
|
|
||||||
require.Equal(t, "This is a test", msg.Message)
|
|
||||||
|
|
||||||
cmd = exec.Command("sh", "-c", fmt.Sprintf(`curl "localhost:%d/mytopic?poll=1"`, port))
|
for i := 0; i < 5; i++ {
|
||||||
require.Nil(t, cmd.Run())
|
response := request(t, s, "PUT", "/mytopic", "test", map[string]string{
|
||||||
b, err = cmd.CombinedOutput()
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
response := request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
|
||||||
|
// User stats show 10 messages
|
||||||
|
response = request(t, s, "GET", "/v1/account", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
msg = toMessage(t, string(b))
|
require.Equal(t, int64(5), account.Stats.Messages)
|
||||||
require.Equal(t, "This is a test", msg.Message)
|
|
||||||
|
// Wait for stats resetter to run
|
||||||
|
time.Sleep(2200 * time.Millisecond)
|
||||||
|
|
||||||
|
// User stats show 0 messages now!
|
||||||
|
response = request(t, s, "GET", "/v1/account", "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
account, err = util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), account.Stats.Messages)
|
||||||
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
type testMailer struct {
|
type testMailer struct {
|
||||||
count int
|
count int
|
||||||
@@ -1124,6 +1127,42 @@ func TestServer_PublishAsJSON_Invalid(t *testing.T) {
|
|||||||
require.Equal(t, 400, response.Code)
|
require.Equal(t, 400, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishWithTierBasedMessageLimitAndExpiry(t *testing.T) {
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Create tier with certain limits
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "test",
|
||||||
|
MessagesLimit: 5,
|
||||||
|
MessagesExpiryDuration: -5 * time.Second, // Second, what a hack!
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
||||||
|
// Publish to reach message limit
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
response := request(t, s, "PUT", "/mytopic", fmt.Sprintf("this is message %d", i+1), map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.True(t, msg.Expires < time.Now().Unix()+5)
|
||||||
|
}
|
||||||
|
response := request(t, s, "PUT", "/mytopic", "this is too much", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 429, response.Code)
|
||||||
|
|
||||||
|
// Run pruning and see if they are gone
|
||||||
|
s.execManager()
|
||||||
|
response = request(t, s, "GET", "/mytopic/json?poll=1", "", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
require.Empty(t, response.Body)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachment(t *testing.T) {
|
func TestServer_PublishAttachment(t *testing.T) {
|
||||||
content := util.RandomString(5000) // > 4096
|
content := util.RandomString(5000) // > 4096
|
||||||
s := newTestServer(t, newTestConfig(t))
|
s := newTestServer(t, newTestConfig(t))
|
||||||
@@ -1151,7 +1190,7 @@ func TestServer_PublishAttachment(t *testing.T) {
|
|||||||
require.Equal(t, "", response.Body.String())
|
require.Equal(t, "", response.Body.String())
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
|
size, err := s.messageCache.AttachmentBytesUsedBySender("9.9.9.9") // See request()
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(5000), size)
|
require.Equal(t, int64(5000), size)
|
||||||
}
|
}
|
||||||
@@ -1180,7 +1219,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
|||||||
require.Equal(t, content, response.Body.String())
|
require.Equal(t, content, response.Body.String())
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||||
size, err := s.messageCache.AttachmentBytesUsed("1.2.3.4")
|
size, err := s.messageCache.AttachmentBytesUsedBySender("1.2.3.4")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(21), size)
|
require.Equal(t, int64(21), size)
|
||||||
}
|
}
|
||||||
@@ -1200,7 +1239,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
|||||||
require.Equal(t, netip.Addr{}, msg.Sender)
|
require.Equal(t, netip.Addr{}, msg.Sender)
|
||||||
|
|
||||||
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
|
||||||
size, err := s.messageCache.AttachmentBytesUsed("127.0.0.1")
|
size, err := s.messageCache.AttachmentBytesUsedBySender("127.0.0.1")
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(0), size)
|
require.Equal(t, int64(0), size)
|
||||||
}
|
}
|
||||||
@@ -1286,7 +1325,7 @@ func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *
|
|||||||
require.Equal(t, 41301, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
func TestServer_PublishAttachmentAndExpire(t *testing.T) {
|
||||||
content := util.RandomString(5000) // > 4096
|
content := util.RandomString(5000) // > 4096
|
||||||
|
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
@@ -1307,12 +1346,111 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) {
|
|||||||
|
|
||||||
// Prune and makes sure it's gone
|
// Prune and makes sure it's gone
|
||||||
time.Sleep(time.Second) // Sigh ...
|
time.Sleep(time.Second) // Sigh ...
|
||||||
s.updateStatsAndPrune()
|
s.execManager()
|
||||||
require.NoFileExists(t, file)
|
require.NoFileExists(t, file)
|
||||||
response = request(t, s, "GET", path, "", nil)
|
response = request(t, s, "GET", path, "", nil)
|
||||||
require.Equal(t, 404, response.Code)
|
require.Equal(t, 404, response.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentWithTierBasedExpiry(t *testing.T) {
|
||||||
|
content := util.RandomString(5000) // > 4096
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.AttachmentExpiryDuration = time.Millisecond // Hack
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Create tier with certain limits
|
||||||
|
sevenDays := time.Duration(604800) * time.Second
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "test",
|
||||||
|
MessagesLimit: 10,
|
||||||
|
MessagesExpiryDuration: sevenDays,
|
||||||
|
AttachmentFileSizeLimit: 50_000,
|
||||||
|
AttachmentTotalSizeLimit: 200_000,
|
||||||
|
AttachmentExpiryDuration: sevenDays, // 7 days
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
||||||
|
// Publish and make sure we can retrieve it
|
||||||
|
response := request(t, s, "PUT", "/mytopic", content, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
require.True(t, msg.Attachment.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
|
||||||
|
require.True(t, msg.Expires > time.Now().Add(sevenDays-30*time.Second).Unix())
|
||||||
|
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 still there
|
||||||
|
time.Sleep(time.Second) // Sigh ...
|
||||||
|
s.execManager()
|
||||||
|
require.FileExists(t, file)
|
||||||
|
response = request(t, s, "GET", path, "", nil)
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
|
||||||
|
smallFile := util.RandomString(20_000)
|
||||||
|
largeFile := util.RandomString(50_000)
|
||||||
|
|
||||||
|
c := newTestConfigWithAuthFile(t)
|
||||||
|
c.AttachmentFileSizeLimit = 20_000
|
||||||
|
c.VisitorAttachmentTotalSizeLimit = 40_000
|
||||||
|
s := newTestServer(t, c)
|
||||||
|
|
||||||
|
// Create tier with certain limits
|
||||||
|
require.Nil(t, s.userManager.CreateTier(&user.Tier{
|
||||||
|
Code: "test",
|
||||||
|
MessagesLimit: 100,
|
||||||
|
AttachmentFileSizeLimit: 50_000,
|
||||||
|
AttachmentTotalSizeLimit: 200_000,
|
||||||
|
AttachmentExpiryDuration: 30 * time.Second,
|
||||||
|
}))
|
||||||
|
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, s.userManager.ChangeTier("phil", "test"))
|
||||||
|
|
||||||
|
// Publish small file as anonymous
|
||||||
|
response := request(t, s, "PUT", "/mytopic", smallFile, nil)
|
||||||
|
msg := toMessage(t, response.Body.String())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||||
|
|
||||||
|
// Publish large file as anonymous
|
||||||
|
response = request(t, s, "PUT", "/mytopic", largeFile, nil)
|
||||||
|
require.Equal(t, 413, response.Code)
|
||||||
|
require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
|
||||||
|
// Publish too large file as phil
|
||||||
|
response = request(t, s, "PUT", "/mytopic", largeFile+" a few more bytes", map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 413, response.Code)
|
||||||
|
require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
|
||||||
|
// Publish large file as phil (4x)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 200, response.Code)
|
||||||
|
msg = toMessage(t, response.Body.String())
|
||||||
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||||
|
}
|
||||||
|
response = request(t, s, "PUT", "/mytopic", largeFile, map[string]string{
|
||||||
|
"Authorization": util.BasicAuth("phil", "phil"),
|
||||||
|
})
|
||||||
|
require.Equal(t, 413, response.Code)
|
||||||
|
require.Equal(t, 41301, toHTTPError(t, response.Body.String()).Code)
|
||||||
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
|
func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
|
||||||
content := util.RandomString(5000) // > 4096
|
content := util.RandomString(5000) // > 4096
|
||||||
|
|
||||||
@@ -1325,7 +1463,7 @@ func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
|
|||||||
msg := toMessage(t, response.Body.String())
|
msg := toMessage(t, response.Body.String())
|
||||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
|
||||||
// Get it 4 times successfully
|
// Value it 4 times successfully
|
||||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||||
for i := 1; i <= 4; i++ { // 4 successful downloads
|
for i := 1; i <= 4; i++ { // 4 successful downloads
|
||||||
response = request(t, s, "GET", path, "", nil)
|
response = request(t, s, "GET", path, "", nil)
|
||||||
@@ -1361,7 +1499,7 @@ func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
|
|||||||
require.Equal(t, 41301, err.Code)
|
require.Equal(t, 41301, err.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_PublishAttachmentUserStats(t *testing.T) {
|
func TestServer_PublishAttachmentAccountStats(t *testing.T) {
|
||||||
content := util.RandomString(4999) // > 4096
|
content := util.RandomString(4999) // > 4096
|
||||||
|
|
||||||
c := newTestConfig(t)
|
c := newTestConfig(t)
|
||||||
@@ -1375,14 +1513,15 @@ func TestServer_PublishAttachmentUserStats(t *testing.T) {
|
|||||||
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/")
|
||||||
|
|
||||||
// User stats
|
// User stats
|
||||||
response = request(t, s, "GET", "/user/stats", "", nil)
|
response = request(t, s, "GET", "/v1/account", "", nil)
|
||||||
require.Equal(t, 200, response.Code)
|
require.Equal(t, 200, response.Code)
|
||||||
var stats visitorStats
|
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(response.Body))
|
||||||
require.Nil(t, json.NewDecoder(strings.NewReader(response.Body.String())).Decode(&stats))
|
require.Nil(t, err)
|
||||||
require.Equal(t, int64(5000), stats.AttachmentFileSizeLimit)
|
require.Equal(t, int64(5000), account.Limits.AttachmentFileSize)
|
||||||
require.Equal(t, int64(6000), stats.VisitorAttachmentBytesTotal)
|
require.Equal(t, int64(6000), account.Limits.AttachmentTotalSize)
|
||||||
require.Equal(t, int64(4999), stats.VisitorAttachmentBytesUsed)
|
require.Equal(t, int64(4999), account.Stats.AttachmentTotalSize)
|
||||||
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
|
require.Equal(t, int64(1001), account.Stats.AttachmentTotalSizeRemaining)
|
||||||
|
require.Equal(t, int64(1), account.Stats.Messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
||||||
@@ -1392,7 +1531,8 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
|||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11"
|
||||||
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
||||||
v := s.visitor(r)
|
v, err := s.visitor(r)
|
||||||
|
require.Nil(t, err)
|
||||||
require.Equal(t, "8.9.10.11", v.ip.String())
|
require.Equal(t, "8.9.10.11", v.ip.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1403,7 +1543,8 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
|
|||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11"
|
||||||
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
||||||
v := s.visitor(r)
|
v, err := s.visitor(r)
|
||||||
|
require.Nil(t, err)
|
||||||
require.Equal(t, "1.1.1.1", v.ip.String())
|
require.Equal(t, "1.1.1.1", v.ip.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1414,7 +1555,8 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
|||||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||||
r.RemoteAddr = "8.9.10.11"
|
r.RemoteAddr = "8.9.10.11"
|
||||||
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
||||||
v := s.visitor(r)
|
v, err := s.visitor(r)
|
||||||
|
require.Nil(t, err)
|
||||||
require.Equal(t, "234.5.2.1", v.ip.String())
|
require.Equal(t, "234.5.2.1", v.ip.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1443,7 +1585,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
log.Printf("Updating stats")
|
log.Printf("Updating stats")
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
s.updateStatsAndPrune()
|
s.execManager()
|
||||||
log.Printf("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond))
|
log.Printf("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond))
|
||||||
statsChan <- true
|
statsChan <- true
|
||||||
}()
|
}()
|
||||||
@@ -1471,6 +1613,12 @@ func newTestConfig(t *testing.T) *Config {
|
|||||||
return conf
|
return conf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newTestConfigWithAuthFile(t *testing.T) *Config {
|
||||||
|
conf := newTestConfig(t)
|
||||||
|
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||||
|
return conf
|
||||||
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T, config *Config) *Server {
|
func newTestServer(t *testing.T, config *Config) *Server {
|
||||||
server, err := New(config)
|
server, err := New(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1534,10 +1682,6 @@ func toHTTPError(t *testing.T, s string) *errHTTP {
|
|||||||
return &e
|
return &e
|
||||||
}
|
}
|
||||||
|
|
||||||
func basicAuth(s string) string {
|
|
||||||
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func readAll(t *testing.T, rc io.ReadCloser) string {
|
func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||||
b, err := io.ReadAll(rc)
|
b, err := io.ReadAll(rc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ type smtpBackend struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ smtp.Backend = (*smtpBackend)(nil)
|
|
||||||
var _ smtp.Session = (*smtpSession)(nil)
|
|
||||||
|
|
||||||
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
|
func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Request)) *smtpBackend {
|
||||||
return &smtpBackend{
|
return &smtpBackend{
|
||||||
config: conf,
|
config: conf,
|
||||||
@@ -44,9 +41,14 @@ func newMailBackend(conf *Config, handler func(http.ResponseWriter, *http.Reques
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) NewSession(conn *smtp.Conn) (smtp.Session, error) {
|
func (b *smtpBackend) Login(state *smtp.ConnectionState, username, password string) (smtp.Session, error) {
|
||||||
log.Debug("%s Incoming mail", logSMTPPrefix(conn))
|
log.Debug("%s Incoming mail, login with user %s", logSMTPPrefix(state), username)
|
||||||
return &smtpSession{backend: b, conn: conn}, nil
|
return &smtpSession{backend: b, state: state}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *smtpBackend) AnonymousLogin(state *smtp.ConnectionState) (smtp.Session, error) {
|
||||||
|
log.Debug("%s Incoming mail, anonymous login", logSMTPPrefix(state))
|
||||||
|
return &smtpSession{backend: b, state: state}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
||||||
@@ -58,23 +60,23 @@ func (b *smtpBackend) Counts() (total int64, success int64, failure int64) {
|
|||||||
// smtpSession is returned after EHLO.
|
// smtpSession is returned after EHLO.
|
||||||
type smtpSession struct {
|
type smtpSession struct {
|
||||||
backend *smtpBackend
|
backend *smtpBackend
|
||||||
conn *smtp.Conn
|
state *smtp.ConnectionState
|
||||||
topic string
|
topic string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) AuthPlain(username, _ string) error {
|
func (s *smtpSession) AuthPlain(username, password string) error {
|
||||||
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.conn), username)
|
log.Debug("%s AUTH PLAIN (with username %s)", logSMTPPrefix(s.state), username)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
func (s *smtpSession) Mail(from string, opts smtp.MailOptions) error {
|
||||||
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.conn), from, opts)
|
log.Debug("%s MAIL FROM: %s (with options: %#v)", logSMTPPrefix(s.state), from, opts)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *smtpSession) Rcpt(to string) error {
|
func (s *smtpSession) Rcpt(to string) error {
|
||||||
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.conn), to)
|
log.Debug("%s RCPT TO: %s", logSMTPPrefix(s.state), to)
|
||||||
return s.withFailCount(func() error {
|
return s.withFailCount(func() error {
|
||||||
conf := s.backend.config
|
conf := s.backend.config
|
||||||
addressList, err := mail.ParseAddressList(to)
|
addressList, err := mail.ParseAddressList(to)
|
||||||
@@ -112,9 +114,9 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if log.IsTrace() {
|
if log.IsTrace() {
|
||||||
log.Trace("%s DATA: %s", logSMTPPrefix(s.conn), string(b))
|
log.Trace("%s DATA: %s", logSMTPPrefix(s.state), string(b))
|
||||||
} else if log.IsDebug() {
|
} else if log.IsDebug() {
|
||||||
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.conn), len(b))
|
log.Debug("%s DATA: %d byte(s)", logSMTPPrefix(s.state), len(b))
|
||||||
}
|
}
|
||||||
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
msg, err := mail.ReadMessage(bytes.NewReader(b))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -154,9 +156,9 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
|
|
||||||
func (s *smtpSession) publishMessage(m *message) error {
|
func (s *smtpSession) publishMessage(m *message) error {
|
||||||
// Extract remote address (for rate limiting)
|
// Extract remote address (for rate limiting)
|
||||||
remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
|
remoteAddr, _, err := net.SplitHostPort(s.state.RemoteAddr.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
remoteAddr = s.conn.Conn().RemoteAddr().String()
|
remoteAddr = s.state.RemoteAddr.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call HTTP handler with fake HTTP request
|
// Call HTTP handler with fake HTTP request
|
||||||
@@ -196,7 +198,7 @@ func (s *smtpSession) withFailCount(fn func() error) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// Almost all of these errors are parse errors, and user input errors.
|
// Almost all of these errors are parse errors, and user input errors.
|
||||||
// We do not want to spam the log with WARN messages.
|
// We do not want to spam the log with WARN messages.
|
||||||
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.conn), err.Error())
|
log.Debug("%s Incoming mail error: %s", logSMTPPrefix(s.state), err.Error())
|
||||||
s.backend.failure++
|
s.backend.failure++
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSmtpBackend_Multipart(t *testing.T) {
|
func TestSmtpBackend_Multipart(t *testing.T) {
|
||||||
email := `EHLO example.com
|
email := `MIME-Version: 1.0
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
|
||||||
DATA
|
|
||||||
MIME-Version: 1.0
|
|
||||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
Subject: and one more
|
Subject: and one more
|
||||||
@@ -35,25 +28,20 @@ Content-Type: text/html; charset="UTF-8"
|
|||||||
|
|
||||||
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
<div dir="ltr">what's up<br clear="all"><div><br></div></div>
|
||||||
|
|
||||||
--000000000000f3320b05d42915c9--
|
--000000000000f3320b05d42915c9--`
|
||||||
.
|
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
`
|
|
||||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
require.Equal(t, "/mytopic", r.URL.Path)
|
||||||
require.Equal(t, "and one more", r.Header.Get("Title"))
|
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
defer s.Close()
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
defer c.Close()
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_MultipartNoBody(t *testing.T) {
|
func TestSmtpBackend_MultipartNoBody(t *testing.T) {
|
||||||
email := `EHLO example.com
|
email := `MIME-Version: 1.0
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: ntfy-emailtest@ntfy.sh
|
|
||||||
DATA
|
|
||||||
MIME-Version: 1.0
|
|
||||||
Date: Tue, 28 Dec 2021 01:33:34 +0100
|
Date: Tue, 28 Dec 2021 01:33:34 +0100
|
||||||
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
|
Message-ID: <CAAvm7ABCDsi9vsuu0WTRXzZQBC8dXrDOLT8iCWdqrsmg@mail.gmail.com>
|
||||||
Subject: This email has a subject but no body
|
Subject: This email has a subject but no body
|
||||||
@@ -71,25 +59,20 @@ Content-Type: text/html; charset="UTF-8"
|
|||||||
|
|
||||||
<div dir="ltr"><br></div>
|
<div dir="ltr"><br></div>
|
||||||
|
|
||||||
--000000000000bcf4a405d429f8d4--
|
--000000000000bcf4a405d429f8d4--`
|
||||||
.
|
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
`
|
|
||||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
require.Equal(t, "/emailtest", r.URL.Path)
|
require.Equal(t, "/emailtest", r.URL.Path)
|
||||||
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
|
require.Equal(t, "", r.Header.Get("Title")) // We flipped message and body
|
||||||
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
|
require.Equal(t, "This email has a subject but no body", readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
defer s.Close()
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
defer c.Close()
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
require.Nil(t, session.Rcpt("ntfy-emailtest@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Plaintext(t *testing.T) {
|
func TestSmtpBackend_Plaintext(t *testing.T) {
|
||||||
email := `EHLO example.com
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: mytopic@ntfy.sh
|
|
||||||
DATA
|
|
||||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
|
||||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
Subject: and one more
|
Subject: and one more
|
||||||
From: Phil <phil@example.com>
|
From: Phil <phil@example.com>
|
||||||
@@ -97,68 +80,56 @@ To: mytopic@ntfy.sh
|
|||||||
Content-Type: text/plain; charset="UTF-8"
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
what's up
|
what's up
|
||||||
.
|
|
||||||
`
|
`
|
||||||
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
require.Equal(t, "/mytopic", r.URL.Path)
|
||||||
require.Equal(t, "and one more", r.Header.Get("Title"))
|
require.Equal(t, "and one more", r.Header.Get("Title"))
|
||||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
defer s.Close()
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
defer c.Close()
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
|
func TestSmtpBackend_Plaintext_No_ContentType(t *testing.T) {
|
||||||
email := `EHLO example.com
|
email := `Subject: Very short mail
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: mytopic@ntfy.sh
|
|
||||||
DATA
|
|
||||||
Subject: Very short mail
|
|
||||||
|
|
||||||
what's up
|
what's up
|
||||||
.
|
|
||||||
`
|
`
|
||||||
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "/mytopic", r.URL.Path)
|
require.Equal(t, "/mytopic", r.URL.Path)
|
||||||
require.Equal(t, "Very short mail", r.Header.Get("Title"))
|
require.Equal(t, "Very short mail", r.Header.Get("Title"))
|
||||||
require.Equal(t, "what's up", readAll(t, r.Body))
|
require.Equal(t, "what's up", readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
defer s.Close()
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
defer c.Close()
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
require.Nil(t, session.Rcpt("mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
|
func TestSmtpBackend_Plaintext_EncodedSubject(t *testing.T) {
|
||||||
email := `EHLO example.com
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
|
||||||
DATA
|
|
||||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
|
||||||
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
|
Subject: =?UTF-8?B?VGhyZWUgc2FudGFzIPCfjoXwn46F8J+OhQ==?=
|
||||||
From: Phil <phil@example.com>
|
From: Phil <phil@example.com>
|
||||||
To: ntfy-mytopic@ntfy.sh
|
To: ntfy-mytopic@ntfy.sh
|
||||||
Content-Type: text/plain; charset="UTF-8"
|
Content-Type: text/plain; charset="UTF-8"
|
||||||
|
|
||||||
what's up
|
what's up
|
||||||
.
|
|
||||||
`
|
`
|
||||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
_, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
|
require.Equal(t, "Three santas 🎅🎅🎅", r.Header.Get("Title"))
|
||||||
})
|
})
|
||||||
defer s.Close()
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
defer c.Close()
|
require.Nil(t, session.Mail("phil@example.com", smtp.MailOptions{}))
|
||||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
require.Nil(t, session.Rcpt("ntfy-mytopic@ntfy.sh"))
|
||||||
|
require.Nil(t, session.Data(strings.NewReader(email)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
|
func TestSmtpBackend_Plaintext_TooLongTruncate(t *testing.T) {
|
||||||
email := `EHLO example.com
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: mytopic@ntfy.sh
|
|
||||||
DATA
|
|
||||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
|
||||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
Subject: and one more
|
Subject: and one more
|
||||||
From: Phil <phil@example.com>
|
From: Phil <phil@example.com>
|
||||||
@@ -177,61 +148,60 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
that should do it
|
that should do it
|
||||||
.
|
|
||||||
`
|
`
|
||||||
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
conf, backend := newTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
expected := `you know this is a string.
|
expected := `you know this is a string.
|
||||||
it's a long string.
|
it's a long string.
|
||||||
it's supposed to be longer than the max message length
|
it's supposed to be longer than the max message length
|
||||||
@@ -244,71 +214,68 @@ so i'm gonna fill the rest of this with AAAAAAAAAAAAAAAAAAAAAAAAAAA
|
|||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa
|
||||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
pppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp
|
......................................................................
|
||||||
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
and with BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB
|
||||||
BBBBBBBBBBBBBBBBBBBBBBBBB`
|
BBBBBBBBBBBBBBBBBBBBBBBBB`
|
||||||
require.Equal(t, 4096, len(expected)) // Sanity check
|
require.Equal(t, 4096, len(expected)) // Sanity check
|
||||||
require.Equal(t, expected, readAll(t, r.Body))
|
require.Equal(t, expected, readAll(t, r.Body))
|
||||||
})
|
})
|
||||||
defer s.Close()
|
|
||||||
defer c.Close()
|
|
||||||
conf.SMTPServerAddrPrefix = ""
|
conf.SMTPServerAddrPrefix = ""
|
||||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
session, _ := backend.AnonymousLogin(fakeConnState(t, "1.2.3.4"))
|
||||||
|
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) {
|
func TestSmtpBackend_Unsupported(t *testing.T) {
|
||||||
email := `EHLO example.com
|
email := `Date: Tue, 28 Dec 2021 00:30:10 +0100
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: ntfy-mytopic@ntfy.sh
|
|
||||||
DATA
|
|
||||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
|
||||||
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
Message-ID: <CAAvm79YP0C=Rt1N=KWmSUBB87KK2rRChmdzKqF1vCwMEUiVzLQ@mail.gmail.com>
|
||||||
Subject: and one more
|
Subject: and one more
|
||||||
From: Phil <phil@example.com>
|
From: Phil <phil@example.com>
|
||||||
@@ -316,89 +283,34 @@ To: mytopic@ntfy.sh
|
|||||||
Content-Type: text/SOMETHINGELSE
|
Content-Type: text/SOMETHINGELSE
|
||||||
|
|
||||||
what's up
|
what's up
|
||||||
.
|
|
||||||
`
|
`
|
||||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
conf, backend := newTestBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||||
t.Fatal("This should not be called")
|
// Nothing.
|
||||||
})
|
})
|
||||||
defer s.Close()
|
conf.SMTPServerAddrPrefix = ""
|
||||||
defer c.Close()
|
session, _ := backend.Login(fakeConnState(t, "1.2.3.4"), "user", "pass")
|
||||||
writeAndReadUntilLine(t, email, c, scanner, "554 5.0.0 Error: transaction failed, blame it on the weather: unsupported content type")
|
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 TestSmtpBackend_InvalidAddress(t *testing.T) {
|
func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Request)) (*Config, *smtpBackend) {
|
||||||
email := `EHLO example.com
|
conf := newTestConfig(t)
|
||||||
MAIL FROM: phil@example.com
|
|
||||||
RCPT TO: unsupported@ntfy.sh
|
|
||||||
DATA
|
|
||||||
Date: Tue, 28 Dec 2021 00:30:10 +0100
|
|
||||||
Subject: and one more
|
|
||||||
From: Phil <phil@example.com>
|
|
||||||
To: mytopic@ntfy.sh
|
|
||||||
Content-Type: text/plain
|
|
||||||
|
|
||||||
what's up
|
|
||||||
.
|
|
||||||
`
|
|
||||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
t.Fatal("This should not be called")
|
|
||||||
})
|
|
||||||
defer s.Close()
|
|
||||||
defer c.Close()
|
|
||||||
writeAndReadUntilLine(t, email, c, scanner, "451 4.0.0 invalid address")
|
|
||||||
}
|
|
||||||
|
|
||||||
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
|
|
||||||
|
|
||||||
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {
|
|
||||||
conf = newTestConfig(t)
|
|
||||||
conf.SMTPServerListen = ":25"
|
conf.SMTPServerListen = ":25"
|
||||||
conf.SMTPServerDomain = "ntfy.sh"
|
conf.SMTPServerDomain = "ntfy.sh"
|
||||||
conf.SMTPServerAddrPrefix = "ntfy-"
|
conf.SMTPServerAddrPrefix = "ntfy-"
|
||||||
backend := newMailBackend(conf, handler)
|
backend := newMailBackend(conf, handler)
|
||||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
return conf, backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
|
||||||
|
ip, err := net.ResolveIPAddr("ip", remoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
s = smtp.NewServer(backend)
|
return &smtp.ConnectionState{
|
||||||
s.Domain = conf.SMTPServerDomain
|
Hostname: "myhostname",
|
||||||
s.AllowInsecureAuth = true
|
LocalAddr: ip,
|
||||||
go func() {
|
RemoteAddr: ip,
|
||||||
require.Nil(t, s.Serve(l))
|
|
||||||
}()
|
|
||||||
c, err = net.Dial("tcp", l.Addr().String())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
scanner = bufio.NewScanner(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeAndReadUntilLine(t *testing.T, email string, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {
|
|
||||||
_, err := io.WriteString(conn, email)
|
|
||||||
require.Nil(t, err)
|
|
||||||
readUntilLine(t, conn, scanner, expectedLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
func readUntilLine(t *testing.T, conn net.Conn, scanner *bufio.Scanner, expectedLine string) {
|
|
||||||
cancelChan := make(chan bool)
|
|
||||||
go func() {
|
|
||||||
select {
|
|
||||||
case <-cancelChan:
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
conn.Close()
|
|
||||||
t.Error("Failed waiting for expected output")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
var output string
|
|
||||||
for scanner.Scan() {
|
|
||||||
text := scanner.Text()
|
|
||||||
if strings.TrimSpace(text) == expectedLine {
|
|
||||||
cancelChan <- true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output += text + "\n"
|
|
||||||
//fmt.Println(text)
|
|
||||||
}
|
|
||||||
t.Fatalf("Expected line '%s' not found in output:\n%s", expectedLine, output)
|
|
||||||
}
|
}
|
||||||
|
|||||||
146
server/types.go
146
server/types.go
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,9 +23,10 @@ const (
|
|||||||
|
|
||||||
// message represents a message published to a topic
|
// message represents a message published to a topic
|
||||||
type message struct {
|
type message struct {
|
||||||
ID string `json:"id"` // Random message ID
|
ID string `json:"id"` // Random message ID
|
||||||
Time int64 `json:"time"` // Unix time in seconds
|
Time int64 `json:"time"` // Unix time in seconds
|
||||||
Event string `json:"event"` // One of the above
|
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
||||||
|
Event string `json:"event"` // One of the above
|
||||||
Topic string `json:"topic"`
|
Topic string `json:"topic"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
@@ -35,8 +37,9 @@ type message struct {
|
|||||||
Actions []*action `json:"actions,omitempty"`
|
Actions []*action `json:"actions,omitempty"`
|
||||||
Attachment *attachment `json:"attachment,omitempty"`
|
Attachment *attachment `json:"attachment,omitempty"`
|
||||||
PollID string `json:"poll_id,omitempty"`
|
PollID string `json:"poll_id,omitempty"`
|
||||||
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
|
||||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||||
|
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
||||||
|
User string `json:"-"` // Username of the uploader, used to associated attachments
|
||||||
}
|
}
|
||||||
|
|
||||||
type attachment struct {
|
type attachment struct {
|
||||||
@@ -217,3 +220,138 @@ func (q *queryFilter) Pass(msg *message) bool {
|
|||||||
type apiHealthResponse struct {
|
type apiHealthResponse struct {
|
||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type apiAccountCreateRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountPasswordChangeRequest struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountTokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Expires int64 `json:"expires"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountTier struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountLimits struct {
|
||||||
|
Basis string `json:"basis,omitempty"` // "ip", "role" or "tier"
|
||||||
|
Messages int64 `json:"messages"`
|
||||||
|
MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
|
||||||
|
Emails int64 `json:"emails"`
|
||||||
|
Reservations int64 `json:"reservations"`
|
||||||
|
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||||
|
AttachmentFileSize int64 `json:"attachment_file_size"`
|
||||||
|
AttachmentExpiryDuration int64 `json:"attachment_expiry_duration"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountStats struct {
|
||||||
|
Messages int64 `json:"messages"`
|
||||||
|
MessagesRemaining int64 `json:"messages_remaining"`
|
||||||
|
Emails int64 `json:"emails"`
|
||||||
|
EmailsRemaining int64 `json:"emails_remaining"`
|
||||||
|
Reservations int64 `json:"reservations"`
|
||||||
|
ReservationsRemaining int64 `json:"reservations_remaining"`
|
||||||
|
AttachmentTotalSize int64 `json:"attachment_total_size"`
|
||||||
|
AttachmentTotalSizeRemaining int64 `json:"attachment_total_size_remaining"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountReservation struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Everyone string `json:"everyone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountBilling struct {
|
||||||
|
Customer bool `json:"customer"`
|
||||||
|
Subscription bool `json:"subscription"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
PaidUntil int64 `json:"paid_until,omitempty"`
|
||||||
|
CancelAt int64 `json:"cancel_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountResponse struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
SyncTopic string `json:"sync_topic,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Notification *user.NotificationPrefs `json:"notification,omitempty"`
|
||||||
|
Subscriptions []*user.Subscription `json:"subscriptions,omitempty"`
|
||||||
|
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||||
|
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||||
|
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||||
|
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||||
|
Billing *apiAccountBilling `json:"billing,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountReservationRequest struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Everyone string `json:"everyone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiConfigResponse struct {
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
AppRoot string `json:"app_root"`
|
||||||
|
EnableLogin bool `json:"enable_login"`
|
||||||
|
EnableSignup bool `json:"enable_signup"`
|
||||||
|
EnablePayments bool `json:"enable_payments"`
|
||||||
|
EnableReservations bool `json:"enable_reservations"`
|
||||||
|
DisallowedTopics []string `json:"disallowed_topics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountBillingTier struct {
|
||||||
|
Code string `json:"code,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Price string `json:"price,omitempty"`
|
||||||
|
Limits *apiAccountLimits `json:"limits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountBillingSubscriptionCreateResponse struct {
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountBillingSubscriptionChangeRequest struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountBillingPortalRedirectResponse struct {
|
||||||
|
RedirectURL string `json:"redirect_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiAccountSyncTopicResponse struct {
|
||||||
|
Event string `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiSuccessResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSuccessResponse() *apiSuccessResponse {
|
||||||
|
return &apiSuccessResponse{
|
||||||
|
Success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiStripeSubscriptionUpdatedEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Customer string `json:"customer"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CurrentPeriodEnd int64 `json:"current_period_end"`
|
||||||
|
CancelAt int64 `json:"cancel_at"`
|
||||||
|
Items *struct {
|
||||||
|
Data []*struct {
|
||||||
|
Price *struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"price"`
|
||||||
|
} `json:"data"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiStripeSubscriptionDeletedEvent struct {
|
||||||
|
Customer string `json:"customer"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package server
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emersion/go-smtp"
|
"github.com/emersion/go-smtp"
|
||||||
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
@@ -57,8 +60,8 @@ func logHTTPPrefix(v *visitor, r *http.Request) string {
|
|||||||
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
|
return fmt.Sprintf("%s HTTP %s %s", v.ip, r.Method, requestURI)
|
||||||
}
|
}
|
||||||
|
|
||||||
func logSMTPPrefix(conn *smtp.Conn) string {
|
func logSMTPPrefix(state *smtp.ConnectionState) string {
|
||||||
return fmt.Sprintf("%s/%s SMTP", conn.Hostname(), conn.Conn().RemoteAddr().String())
|
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHTTPRequest(r *http.Request) string {
|
func renderHTTPRequest(r *http.Request) string {
|
||||||
@@ -89,3 +92,45 @@ func renderHTTPRequest(r *http.Request) string {
|
|||||||
r.Body = body // Important: Reset body, so it can be re-read
|
r.Body = body // Important: Reset body, so it can be re-read
|
||||||
return strings.TrimSpace(lines)
|
return strings.TrimSpace(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractIPAddress(r *http.Request, behindProxy bool) netip.Addr {
|
||||||
|
remoteAddr := r.RemoteAddr
|
||||||
|
addrPort, err := netip.ParseAddrPort(remoteAddr)
|
||||||
|
ip := addrPort.Addr()
|
||||||
|
if err != nil {
|
||||||
|
// This should not happen in real life; only in tests. So, using falling back to 0.0.0.0 if address unspecified
|
||||||
|
ip, err = netip.ParseAddr(remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
ip = netip.IPv4Unspecified()
|
||||||
|
if remoteAddr != "@" || !behindProxy { // RemoteAddr is @ when unix socket is used
|
||||||
|
log.Warn("unable to parse IP (%s), new visitor with unspecified IP (0.0.0.0) created %s", remoteAddr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if behindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" {
|
||||||
|
// X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy,
|
||||||
|
// only the right-most address can be trusted (as this is the one added by our proxy server).
|
||||||
|
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
|
||||||
|
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
|
||||||
|
realIP, err := netip.ParseAddr(strings.TrimSpace(util.LastString(ips, remoteAddr)))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("invalid IP address %s received in X-Forwarded-For header: %s", ip, err.Error())
|
||||||
|
// Fall back to regular remote address if X-Forwarded-For is damaged
|
||||||
|
} else {
|
||||||
|
ip = realIP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
func readJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
||||||
|
obj, err := util.UnmarshalJSONWithLimit[T](r, limit)
|
||||||
|
if err == util.ErrUnmarshalJSON {
|
||||||
|
return nil, errHTTPBadRequestJSONInvalid
|
||||||
|
} else if err == util.ErrTooLargeJSON {
|
||||||
|
return nil, errHTTPEntityTooLargeJSONBody
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return obj, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"heckel.io/ntfy/user"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,6 +16,10 @@ const (
|
|||||||
// has to be very high to prevent e-mail abuse, but it doesn't really affect the other limits anyway, since
|
// 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).
|
// they are replenished faster (typically).
|
||||||
visitorExpungeAfter = 24 * time.Hour
|
visitorExpungeAfter = 24 * time.Hour
|
||||||
|
|
||||||
|
// visitorDefaultReservationsLimit is the amount of topic names a user without a tier is allowed to reserve.
|
||||||
|
// This number is zero, and changing it may have unintended consequences in the web app, or otherwise
|
||||||
|
visitorDefaultReservationsLimit = int64(0)
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -23,41 +28,99 @@ var (
|
|||||||
|
|
||||||
// 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
|
||||||
messageCache *messageCache
|
messageCache *messageCache
|
||||||
ip netip.Addr
|
userManager *user.Manager // May be nil!
|
||||||
requests *rate.Limiter
|
ip netip.Addr
|
||||||
emails *rate.Limiter
|
user *user.User
|
||||||
subscriptions util.Limiter
|
messages int64 // Number of messages sent, reset every day
|
||||||
bandwidth util.Limiter
|
emails int64 // Number of emails sent, reset every day
|
||||||
firebase time.Time // Next allowed Firebase message
|
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||||
seen time.Time
|
messagesLimiter util.Limiter // Rate limiter for messages, may be nil
|
||||||
mu sync.Mutex
|
emailsLimiter *rate.Limiter // Rate limiter for emails
|
||||||
|
subscriptionLimiter util.Limiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||||
|
bandwidthLimiter util.Limiter // Limiter for attachment bandwidth downloads
|
||||||
|
accountLimiter *rate.Limiter // Rate limiter for account creation
|
||||||
|
firebase time.Time // Next allowed Firebase message
|
||||||
|
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type visitorInfo struct {
|
||||||
|
Limits *visitorLimits
|
||||||
|
Stats *visitorStats
|
||||||
|
}
|
||||||
|
|
||||||
|
type visitorLimits struct {
|
||||||
|
Basis visitorLimitBasis
|
||||||
|
MessagesLimit int64
|
||||||
|
MessagesExpiryDuration time.Duration
|
||||||
|
EmailsLimit int64
|
||||||
|
ReservationsLimit int64
|
||||||
|
AttachmentTotalSizeLimit int64
|
||||||
|
AttachmentFileSizeLimit int64
|
||||||
|
AttachmentExpiryDuration time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type visitorStats struct {
|
type visitorStats struct {
|
||||||
AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"`
|
Messages int64
|
||||||
VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"`
|
MessagesRemaining int64
|
||||||
VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"`
|
Emails int64
|
||||||
VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"`
|
EmailsRemaining int64
|
||||||
|
Reservations int64
|
||||||
|
ReservationsRemaining int64
|
||||||
|
AttachmentTotalSize int64
|
||||||
|
AttachmentTotalSizeRemaining int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr) *visitor {
|
// visitorLimitBasis describes how the visitor limits were derived, either from a user's
|
||||||
|
// IP address (default config), or from its tier
|
||||||
|
type visitorLimitBasis string
|
||||||
|
|
||||||
|
const (
|
||||||
|
visitorLimitBasisIP = visitorLimitBasis("ip")
|
||||||
|
visitorLimitBasisTier = visitorLimitBasis("tier")
|
||||||
|
)
|
||||||
|
|
||||||
|
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
||||||
|
var messagesLimiter util.Limiter
|
||||||
|
var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter
|
||||||
|
var messages, emails int64
|
||||||
|
if user != nil {
|
||||||
|
messages = user.Stats.Messages
|
||||||
|
emails = user.Stats.Emails
|
||||||
|
} else {
|
||||||
|
accountLimiter = rate.NewLimiter(rate.Every(conf.VisitorAccountCreateLimitReplenish), conf.VisitorAccountCreateLimitBurst)
|
||||||
|
}
|
||||||
|
if user != nil && user.Tier != nil {
|
||||||
|
requestLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.MessagesLimit), conf.VisitorRequestLimitBurst)
|
||||||
|
messagesLimiter = util.NewFixedLimiter(user.Tier.MessagesLimit)
|
||||||
|
emailsLimiter = rate.NewLimiter(dailyLimitToRate(user.Tier.EmailsLimit), conf.VisitorEmailLimitBurst)
|
||||||
|
} else {
|
||||||
|
requestLimiter = rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst)
|
||||||
|
emailsLimiter = rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst)
|
||||||
|
}
|
||||||
return &visitor{
|
return &visitor{
|
||||||
config: conf,
|
config: conf,
|
||||||
messageCache: messageCache,
|
messageCache: messageCache,
|
||||||
ip: ip,
|
userManager: userManager, // May be nil
|
||||||
requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst),
|
ip: ip,
|
||||||
emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst),
|
user: user,
|
||||||
subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
messages: messages,
|
||||||
bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
emails: emails,
|
||||||
firebase: time.Unix(0, 0),
|
requestLimiter: requestLimiter,
|
||||||
seen: time.Now(),
|
messagesLimiter: messagesLimiter, // May be nil
|
||||||
|
emailsLimiter: emailsLimiter,
|
||||||
|
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||||
|
bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour),
|
||||||
|
accountLimiter: accountLimiter, // May be nil
|
||||||
|
firebase: time.Unix(0, 0),
|
||||||
|
seen: time.Now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) RequestAllowed() error {
|
func (v *visitor) RequestAllowed() error {
|
||||||
if !v.requests.Allow() {
|
if !v.requestLimiter.Allow() {
|
||||||
return errVisitorLimitReached
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -78,8 +141,15 @@ func (v *visitor) FirebaseTemporarilyDeny() {
|
|||||||
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
|
v.firebase = time.Now().Add(v.config.FirebaseQuotaExceededPenaltyDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (v *visitor) MessageAllowed() error {
|
||||||
|
if v.messagesLimiter != nil && v.messagesLimiter.Allow(1) != nil {
|
||||||
|
return errVisitorLimitReached
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (v *visitor) EmailAllowed() error {
|
func (v *visitor) EmailAllowed() error {
|
||||||
if !v.emails.Allow() {
|
if !v.emailsLimiter.Allow() {
|
||||||
return errVisitorLimitReached
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -88,7 +158,7 @@ func (v *visitor) EmailAllowed() error {
|
|||||||
func (v *visitor) SubscriptionAllowed() error {
|
func (v *visitor) SubscriptionAllowed() error {
|
||||||
v.mu.Lock()
|
v.mu.Lock()
|
||||||
defer v.mu.Unlock()
|
defer v.mu.Unlock()
|
||||||
if err := v.subscriptions.Allow(1); err != nil {
|
if err := v.subscriptionLimiter.Allow(1); err != nil {
|
||||||
return errVisitorLimitReached
|
return errVisitorLimitReached
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -97,7 +167,7 @@ func (v *visitor) SubscriptionAllowed() 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.Allow(-1)
|
v.subscriptionLimiter.Allow(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) Keepalive() {
|
func (v *visitor) Keepalive() {
|
||||||
@@ -107,7 +177,7 @@ func (v *visitor) Keepalive() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) BandwidthLimiter() util.Limiter {
|
func (v *visitor) BandwidthLimiter() util.Limiter {
|
||||||
return v.bandwidth
|
return v.bandwidthLimiter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) Stale() bool {
|
func (v *visitor) Stale() bool {
|
||||||
@@ -116,19 +186,116 @@ func (v *visitor) Stale() bool {
|
|||||||
return time.Since(v.seen) > visitorExpungeAfter
|
return time.Since(v.seen) > visitorExpungeAfter
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visitor) Stats() (*visitorStats, error) {
|
func (v *visitor) IncrementMessages() {
|
||||||
attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip.String())
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.messages++
|
||||||
|
if v.user != nil {
|
||||||
|
v.user.Stats.Messages = v.messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visitor) IncrementEmails() {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.emails++
|
||||||
|
if v.user != nil {
|
||||||
|
v.user.Stats.Emails = v.emails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visitor) ResetStats() {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
v.messages = 0
|
||||||
|
v.emails = 0
|
||||||
|
if v.user != nil {
|
||||||
|
v.user.Stats.Messages = 0
|
||||||
|
v.user.Stats.Emails = 0
|
||||||
|
// v.messagesLimiter = ... // FIXME
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visitor) Limits() *visitorLimits {
|
||||||
|
v.mu.Lock()
|
||||||
|
defer v.mu.Unlock()
|
||||||
|
limits := defaultVisitorLimits(v.config)
|
||||||
|
if v.user != nil && v.user.Tier != nil {
|
||||||
|
limits.Basis = visitorLimitBasisTier
|
||||||
|
limits.MessagesLimit = v.user.Tier.MessagesLimit
|
||||||
|
limits.MessagesExpiryDuration = v.user.Tier.MessagesExpiryDuration
|
||||||
|
limits.EmailsLimit = v.user.Tier.EmailsLimit
|
||||||
|
limits.ReservationsLimit = v.user.Tier.ReservationsLimit
|
||||||
|
limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
|
||||||
|
limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
|
||||||
|
limits.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration
|
||||||
|
}
|
||||||
|
return limits
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visitor) Info() (*visitorInfo, error) {
|
||||||
|
v.mu.Lock()
|
||||||
|
messages := v.messages
|
||||||
|
emails := v.emails
|
||||||
|
v.mu.Unlock()
|
||||||
|
var attachmentsBytesUsed int64
|
||||||
|
var err error
|
||||||
|
if v.user != nil {
|
||||||
|
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name)
|
||||||
|
} else {
|
||||||
|
attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedBySender(v.ip.String())
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed
|
var reservations int64
|
||||||
if attachmentsBytesRemaining < 0 {
|
if v.user != nil && v.userManager != nil {
|
||||||
attachmentsBytesRemaining = 0
|
reservations, err = v.userManager.ReservationsCount(v.user.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return &visitorStats{
|
limits := v.Limits()
|
||||||
AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit,
|
stats := &visitorStats{
|
||||||
VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit,
|
Messages: messages,
|
||||||
VisitorAttachmentBytesUsed: attachmentsBytesUsed,
|
MessagesRemaining: zeroIfNegative(limits.MessagesLimit - messages),
|
||||||
VisitorAttachmentBytesRemaining: attachmentsBytesRemaining,
|
Emails: emails,
|
||||||
|
EmailsRemaining: zeroIfNegative(limits.EmailsLimit - emails),
|
||||||
|
Reservations: reservations,
|
||||||
|
ReservationsRemaining: zeroIfNegative(limits.ReservationsLimit - reservations),
|
||||||
|
AttachmentTotalSize: attachmentsBytesUsed,
|
||||||
|
AttachmentTotalSizeRemaining: zeroIfNegative(limits.AttachmentTotalSizeLimit - attachmentsBytesUsed),
|
||||||
|
}
|
||||||
|
return &visitorInfo{
|
||||||
|
Limits: limits,
|
||||||
|
Stats: stats,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func zeroIfNegative(value int64) int64 {
|
||||||
|
if value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func replenishDurationToDailyLimit(duration time.Duration) int64 {
|
||||||
|
return int64(24 * time.Hour / duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyLimitToRate(limit int64) rate.Limit {
|
||||||
|
return rate.Limit(limit) * rate.Every(24*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultVisitorLimits(conf *Config) *visitorLimits {
|
||||||
|
return &visitorLimits{
|
||||||
|
Basis: visitorLimitBasisIP,
|
||||||
|
MessagesLimit: replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish),
|
||||||
|
MessagesExpiryDuration: conf.CacheDuration,
|
||||||
|
EmailsLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish),
|
||||||
|
ReservationsLimit: visitorDefaultReservationsLimit,
|
||||||
|
AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit,
|
||||||
|
AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit,
|
||||||
|
AttachmentExpiryDuration: conf.AttachmentExpiryDuration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1123
user/manager.go
Normal file
1123
user/manager.go
Normal file
File diff suppressed because it is too large
Load Diff
670
user/manager_test.go
Normal file
670
user/manager_test.go
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"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 TestManager_FullScenario_Default_DenyAll(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test"))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair!
|
||||||
|
require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead))
|
||||||
|
require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite))
|
||||||
|
require.Nil(t, a.AllowAccess(Everyone, "up*", PermissionWrite)) // 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, RoleAdmin, phil.Role)
|
||||||
|
|
||||||
|
philGrants, err := a.Grants("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []Grant{}, philGrants)
|
||||||
|
|
||||||
|
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, RoleUser, ben.Role)
|
||||||
|
|
||||||
|
benGrants, err := a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []Grant{
|
||||||
|
{"mytopic", PermissionReadWrite},
|
||||||
|
{"writeme", PermissionWrite},
|
||||||
|
{"readme", PermissionRead},
|
||||||
|
{"everyonewrite", PermissionDenyAll},
|
||||||
|
}, benGrants)
|
||||||
|
|
||||||
|
notben, err := a.Authenticate("ben", "this is wrong")
|
||||||
|
require.Nil(t, notben)
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
|
||||||
|
// Admin can do everything
|
||||||
|
require.Nil(t, a.Authorize(phil, "sometopic", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(phil, "mytopic", PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(phil, "readme", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(phil, "writeme", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(phil, "announcements", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(phil, "everyonewrite", PermissionWrite))
|
||||||
|
|
||||||
|
// User cannot do everything
|
||||||
|
require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(ben, "readme", PermissionRead))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "readme", PermissionWrite))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "writeme", PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "everyonewrite", PermissionRead))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "everyonewrite", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "announcements", PermissionRead))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "announcements", PermissionWrite))
|
||||||
|
|
||||||
|
// Everyone else can do barely anything
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionRead))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", PermissionWrite))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopic", PermissionRead))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "mytopic", PermissionWrite))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "readme", PermissionRead))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "readme", PermissionWrite))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "writeme", PermissionRead))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "writeme", PermissionWrite))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(nil, "announcements", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(nil, "announcements", PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(nil, "everyonewrite", PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(nil, "everyonewrite", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(nil, "up1234", PermissionWrite)) // Wildcard permission
|
||||||
|
require.Nil(t, a.Authorize(nil, "up5678", PermissionWrite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_AddUser_Invalid(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Equal(t, ErrInvalidArgument, a.AddUser(" invalid ", "pass", RoleAdmin, "unit-test"))
|
||||||
|
require.Equal(t, ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role", "unit-test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_AddUser_Timing(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
start := time.Now().UnixMilli()
|
||||||
|
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, "unit-test"))
|
||||||
|
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Authenticate_Timing(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, "unit-test"))
|
||||||
|
|
||||||
|
// 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, 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, ErrUnauthenticated, err)
|
||||||
|
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, minBcryptTimingMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_UserManagement(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test"))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "everyonewrite", PermissionDenyAll)) // How unfair!
|
||||||
|
require.Nil(t, a.AllowAccess(Everyone, "announcements", PermissionRead))
|
||||||
|
require.Nil(t, a.AllowAccess(Everyone, "everyonewrite", PermissionReadWrite))
|
||||||
|
|
||||||
|
// 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, RoleAdmin, phil.Role)
|
||||||
|
|
||||||
|
philGrants, err := a.Grants("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []Grant{}, philGrants)
|
||||||
|
|
||||||
|
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, RoleUser, ben.Role)
|
||||||
|
|
||||||
|
benGrants, err := a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []Grant{
|
||||||
|
{"mytopic", PermissionReadWrite},
|
||||||
|
{"writeme", PermissionWrite},
|
||||||
|
{"readme", PermissionRead},
|
||||||
|
{"everyonewrite", PermissionDenyAll},
|
||||||
|
}, benGrants)
|
||||||
|
|
||||||
|
everyone, err := a.User(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "*", everyone.Name)
|
||||||
|
require.Equal(t, "", everyone.Hash)
|
||||||
|
require.Equal(t, RoleAnonymous, everyone.Role)
|
||||||
|
|
||||||
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, []Grant{
|
||||||
|
{"everyonewrite", PermissionReadWrite},
|
||||||
|
{"announcements", PermissionRead},
|
||||||
|
}, everyoneGrants)
|
||||||
|
|
||||||
|
// Ben: Before revoking
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite)) // Overwrite!
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "writeme", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "mytopic", PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(ben, "mytopic", PermissionWrite))
|
||||||
|
require.Nil(t, a.Authorize(ben, "readme", PermissionRead))
|
||||||
|
require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite))
|
||||||
|
|
||||||
|
// Revoke access for "ben" to "mytopic", then check again
|
||||||
|
require.Nil(t, a.ResetAccess("ben", "mytopic"))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "mytopic", PermissionWrite)) // Revoked
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "mytopic", PermissionRead)) // Revoked
|
||||||
|
require.Nil(t, a.Authorize(ben, "readme", PermissionRead)) // Unchanged
|
||||||
|
require.Nil(t, a.Authorize(ben, "writeme", PermissionWrite)) // Unchanged
|
||||||
|
|
||||||
|
// Revoke rest of the access
|
||||||
|
require.Nil(t, a.ResetAccess("ben", ""))
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "readme", PermissionRead)) // Revoked
|
||||||
|
require.Equal(t, ErrUnauthorized, a.Authorize(ben, "wrtiteme", 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, ErrUserNotFound, 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 TestManager_ChangePassword(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("phil", "phil", RoleAdmin, "unit-test"))
|
||||||
|
|
||||||
|
_, err := a.Authenticate("phil", "phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
require.Nil(t, a.ChangePassword("phil", "newpass"))
|
||||||
|
_, err = a.Authenticate("phil", "phil")
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
_, err = a.Authenticate("phil", "newpass")
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_ChangeRole(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "mytopic", PermissionReadWrite))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "readme", PermissionRead))
|
||||||
|
|
||||||
|
ben, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, RoleUser, ben.Role)
|
||||||
|
|
||||||
|
benGrants, err := a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(benGrants))
|
||||||
|
|
||||||
|
require.Nil(t, a.ChangeRole("ben", RoleAdmin))
|
||||||
|
|
||||||
|
ben, err = a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, RoleAdmin, ben.Role)
|
||||||
|
|
||||||
|
benGrants, err = a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(benGrants))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Reservations(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, a.ReserveAccess("ben", "ztopic", PermissionDenyAll))
|
||||||
|
require.Nil(t, a.ReserveAccess("ben", "readme", PermissionRead))
|
||||||
|
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
|
||||||
|
|
||||||
|
reservations, err := a.Reservations("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 2, len(reservations))
|
||||||
|
require.Equal(t, Reservation{
|
||||||
|
Topic: "readme",
|
||||||
|
Owner: PermissionReadWrite,
|
||||||
|
Everyone: PermissionRead,
|
||||||
|
}, reservations[0])
|
||||||
|
require.Equal(t, Reservation{
|
||||||
|
Topic: "ztopic",
|
||||||
|
Owner: PermissionReadWrite,
|
||||||
|
Everyone: PermissionDenyAll,
|
||||||
|
}, reservations[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.CreateTier(&Tier{
|
||||||
|
Code: "pro",
|
||||||
|
Name: "ntfy Pro",
|
||||||
|
StripePriceID: "price123",
|
||||||
|
MessagesLimit: 5_000,
|
||||||
|
MessagesExpiryDuration: 3 * 24 * time.Hour,
|
||||||
|
EmailsLimit: 50,
|
||||||
|
ReservationsLimit: 5,
|
||||||
|
AttachmentFileSizeLimit: 52428800,
|
||||||
|
AttachmentTotalSizeLimit: 524288000,
|
||||||
|
AttachmentExpiryDuration: 24 * time.Hour,
|
||||||
|
}))
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
require.Nil(t, a.ChangeTier("ben", "pro"))
|
||||||
|
require.Nil(t, a.ReserveAccess("ben", "mytopic", PermissionDenyAll))
|
||||||
|
|
||||||
|
ben, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, RoleUser, ben.Role)
|
||||||
|
require.Equal(t, "pro", ben.Tier.Code)
|
||||||
|
require.Equal(t, true, ben.Tier.Paid)
|
||||||
|
require.Equal(t, int64(5000), ben.Tier.MessagesLimit)
|
||||||
|
require.Equal(t, 3*24*time.Hour, ben.Tier.MessagesExpiryDuration)
|
||||||
|
require.Equal(t, int64(50), ben.Tier.EmailsLimit)
|
||||||
|
require.Equal(t, int64(5), ben.Tier.ReservationsLimit)
|
||||||
|
require.Equal(t, int64(52428800), ben.Tier.AttachmentFileSizeLimit)
|
||||||
|
require.Equal(t, int64(524288000), ben.Tier.AttachmentTotalSizeLimit)
|
||||||
|
require.Equal(t, 24*time.Hour, ben.Tier.AttachmentExpiryDuration)
|
||||||
|
|
||||||
|
benGrants, err := a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(benGrants))
|
||||||
|
require.Equal(t, PermissionReadWrite, benGrants[0].Allow)
|
||||||
|
|
||||||
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(everyoneGrants))
|
||||||
|
require.Equal(t, PermissionDenyAll, everyoneGrants[0].Allow)
|
||||||
|
|
||||||
|
benReservations, err := a.Reservations("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 1, len(benReservations))
|
||||||
|
require.Equal(t, "mytopic", benReservations[0].Topic)
|
||||||
|
require.Equal(t, PermissionReadWrite, benReservations[0].Owner)
|
||||||
|
require.Equal(t, PermissionDenyAll, benReservations[0].Everyone)
|
||||||
|
|
||||||
|
// Switch to admin, this should remove all grants and owned ACL entries
|
||||||
|
require.Nil(t, a.ChangeRole("ben", RoleAdmin))
|
||||||
|
|
||||||
|
benGrants, err = a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(benGrants))
|
||||||
|
|
||||||
|
everyoneGrants, err = a.Grants(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 0, len(everyoneGrants))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Token_Valid(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
u, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create token for user
|
||||||
|
token, err := a.CreateToken(u)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.NotEmpty(t, token.Value)
|
||||||
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token.Expires.Unix())
|
||||||
|
|
||||||
|
u2, err := a.AuthenticateToken(token.Value)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, u.Name, u2.Name)
|
||||||
|
require.Equal(t, token.Value, u2.Token)
|
||||||
|
|
||||||
|
// Remove token and auth again
|
||||||
|
require.Nil(t, a.RemoveToken(u2))
|
||||||
|
u3, err := a.AuthenticateToken(token.Value)
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
require.Nil(t, u3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Token_Invalid(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
u, err := a.AuthenticateToken(strings.Repeat("x", 32)) // 32 == token length
|
||||||
|
require.Nil(t, u)
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
|
||||||
|
u, err = a.AuthenticateToken("not long enough anyway")
|
||||||
|
require.Nil(t, u)
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Token_Expire(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
u, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create tokens for user
|
||||||
|
token1, err := a.CreateToken(u)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.NotEmpty(t, token1.Value)
|
||||||
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token1.Expires.Unix())
|
||||||
|
|
||||||
|
token2, err := a.CreateToken(u)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.NotEmpty(t, token2.Value)
|
||||||
|
require.NotEqual(t, token1.Value, token2.Value)
|
||||||
|
require.True(t, time.Now().Add(71*time.Hour).Unix() < token2.Expires.Unix())
|
||||||
|
|
||||||
|
// See that tokens work
|
||||||
|
_, err = a.AuthenticateToken(token1.Value)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = a.AuthenticateToken(token2.Value)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Modify token expiration in database
|
||||||
|
_, err = a.db.Exec("UPDATE user_token SET expires = 1 WHERE token = ?", token1.Value)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Now token1 shouldn't work anymore
|
||||||
|
_, err = a.AuthenticateToken(token1.Value)
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
|
||||||
|
result, err := a.db.Query("SELECT * from user_token WHERE token = ?", token1.Value)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, result.Next()) // Still a matching row
|
||||||
|
require.Nil(t, result.Close())
|
||||||
|
|
||||||
|
// Expire tokens and check database rows
|
||||||
|
require.Nil(t, a.RemoveExpiredTokens())
|
||||||
|
|
||||||
|
result, err = a.db.Query("SELECT * from user_token WHERE token = ?", token1.Value)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.False(t, result.Next()) // No matching row!
|
||||||
|
require.Nil(t, result.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Token_Extend(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
// Try to extend token for user without token
|
||||||
|
u, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
_, err = a.ExtendToken(u)
|
||||||
|
require.Equal(t, errNoTokenProvided, err)
|
||||||
|
|
||||||
|
// Create token for user
|
||||||
|
token, err := a.CreateToken(u)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.NotEmpty(t, token.Value)
|
||||||
|
|
||||||
|
userWithToken, err := a.AuthenticateToken(token.Value)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
|
||||||
|
extendedToken, err := a.ExtendToken(userWithToken)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, token.Value, extendedToken.Value)
|
||||||
|
require.True(t, token.Expires.Unix() < extendedToken.Expires.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_Token_MaxCount_AutoDelete(t *testing.T) {
|
||||||
|
a := newTestManager(t, PermissionDenyAll)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
// Try to extend token for user without token
|
||||||
|
u, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Tokens
|
||||||
|
baseTime := time.Now().Add(24 * time.Hour)
|
||||||
|
tokens := make([]string, 0)
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
token, err := a.CreateToken(u)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.NotEmpty(t, token.Value)
|
||||||
|
tokens = append(tokens, token.Value)
|
||||||
|
|
||||||
|
// Manually modify expiry date to avoid sorting issues (this is a hack)
|
||||||
|
_, err = a.db.Exec(`UPDATE user_token SET expires=? WHERE token=?`, baseTime.Add(time.Duration(i)*time.Minute).Unix(), token.Value)
|
||||||
|
require.Nil(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = a.AuthenticateToken(tokens[0])
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
|
||||||
|
_, err = a.AuthenticateToken(tokens[1])
|
||||||
|
require.Equal(t, ErrUnauthenticated, err)
|
||||||
|
|
||||||
|
for i := 2; i < 12; i++ {
|
||||||
|
userWithToken, err := a.AuthenticateToken(tokens[i])
|
||||||
|
require.Nil(t, err, "token[%d]=%s failed", i, tokens[i])
|
||||||
|
require.Equal(t, "ben", userWithToken.Name)
|
||||||
|
require.Equal(t, tokens[i], userWithToken.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int
|
||||||
|
rows, err := a.db.Query(`SELECT COUNT(*) FROM user_token`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, rows.Next())
|
||||||
|
require.Nil(t, rows.Scan(&count))
|
||||||
|
require.Equal(t, 10, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_EnqueueStats(t *testing.T) {
|
||||||
|
a, err := newManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, 1500*time.Millisecond)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
// Baseline: No messages or emails
|
||||||
|
u, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), u.Stats.Messages)
|
||||||
|
require.Equal(t, int64(0), u.Stats.Emails)
|
||||||
|
|
||||||
|
u.Stats.Messages = 11
|
||||||
|
u.Stats.Emails = 2
|
||||||
|
a.EnqueueStats(u)
|
||||||
|
|
||||||
|
// Still no change, because it's queued asynchronously
|
||||||
|
u, err = a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(0), u.Stats.Messages)
|
||||||
|
require.Equal(t, int64(0), u.Stats.Emails)
|
||||||
|
|
||||||
|
// After 2 seconds they should be persisted
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
u, err = a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, int64(11), u.Stats.Messages)
|
||||||
|
require.Equal(t, int64(2), u.Stats.Emails)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_ChangeSettings(t *testing.T) {
|
||||||
|
a, err := newManager(filepath.Join(t.TempDir(), "db"), "", PermissionReadWrite, 1500*time.Millisecond)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, a.AddUser("ben", "ben", RoleUser, "unit-test"))
|
||||||
|
|
||||||
|
// No settings
|
||||||
|
u, err := a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Nil(t, u.Prefs.Subscriptions)
|
||||||
|
require.Nil(t, u.Prefs.Notification)
|
||||||
|
require.Equal(t, "", u.Prefs.Language)
|
||||||
|
|
||||||
|
// Save with new settings
|
||||||
|
u.Prefs = &Prefs{
|
||||||
|
Language: "de",
|
||||||
|
Notification: &NotificationPrefs{
|
||||||
|
Sound: "ding",
|
||||||
|
MinPriority: 2,
|
||||||
|
},
|
||||||
|
Subscriptions: []*Subscription{
|
||||||
|
{
|
||||||
|
ID: "someID",
|
||||||
|
BaseURL: "https://ntfy.sh",
|
||||||
|
Topic: "mytopic",
|
||||||
|
DisplayName: "My Topic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.Nil(t, a.ChangeSettings(u))
|
||||||
|
|
||||||
|
// Read again
|
||||||
|
u, err = a.User("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "de", u.Prefs.Language)
|
||||||
|
require.Equal(t, "ding", u.Prefs.Notification.Sound)
|
||||||
|
require.Equal(t, 2, u.Prefs.Notification.MinPriority)
|
||||||
|
require.Equal(t, 0, u.Prefs.Notification.DeleteAfter)
|
||||||
|
require.Equal(t, "someID", u.Prefs.Subscriptions[0].ID)
|
||||||
|
require.Equal(t, "https://ntfy.sh", u.Prefs.Subscriptions[0].BaseURL)
|
||||||
|
require.Equal(t, "mytopic", u.Prefs.Subscriptions[0].Topic)
|
||||||
|
require.Equal(t, "My Topic", u.Prefs.Subscriptions[0].DisplayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||||
|
filename := filepath.Join(t.TempDir(), "user.db")
|
||||||
|
db, err := sql.Open("sqlite3", filename)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create "version 1" schema
|
||||||
|
_, err = db.Exec(`
|
||||||
|
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
|
||||||
|
);
|
||||||
|
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Insert a bunch of users and ACL entries
|
||||||
|
_, err = db.Exec(`
|
||||||
|
BEGIN;
|
||||||
|
INSERT INTO user (user, pass, role) VALUES ('ben', '$2a$10$EEp6gBheOsqEFsXlo523E.gBVoeg1ytphXiEvTPlNzkenBlHZBPQy', 'user');
|
||||||
|
INSERT INTO user (user, pass, role) VALUES ('phil', '$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C', 'admin');
|
||||||
|
INSERT INTO access (user, topic, read, write) VALUES ('ben', 'stats', 1, 1);
|
||||||
|
INSERT INTO access (user, topic, read, write) VALUES ('ben', 'secret', 1, 0);
|
||||||
|
INSERT INTO access (user, topic, read, write) VALUES ('*', 'stats', 1, 0);
|
||||||
|
COMMIT;
|
||||||
|
`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
// Create manager to trigger migration
|
||||||
|
a := newTestManagerFromFile(t, filename, "", PermissionDenyAll, userStatsQueueWriterInterval)
|
||||||
|
checkSchemaVersion(t, a.db)
|
||||||
|
|
||||||
|
users, err := a.Users()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, 3, len(users))
|
||||||
|
phil, ben, everyone := users[0], users[1], users[2]
|
||||||
|
|
||||||
|
philGrants, err := a.Grants("phil")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
benGrants, err := a.Grants("ben")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
everyoneGrants, err := a.Grants(Everyone)
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, "phil", phil.Name)
|
||||||
|
require.Equal(t, RoleAdmin, phil.Role)
|
||||||
|
require.Equal(t, syncTopicLength, len(phil.SyncTopic))
|
||||||
|
require.Equal(t, 0, len(philGrants))
|
||||||
|
|
||||||
|
require.Equal(t, "ben", ben.Name)
|
||||||
|
require.Equal(t, RoleUser, ben.Role)
|
||||||
|
require.Equal(t, syncTopicLength, len(ben.SyncTopic))
|
||||||
|
require.NotEqual(t, ben.SyncTopic, phil.SyncTopic)
|
||||||
|
require.Equal(t, 2, len(benGrants))
|
||||||
|
require.Equal(t, "stats", benGrants[0].TopicPattern)
|
||||||
|
require.Equal(t, PermissionReadWrite, benGrants[0].Allow)
|
||||||
|
require.Equal(t, "secret", benGrants[1].TopicPattern)
|
||||||
|
require.Equal(t, PermissionRead, benGrants[1].Allow)
|
||||||
|
|
||||||
|
require.Equal(t, Everyone, everyone.Name)
|
||||||
|
require.Equal(t, RoleAnonymous, everyone.Role)
|
||||||
|
require.Equal(t, 1, len(everyoneGrants))
|
||||||
|
require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
|
||||||
|
require.Equal(t, PermissionRead, everyoneGrants[0].Allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||||
|
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.True(t, rows.Next())
|
||||||
|
|
||||||
|
var schemaVersion int
|
||||||
|
require.Nil(t, rows.Scan(&schemaVersion))
|
||||||
|
require.Equal(t, currentSchemaVersion, schemaVersion)
|
||||||
|
require.Nil(t, rows.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestManager(t *testing.T, defaultAccess Permission) *Manager {
|
||||||
|
return newTestManagerFromFile(t, filepath.Join(t.TempDir(), "user.db"), "", defaultAccess, userStatsQueueWriterInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestManagerFromFile(t *testing.T, filename, startupQueries string, defaultAccess Permission, statsWriterInterval time.Duration) *Manager {
|
||||||
|
a, err := newManager(filename, startupQueries, defaultAccess, statsWriterInterval)
|
||||||
|
require.Nil(t, err)
|
||||||
|
return a
|
||||||
|
}
|
||||||
230
user/types.go
Normal file
230
user/types.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// Package user deals with authentication and authorization against topics
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/stripe/stripe-go/v74"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User is a struct that represents a user
|
||||||
|
type User struct {
|
||||||
|
Name string
|
||||||
|
Hash string // password hash (bcrypt)
|
||||||
|
Token string // Only set if token was used to log in
|
||||||
|
Role Role
|
||||||
|
Prefs *Prefs
|
||||||
|
Tier *Tier
|
||||||
|
Stats *Stats
|
||||||
|
Billing *Billing
|
||||||
|
SyncTopic string
|
||||||
|
Created time.Time
|
||||||
|
LastSeen time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auther is an interface for 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token represents a user token, including expiry date
|
||||||
|
type Token struct {
|
||||||
|
Value string
|
||||||
|
Expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefs represents a user's configuration settings
|
||||||
|
type Prefs struct {
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Notification *NotificationPrefs `json:"notification,omitempty"`
|
||||||
|
Subscriptions []*Subscription `json:"subscriptions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier represents a user's account type, including its account limits
|
||||||
|
type Tier struct {
|
||||||
|
Code string
|
||||||
|
Name string
|
||||||
|
Paid bool
|
||||||
|
MessagesLimit int64
|
||||||
|
MessagesExpiryDuration time.Duration
|
||||||
|
EmailsLimit int64
|
||||||
|
ReservationsLimit int64
|
||||||
|
AttachmentFileSizeLimit int64
|
||||||
|
AttachmentTotalSizeLimit int64
|
||||||
|
AttachmentExpiryDuration time.Duration
|
||||||
|
StripePriceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription represents a user's topic subscription
|
||||||
|
type Subscription struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationPrefs represents the user's notification settings
|
||||||
|
type NotificationPrefs struct {
|
||||||
|
Sound string `json:"sound,omitempty"`
|
||||||
|
MinPriority int `json:"min_priority,omitempty"`
|
||||||
|
DeleteAfter int `json:"delete_after,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats is a struct holding daily user statistics
|
||||||
|
type Stats struct {
|
||||||
|
Messages int64
|
||||||
|
Emails int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Billing is a struct holding a user's billing information
|
||||||
|
type Billing struct {
|
||||||
|
StripeCustomerID string
|
||||||
|
StripeSubscriptionID string
|
||||||
|
StripeSubscriptionStatus stripe.SubscriptionStatus
|
||||||
|
StripeSubscriptionPaidUntil time.Time
|
||||||
|
StripeSubscriptionCancelAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant is a struct that represents an access control entry to a topic by a user
|
||||||
|
type Grant struct {
|
||||||
|
TopicPattern string // May include wildcard (*)
|
||||||
|
Allow Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reservation is a struct that represents the ownership over a topic by a user
|
||||||
|
type Reservation struct {
|
||||||
|
Topic string
|
||||||
|
Owner Permission
|
||||||
|
Everyone Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission represents a read or write permission to a topic
|
||||||
|
type Permission uint8
|
||||||
|
|
||||||
|
// Permissions to a topic
|
||||||
|
const (
|
||||||
|
PermissionDenyAll Permission = iota
|
||||||
|
PermissionRead
|
||||||
|
PermissionWrite
|
||||||
|
PermissionReadWrite // 3!
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPermission is a helper to create a Permission based on read/write bool values
|
||||||
|
func NewPermission(read, write bool) Permission {
|
||||||
|
p := uint8(0)
|
||||||
|
if read {
|
||||||
|
p |= uint8(PermissionRead)
|
||||||
|
}
|
||||||
|
if write {
|
||||||
|
p |= uint8(PermissionWrite)
|
||||||
|
}
|
||||||
|
return Permission(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePermission parses the string representation and returns a Permission
|
||||||
|
func ParsePermission(s string) (Permission, error) {
|
||||||
|
switch s {
|
||||||
|
case "read-write", "rw":
|
||||||
|
return NewPermission(true, true), nil
|
||||||
|
case "read-only", "read", "ro":
|
||||||
|
return NewPermission(true, false), nil
|
||||||
|
case "write-only", "write", "wo":
|
||||||
|
return NewPermission(false, true), nil
|
||||||
|
case "deny-all", "deny", "none":
|
||||||
|
return NewPermission(false, false), nil
|
||||||
|
default:
|
||||||
|
return NewPermission(false, false), errors.New("invalid permission")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRead returns true if readable
|
||||||
|
func (p Permission) IsRead() bool {
|
||||||
|
return p&PermissionRead != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsWrite returns true if writable
|
||||||
|
func (p Permission) IsWrite() bool {
|
||||||
|
return p&PermissionWrite != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsReadWrite returns true if readable and writable
|
||||||
|
func (p Permission) IsReadWrite() bool {
|
||||||
|
return p.IsRead() && p.IsWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns a string representation of the permission
|
||||||
|
func (p Permission) String() string {
|
||||||
|
if p.IsReadWrite() {
|
||||||
|
return "read-write"
|
||||||
|
} else if p.IsRead() {
|
||||||
|
return "read-only"
|
||||||
|
} else if p.IsWrite() {
|
||||||
|
return "write-only"
|
||||||
|
}
|
||||||
|
return "deny-all"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role represents a user's role, either admin or regular user
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
// User roles
|
||||||
|
const (
|
||||||
|
RoleAdmin = Role("admin") // Some queries have these values hardcoded!
|
||||||
|
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 (*)
|
||||||
|
allowedTopicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No '*'
|
||||||
|
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
|
||||||
|
allowedTierRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedTopic returns true if the given topic name is valid
|
||||||
|
func AllowedTopic(topic string) bool {
|
||||||
|
return allowedTopicRegex.MatchString(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
|
||||||
|
func AllowedTopicPattern(topic string) bool {
|
||||||
|
return allowedTopicPatternRegex.MatchString(topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowedTier returns true if the given tier name is valid
|
||||||
|
func AllowedTier(tier string) bool {
|
||||||
|
return allowedTierRegex.MatchString(tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error constants used by the package
|
||||||
|
var (
|
||||||
|
ErrUnauthenticated = errors.New("unauthenticated")
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
ErrInvalidArgument = errors.New("invalid argument")
|
||||||
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrTierNotFound = errors.New("tier not found")
|
||||||
|
ErrTooManyReservations = errors.New("new tier has lower reservation limit")
|
||||||
|
)
|
||||||
52
util/lookup_cache.go
Normal file
52
util/lookup_cache.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LookupCache is a single-value cache with a time-to-live (TTL). The cache has a lookup function
|
||||||
|
// to retrieve the value and stores it until TTL is reached.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// lookup := func() (string, error) {
|
||||||
|
// r, _ := http.Get("...")
|
||||||
|
// s, _ := io.ReadAll(r.Body)
|
||||||
|
// return string(s), nil
|
||||||
|
// }
|
||||||
|
// c := NewLookupCache[string](lookup, time.Hour)
|
||||||
|
// fmt.Println(c.Get()) // Fetches the string via HTTP
|
||||||
|
// fmt.Println(c.Get()) // Uses cached value
|
||||||
|
type LookupCache[T any] struct {
|
||||||
|
value *T
|
||||||
|
lookup func() (T, error)
|
||||||
|
ttl time.Duration
|
||||||
|
updated time.Time
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLookupCache creates a new LookupCache with a given time-to-live (TTL)
|
||||||
|
func NewLookupCache[T any](lookup func() (T, error), ttl time.Duration) *LookupCache[T] {
|
||||||
|
return &LookupCache[T]{
|
||||||
|
value: nil,
|
||||||
|
lookup: lookup,
|
||||||
|
ttl: ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value returns the cached value, or retrieves it via the lookup function
|
||||||
|
func (c *LookupCache[T]) Value() (T, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.value == nil || (c.ttl > 0 && time.Since(c.updated) > c.ttl) {
|
||||||
|
value, err := c.lookup()
|
||||||
|
if err != nil {
|
||||||
|
var t T
|
||||||
|
return t, err
|
||||||
|
}
|
||||||
|
c.value = &value
|
||||||
|
c.updated = time.Now()
|
||||||
|
}
|
||||||
|
return *c.value, nil
|
||||||
|
}
|
||||||
63
util/lookup_cache_test.go
Normal file
63
util/lookup_cache_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLookupCache_Success(t *testing.T) {
|
||||||
|
values, i := []string{"first", "second"}, 0
|
||||||
|
c := NewLookupCache[string](func() (string, error) {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
v := values[i]
|
||||||
|
i++
|
||||||
|
return v, nil
|
||||||
|
}, 500*time.Millisecond)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
v, err := c.Value()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, values[0], v)
|
||||||
|
require.True(t, time.Since(start) >= 300*time.Millisecond)
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
v, err = c.Value()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, values[0], v)
|
||||||
|
require.True(t, time.Since(start) < 200*time.Millisecond)
|
||||||
|
|
||||||
|
time.Sleep(550 * time.Millisecond)
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
v, err = c.Value()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, values[1], v)
|
||||||
|
require.True(t, time.Since(start) >= 300*time.Millisecond)
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
v, err = c.Value()
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, values[1], v)
|
||||||
|
require.True(t, time.Since(start) < 200*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLookupCache_Error(t *testing.T) {
|
||||||
|
c := NewLookupCache[string](func() (string, error) {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
return "", errors.New("some error")
|
||||||
|
}, 500*time.Millisecond)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
v, err := c.Value()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "", v)
|
||||||
|
require.True(t, time.Since(start) >= 200*time.Millisecond)
|
||||||
|
|
||||||
|
start = time.Now()
|
||||||
|
v, err = c.Value()
|
||||||
|
require.NotNil(t, err)
|
||||||
|
require.Equal(t, "", v)
|
||||||
|
require.True(t, time.Since(start) >= 200*time.Millisecond)
|
||||||
|
}
|
||||||
12
util/time.go
12
util/time.go
@@ -14,6 +14,18 @@ var (
|
|||||||
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NextOccurrenceUTC takes a time of day (e.g. 9:00am), and returns the next occurrence
|
||||||
|
// of that time from the current time (in UTC).
|
||||||
|
func NextOccurrenceUTC(timeOfDay, base time.Time) time.Time {
|
||||||
|
hour, minute, seconds := timeOfDay.Clock()
|
||||||
|
now := base.UTC()
|
||||||
|
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, seconds, 0, time.UTC)
|
||||||
|
if next.Before(now) {
|
||||||
|
next = next.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
// ParseFutureTime parses a date/time string to a time.Time. It supports unix timestamps, durations
|
// ParseFutureTime parses a date/time string to a time.Time. It supports unix timestamps, durations
|
||||||
// and natural language dates
|
// and natural language dates
|
||||||
func ParseFutureTime(s string, now time.Time) (time.Time, error) {
|
func ParseFutureTime(s string, now time.Time) (time.Time, error) {
|
||||||
|
|||||||
@@ -11,6 +11,26 @@ var (
|
|||||||
base = time.Date(2021, 12, 10, 10, 17, 23, 0, time.UTC)
|
base = time.Date(2021, 12, 10, 10, 17, 23, 0, time.UTC)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestNextOccurrenceUTC_NextDate(t *testing.T) {
|
||||||
|
loc, err := time.LoadLocation("America/New_York")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
timeOfDay := time.Date(0, 0, 0, 0, 0, 0, 0, time.UTC) // Run at midnight UTC
|
||||||
|
nowInFairfieldCT := time.Date(2023, time.January, 10, 22, 19, 12, 0, loc)
|
||||||
|
nextRunTme := NextOccurrenceUTC(timeOfDay, nowInFairfieldCT)
|
||||||
|
require.Equal(t, time.Date(2023, time.January, 12, 0, 0, 0, 0, time.UTC), nextRunTme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextOccurrenceUTC_SameDay(t *testing.T) {
|
||||||
|
loc, err := time.LoadLocation("America/New_York")
|
||||||
|
require.Nil(t, err)
|
||||||
|
|
||||||
|
timeOfDay := time.Date(0, 0, 0, 4, 0, 0, 0, time.UTC) // Run at 4am UTC
|
||||||
|
nowInFairfieldCT := time.Date(2023, time.January, 10, 22, 19, 12, 0, loc)
|
||||||
|
nextRunTme := NextOccurrenceUTC(timeOfDay, nowInFairfieldCT)
|
||||||
|
require.Equal(t, time.Date(2023, time.January, 11, 4, 0, 0, 0, time.UTC), nextRunTme)
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseFutureTime_11am_FutureTime(t *testing.T) {
|
func TestParseFutureTime_11am_FutureTime(t *testing.T) {
|
||||||
d, err := ParseFutureTime("11am", base)
|
d, err := ParseFutureTime("11am", base)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|||||||
36
util/util.go
36
util/util.go
@@ -31,6 +31,12 @@ var (
|
|||||||
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Errors for UnmarshalJSON and UnmarshalJSONWithLimit functions
|
||||||
|
var (
|
||||||
|
ErrUnmarshalJSON = errors.New("unmarshalling JSON failed")
|
||||||
|
ErrTooLargeJSON = errors.New("too large JSON")
|
||||||
|
)
|
||||||
|
|
||||||
// FileExists checks if a file exists, and returns true if it does
|
// FileExists checks if a file exists, and returns true if it does
|
||||||
func FileExists(filename string) bool {
|
func FileExists(filename string) bool {
|
||||||
stat, _ := os.Stat(filename)
|
stat, _ := os.Stat(filename)
|
||||||
@@ -251,6 +257,11 @@ func BasicAuth(user, pass string) string {
|
|||||||
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
|
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BearerAuth encodes the Authorization header value for a bearer/token auth
|
||||||
|
func BearerAuth(token string) string {
|
||||||
|
return fmt.Sprintf("Bearer %s", token)
|
||||||
|
}
|
||||||
|
|
||||||
// MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed.
|
// MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed.
|
||||||
// This is useful for logging purposes where a failure doesn't matter that much.
|
// This is useful for logging purposes where a failure doesn't matter that much.
|
||||||
func MaybeMarshalJSON(v any) string {
|
func MaybeMarshalJSON(v any) string {
|
||||||
@@ -283,3 +294,28 @@ func QuoteCommand(command []string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(quoted, " ")
|
return strings.Join(quoted, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON reads the given io.ReadCloser into a struct
|
||||||
|
func UnmarshalJSON[T any](body io.ReadCloser) (*T, error) {
|
||||||
|
var obj T
|
||||||
|
if err := json.NewDecoder(body).Decode(&obj); err != nil {
|
||||||
|
return nil, ErrUnmarshalJSON
|
||||||
|
}
|
||||||
|
return &obj, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSONWithLimit reads the given io.ReadCloser into a struct, but only until limit is reached
|
||||||
|
func UnmarshalJSONWithLimit[T any](r io.ReadCloser, limit int) (*T, error) {
|
||||||
|
defer r.Close()
|
||||||
|
p, err := Peek(r, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if p.LimitReached {
|
||||||
|
return nil, ErrTooLargeJSON
|
||||||
|
}
|
||||||
|
var obj T
|
||||||
|
if err := json.NewDecoder(p).Decode(&obj); err != nil {
|
||||||
|
return nil, ErrUnmarshalJSON
|
||||||
|
}
|
||||||
|
return &obj, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -161,3 +163,40 @@ func TestQuoteCommand(t *testing.T) {
|
|||||||
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
|
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
|
||||||
require.Equal(t, `/home/sweet/home "Äöü this is a test" "\a\b"`, QuoteCommand([]string{"/home/sweet/home", "Äöü this is a test", "\\a\\b"}))
|
require.Equal(t, `/home/sweet/home "Äöü this is a test" "\a\b"`, QuoteCommand([]string{"/home/sweet/home", "Äöü this is a test", "\\a\\b"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBasicAuth(t *testing.T) {
|
||||||
|
require.Equal(t, "Basic cGhpbDpwaGls", BasicAuth("phil", "phil"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBearerAuth(t *testing.T) {
|
||||||
|
require.Equal(t, "Bearer sometoken", BearerAuth("sometoken"))
|
||||||
|
}
|
||||||
|
|
||||||
|
type testJSON struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Something int `json:"something"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadJSON_Success(t *testing.T) {
|
||||||
|
v, err := UnmarshalJSON[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)))
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "some name", v.Name)
|
||||||
|
require.Equal(t, 99, v.Something)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadJSON_Failure(t *testing.T) {
|
||||||
|
_, err := UnmarshalJSON[testJSON](io.NopCloser(strings.NewReader(`{"na`)))
|
||||||
|
require.Equal(t, ErrUnmarshalJSON, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadJSONWithLimit_Success(t *testing.T) {
|
||||||
|
v, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 100)
|
||||||
|
require.Nil(t, err)
|
||||||
|
require.Equal(t, "some name", v.Name)
|
||||||
|
require.Equal(t, 99, v.Something)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadJSONWithLimit_FailureTooLong(t *testing.T) {
|
||||||
|
_, err := UnmarshalJSONWithLimit[testJSON](io.NopCloser(strings.NewReader(`{"name":"some name","something":99}`)), 10)
|
||||||
|
require.Equal(t, ErrTooLargeJSON, err)
|
||||||
|
}
|
||||||
|
|||||||
13185
web/package-lock.json
generated
13185
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
|||||||
"@mui/material": "latest",
|
"@mui/material": "latest",
|
||||||
"dexie": "^3.2.1",
|
"dexie": "^3.2.1",
|
||||||
"dexie-react-hooks": "^1.1.1",
|
"dexie-react-hooks": "^1.1.1",
|
||||||
|
"humanize-duration": "^3.27.3",
|
||||||
"i18next": "^21.6.14",
|
"i18next": "^21.6.14",
|
||||||
"i18next-browser-languagedetector": "^6.1.4",
|
"i18next-browser-languagedetector": "^6.1.4",
|
||||||
"i18next-http-backend": "^1.4.0",
|
"i18next-http-backend": "^1.4.0",
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
// Configuration injected by the ntfy server.
|
// THIS FILE IS JUST AN EXAMPLE
|
||||||
//
|
//
|
||||||
// This file is just an example. It is removed during the build process.
|
// It is removed during the build process. The actual config is dynamically
|
||||||
// The actual config is dynamically generated server-side.
|
// generated server-side and served by the ntfy server.
|
||||||
|
//
|
||||||
|
// During web development, you may change values here for rapid testing.
|
||||||
|
|
||||||
var config = {
|
var config = {
|
||||||
appRoot: "/",
|
base_url: "http://localhost:2586", // window.location.origin FIXME update before merging
|
||||||
disallowedTopics: ["docs", "static", "file", "app", "settings"]
|
app_root: "/app",
|
||||||
|
enable_login: true,
|
||||||
|
enable_signup: true,
|
||||||
|
enable_payments: true,
|
||||||
|
enable_reservations: true,
|
||||||
|
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>ntfy web</title>
|
<title>ntfy web</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="static/css/home.css" type="text/css">
|
||||||
|
|
||||||
<!-- Mobile view -->
|
<!-- Mobile view -->
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* general styling */
|
/* general styling */
|
||||||
|
|
||||||
html, body {
|
#site {
|
||||||
font-family: 'Roboto', sans-serif;
|
font-family: 'Roboto', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
@@ -9,22 +9,16 @@ html, body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
#site a, a:visited {
|
||||||
/* prevent scrollbar from repositioning website:
|
|
||||||
* https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
a, a:visited {
|
|
||||||
color: #338574;
|
color: #338574;
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover {
|
#site a:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #317f6f;
|
color: #317f6f;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
#site h1 {
|
||||||
margin-top: 35px;
|
margin-top: 35px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
font-size: 2.5em;
|
font-size: 2.5em;
|
||||||
@@ -34,7 +28,7 @@ h1 {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
#site h2 {
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
font-size: 1.8em;
|
font-size: 1.8em;
|
||||||
@@ -42,7 +36,7 @@ h2 {
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
#site h3 {
|
||||||
margin-top: 25px;
|
margin-top: 25px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
font-size: 1.3em;
|
font-size: 1.3em;
|
||||||
@@ -50,28 +44,28 @@ h3 {
|
|||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
#site p {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
line-height: 160%;
|
line-height: 160%;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
p.smallMarginBottom {
|
#site p.smallMarginBottom {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
b {
|
#site b {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
tt {
|
#site tt {
|
||||||
background: #eee;
|
background: #eee;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
#site code {
|
||||||
display: block;
|
display: block;
|
||||||
background: #eee;
|
background: #eee;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
@@ -85,18 +79,18 @@ code {
|
|||||||
|
|
||||||
/* Main page */
|
/* Main page */
|
||||||
|
|
||||||
#main {
|
#site #main {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto 50px auto;
|
margin: 0 auto 50px auto;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#error {
|
#site #error {
|
||||||
color: darkred;
|
color: darkred;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
#ironicCenterTagDontFreakOut {
|
#site #ironicCenterTagDontFreakOut {
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,22 +114,22 @@ code {
|
|||||||
|
|
||||||
/* Figures */
|
/* Figures */
|
||||||
|
|
||||||
figure {
|
#site figure {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
figure img, figure video {
|
#site figure img, figure video {
|
||||||
filter: drop-shadow(3px 3px 3px #ccc);
|
filter: drop-shadow(3px 3px 3px #ccc);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
figure video {
|
#site figure video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 450px;
|
max-height: 450px;
|
||||||
}
|
}
|
||||||
|
|
||||||
figcaption {
|
#site figcaption {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
@@ -143,18 +137,18 @@ figcaption {
|
|||||||
|
|
||||||
/* Screenshots */
|
/* Screenshots */
|
||||||
|
|
||||||
#screenshots {
|
#site #screenshots {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenshots img {
|
#site #screenshots img {
|
||||||
height: 190px;
|
height: 190px;
|
||||||
margin: 3px;
|
margin: 3px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
filter: drop-shadow(2px 2px 2px #ddd);
|
filter: drop-shadow(2px 2px 2px #ddd);
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenshots .nowrap {
|
#site #screenshots .nowrap {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,52 +214,60 @@ figcaption {
|
|||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
|
|
||||||
#header {
|
#site #header {
|
||||||
background: #338574;
|
background: #338574;
|
||||||
height: 130px;
|
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%); filter: drop-shadow(0 5px 10px #ccc);
|
||||||
|
height: 70px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header #headerBox {
|
#site #header #headerBox {
|
||||||
max-width: 900px;
|
max-width: 900px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header #logo {
|
#site #header #logo {
|
||||||
margin-top: 23px;
|
margin-top: 14px;
|
||||||
|
width: 48px;
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header #name {
|
#site #header #name {
|
||||||
float: left;
|
float: left;
|
||||||
color: white;
|
color: white;
|
||||||
font-size: 2.6em;
|
font-size: 1.7em;
|
||||||
font-weight: 300;
|
font-weight: 400;
|
||||||
margin: 35px 0 0 20px;
|
margin: 12px 0 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header ol {
|
#site #header #menu {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
float: right;
|
float: right;
|
||||||
margin-top: 80px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header ol li {
|
#site #header #menu li {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 0 10px;
|
padding: 3px 10px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header ol li a, nav ol li a:visited {
|
#site #header #menu li {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#site #header #menu li a,
|
||||||
|
#site #header #menu li a:visited {
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header ol li a:hover {
|
#site #header #menu li:hover {
|
||||||
text-decoration: underline;
|
background: #3f9a86;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
#site li {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@@ -274,7 +276,7 @@ li {
|
|||||||
|
|
||||||
/* Hide top menu SMALL SCREEN */
|
/* Hide top menu SMALL SCREEN */
|
||||||
@media only screen and (max-width: 780px) {
|
@media only screen and (max-width: 780px) {
|
||||||
#header ol {
|
#header #menu {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
"publish_dialog_progress_uploading_detail": "Hochladen {{loaded}}/{{total}} ({{percent}} %) …",
|
"publish_dialog_progress_uploading_detail": "Hochladen {{loaded}}/{{total}} ({{percent}} %) …",
|
||||||
"publish_dialog_priority_max": "Max. Priorität",
|
"publish_dialog_priority_max": "Max. Priorität",
|
||||||
"publish_dialog_topic_placeholder": "Thema, z.B. phil_alerts",
|
"publish_dialog_topic_placeholder": "Thema, z.B. phil_alerts",
|
||||||
"publish_dialog_attachment_limits_file_reached": "überschreitet das Dateigrößen-Limit {{filesizeLimit}}",
|
"publish_dialog_attachment_limits_file_reached": "überschreitet das Dateigrößen-Limit {{fileSizeLimit}}",
|
||||||
"publish_dialog_topic_label": "Thema",
|
"publish_dialog_topic_label": "Thema",
|
||||||
"publish_dialog_priority_default": "Standard-Priorität",
|
"publish_dialog_priority_default": "Standard-Priorität",
|
||||||
"publish_dialog_base_url_placeholder": "Service-URL, z.B. https://example.com",
|
"publish_dialog_base_url_placeholder": "Service-URL, z.B. https://example.com",
|
||||||
|
|||||||
@@ -1,19 +1,40 @@
|
|||||||
{
|
{
|
||||||
|
"signup_title": "Create a ntfy account",
|
||||||
|
"signup_form_username": "Username",
|
||||||
|
"signup_form_password": "Password",
|
||||||
|
"signup_form_confirm_password": "Confirm password",
|
||||||
|
"signup_form_button_submit": "Sign up",
|
||||||
|
"signup_form_toggle_password_visibility": "Toggle password visibility",
|
||||||
|
"signup_already_have_account": "Already have an account? Sign in!",
|
||||||
|
"signup_disabled": "Signup is disabled",
|
||||||
|
"signup_error_username_taken": "Username {{username}} is already taken",
|
||||||
|
"signup_error_creation_limit_reached": "Account creation limit reached",
|
||||||
|
"signup_error_unknown": "Unknown error. Check logs for details.",
|
||||||
|
"login_title": "Sign in to your ntfy account",
|
||||||
|
"login_form_button_submit": "Sign in",
|
||||||
|
"login_link_signup": "Sign up",
|
||||||
"action_bar_show_menu": "Show menu",
|
"action_bar_show_menu": "Show menu",
|
||||||
"action_bar_logo_alt": "ntfy logo",
|
"action_bar_logo_alt": "ntfy logo",
|
||||||
"action_bar_settings": "Settings",
|
"action_bar_settings": "Settings",
|
||||||
|
"action_bar_account": "Account",
|
||||||
"action_bar_subscription_settings": "Subscription settings",
|
"action_bar_subscription_settings": "Subscription settings",
|
||||||
"action_bar_send_test_notification": "Send test notification",
|
"action_bar_send_test_notification": "Send test notification",
|
||||||
"action_bar_clear_notifications": "Clear all notifications",
|
"action_bar_clear_notifications": "Clear all notifications",
|
||||||
"action_bar_unsubscribe": "Unsubscribe",
|
"action_bar_unsubscribe": "Unsubscribe",
|
||||||
"action_bar_toggle_mute": "Mute/unmute notifications",
|
"action_bar_toggle_mute": "Mute/unmute notifications",
|
||||||
"action_bar_toggle_action_menu": "Open/close action menu",
|
"action_bar_toggle_action_menu": "Open/close action menu",
|
||||||
|
"action_bar_profile_title": "Profile",
|
||||||
|
"action_bar_profile_settings": "Settings",
|
||||||
|
"action_bar_profile_logout": "Logout",
|
||||||
|
"action_bar_sign_in": "Sign in",
|
||||||
|
"action_bar_sign_up": "Sign up",
|
||||||
"message_bar_type_message": "Type a message here",
|
"message_bar_type_message": "Type a message here",
|
||||||
"message_bar_error_publishing": "Error publishing notification",
|
"message_bar_error_publishing": "Error publishing notification",
|
||||||
"message_bar_show_dialog": "Show publish dialog",
|
"message_bar_show_dialog": "Show publish dialog",
|
||||||
"message_bar_publish": "Publish message",
|
"message_bar_publish": "Publish message",
|
||||||
"nav_topics_title": "Subscribed topics",
|
"nav_topics_title": "Subscribed topics",
|
||||||
"nav_button_all_notifications": "All notifications",
|
"nav_button_all_notifications": "All notifications",
|
||||||
|
"nav_button_account": "Account",
|
||||||
"nav_button_settings": "Settings",
|
"nav_button_settings": "Settings",
|
||||||
"nav_button_documentation": "Documentation",
|
"nav_button_documentation": "Documentation",
|
||||||
"nav_button_publish_message": "Publish notification",
|
"nav_button_publish_message": "Publish notification",
|
||||||
@@ -63,6 +84,7 @@
|
|||||||
"subscription_settings_dialog_title": "Subscription settings",
|
"subscription_settings_dialog_title": "Subscription settings",
|
||||||
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.",
|
"subscription_settings_dialog_description": "Configure settings specifically for this topic subscription. Settings are currently only applied locally.",
|
||||||
"subscription_settings_dialog_display_name_placeholder": "Display name",
|
"subscription_settings_dialog_display_name_placeholder": "Display name",
|
||||||
|
"subscription_settings_dialog_reserve_topic_label": "Reserve topic and configure access",
|
||||||
"subscription_settings_button_cancel": "Cancel",
|
"subscription_settings_button_cancel": "Cancel",
|
||||||
"subscription_settings_button_save": "Save",
|
"subscription_settings_button_save": "Save",
|
||||||
"notifications_loading": "Loading notifications …",
|
"notifications_loading": "Loading notifications …",
|
||||||
@@ -139,7 +161,64 @@
|
|||||||
"subscribe_dialog_login_button_back": "Back",
|
"subscribe_dialog_login_button_back": "Back",
|
||||||
"subscribe_dialog_login_button_login": "Login",
|
"subscribe_dialog_login_button_login": "Login",
|
||||||
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
|
"subscribe_dialog_error_user_not_authorized": "User {{username}} not authorized",
|
||||||
|
"subscribe_dialog_error_topic_already_reserved": "Topic already reserved",
|
||||||
"subscribe_dialog_error_user_anonymous": "anonymous",
|
"subscribe_dialog_error_user_anonymous": "anonymous",
|
||||||
|
"account_basics_title": "Account",
|
||||||
|
"account_basics_username_title": "Username",
|
||||||
|
"account_basics_username_description": "Hey, that's you ❤",
|
||||||
|
"account_basics_username_admin_tooltip": "You are Admin",
|
||||||
|
"account_basics_password_title": "Password",
|
||||||
|
"account_basics_password_description": "Change your account password",
|
||||||
|
"account_basics_password_dialog_title": "Change password",
|
||||||
|
"account_basics_password_dialog_new_password_label": "New password",
|
||||||
|
"account_basics_password_dialog_confirm_password_label": "Confirm password",
|
||||||
|
"account_basics_password_dialog_button_cancel": "Cancel",
|
||||||
|
"account_basics_password_dialog_button_submit": "Change password",
|
||||||
|
"account_usage_title": "Usage",
|
||||||
|
"account_usage_of_limit": "of {{limit}}",
|
||||||
|
"account_usage_unlimited": "Unlimited",
|
||||||
|
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
|
||||||
|
"account_usage_tier_title": "Account type",
|
||||||
|
"account_usage_tier_description": "Your account's power level",
|
||||||
|
"account_usage_tier_admin": "Admin",
|
||||||
|
"account_usage_tier_basic": "Basic",
|
||||||
|
"account_usage_tier_free": "Free",
|
||||||
|
"account_usage_tier_upgrade_button": "Upgrade to Pro",
|
||||||
|
"account_usage_tier_change_button": "Change",
|
||||||
|
"account_usage_tier_paid_until": "Subscription paid until {{date}}, and will auto-renew",
|
||||||
|
"account_usage_tier_payment_overdue": "Your payment is overdue. Please update your payment method, or your account will be downgraded soon.",
|
||||||
|
"account_usage_tier_canceled_subscription": "Your subscription was canceled and will be downgraded to a free account on {{date}}.",
|
||||||
|
"account_usage_manage_billing_button": "Manage billing",
|
||||||
|
"account_usage_messages_title": "Published messages",
|
||||||
|
"account_usage_emails_title": "Emails sent",
|
||||||
|
"account_usage_reservations_title": "Reserved topics",
|
||||||
|
"account_usage_attachment_storage_title": "Attachment storage",
|
||||||
|
"account_usage_attachment_storage_description": "{{filesize}} per file, deleted after {{expiry}}",
|
||||||
|
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
|
||||||
|
"account_delete_title": "Delete account",
|
||||||
|
"account_delete_description": "Permanently delete your account",
|
||||||
|
"account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please type '{{username}}' in the text box below.",
|
||||||
|
"account_delete_dialog_label": "Type '{{username}}' to delete account",
|
||||||
|
"account_delete_dialog_button_cancel": "Cancel",
|
||||||
|
"account_delete_dialog_button_submit": "Permanently delete account",
|
||||||
|
"account_delete_dialog_billing_warning": "Deleting your account also cancels your billing subscription immediately. You will not have access to the billing dashboard anymore.",
|
||||||
|
"account_upgrade_dialog_title": "Change account tier",
|
||||||
|
"account_upgrade_dialog_cancel_warning": "This will <strong>cancel your subscription</strong>, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server <strong>will be deleted</strong>.",
|
||||||
|
"account_upgrade_dialog_proration_info": "<strong>Proration</strong>: When switching between paid plans, the price difference will be charged or refunded in the next invoice. You will not receive another invoice until the end of the next billing period.",
|
||||||
|
"account_upgrade_dialog_reservations_warning_one": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least one reservation</strong>. You can remove reservations in the <Link>Settings</Link>.",
|
||||||
|
"account_upgrade_dialog_reservations_warning_other": "The selected tier allows fewer reserved topics than your current tier. Before changing your tier, <strong>please delete at least {{count}} reservations</strong>. You can remove reservations in the <Link>Settings</Link>.",
|
||||||
|
"account_upgrade_dialog_tier_features_reservations": "{{reservations}} reserved topics",
|
||||||
|
"account_upgrade_dialog_tier_features_messages": "{{messages}} daily messages",
|
||||||
|
"account_upgrade_dialog_tier_features_emails": "{{emails}} daily emails",
|
||||||
|
"account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file",
|
||||||
|
"account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage",
|
||||||
|
"account_upgrade_dialog_tier_selected_label": "Selected",
|
||||||
|
"account_upgrade_dialog_tier_current_label": "Current",
|
||||||
|
"account_upgrade_dialog_button_cancel": "Cancel",
|
||||||
|
"account_upgrade_dialog_button_redirect_signup": "Sign up now",
|
||||||
|
"account_upgrade_dialog_button_pay_now": "Pay now and subscribe",
|
||||||
|
"account_upgrade_dialog_button_cancel_subscription": "Cancel subscription",
|
||||||
|
"account_upgrade_dialog_button_update_subscription": "Update subscription",
|
||||||
"prefs_notifications_title": "Notifications",
|
"prefs_notifications_title": "Notifications",
|
||||||
"prefs_notifications_sound_title": "Notification sound",
|
"prefs_notifications_sound_title": "Notification sound",
|
||||||
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",
|
||||||
@@ -168,10 +247,12 @@
|
|||||||
"prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
|
"prefs_notifications_delete_after_one_month_description": "Notifications are auto-deleted after one month",
|
||||||
"prefs_users_title": "Manage users",
|
"prefs_users_title": "Manage users",
|
||||||
"prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.",
|
"prefs_users_description": "Add/remove users for your protected topics here. Please note that username and password are stored in the browser's local storage.",
|
||||||
|
"prefs_users_description_no_sync": "Users and passwords are not synchronized to your account.",
|
||||||
"prefs_users_table": "Users table",
|
"prefs_users_table": "Users table",
|
||||||
"prefs_users_add_button": "Add user",
|
"prefs_users_add_button": "Add user",
|
||||||
"prefs_users_edit_button": "Edit user",
|
"prefs_users_edit_button": "Edit user",
|
||||||
"prefs_users_delete_button": "Delete user",
|
"prefs_users_delete_button": "Delete user",
|
||||||
|
"prefs_users_table_cannot_delete_or_edit": "Cannot delete or edit logged in user",
|
||||||
"prefs_users_table_user_header": "User",
|
"prefs_users_table_user_header": "User",
|
||||||
"prefs_users_table_base_url_header": "Service URL",
|
"prefs_users_table_base_url_header": "Service URL",
|
||||||
"prefs_users_dialog_title_add": "Add user",
|
"prefs_users_dialog_title_add": "Add user",
|
||||||
@@ -184,6 +265,24 @@
|
|||||||
"prefs_users_dialog_button_save": "Save",
|
"prefs_users_dialog_button_save": "Save",
|
||||||
"prefs_appearance_title": "Appearance",
|
"prefs_appearance_title": "Appearance",
|
||||||
"prefs_appearance_language_title": "Language",
|
"prefs_appearance_language_title": "Language",
|
||||||
|
"prefs_reservations_title": "Reserved topics",
|
||||||
|
"prefs_reservations_description": "You can reserve topic names for personal use here. Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
|
||||||
|
"prefs_reservations_limit_reached": "You reached your reserved topics limit.",
|
||||||
|
"prefs_reservations_add_button": "Add reserved topic",
|
||||||
|
"prefs_reservations_edit_button": "Edit topic access",
|
||||||
|
"prefs_reservations_delete_button": "Reset topic access",
|
||||||
|
"prefs_reservations_table": "Reserved topics table",
|
||||||
|
"prefs_reservations_table_topic_header": "Topic",
|
||||||
|
"prefs_reservations_table_access_header": "Access",
|
||||||
|
"prefs_reservations_table_everyone_deny_all": "Only I can publish and subscribe",
|
||||||
|
"prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe",
|
||||||
|
"prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish",
|
||||||
|
"prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe",
|
||||||
|
"prefs_reservations_dialog_title_add": "Reserve topic",
|
||||||
|
"prefs_reservations_dialog_title_edit": "Edit reserved topic",
|
||||||
|
"prefs_reservations_dialog_description": "Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
|
||||||
|
"prefs_reservations_dialog_topic_label": "Topic",
|
||||||
|
"prefs_reservations_dialog_access_label": "Access",
|
||||||
"priority_min": "min",
|
"priority_min": "min",
|
||||||
"priority_low": "low",
|
"priority_low": "low",
|
||||||
"priority_default": "default",
|
"priority_default": "default",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"message_bar_type_message": "Tapez un message ici",
|
"message_bar_type_message": "Tapez un message ici",
|
||||||
"notifications_attachment_open_button": "Ouvrir la pièce jointe",
|
"notifications_attachment_open_button": "Ouvrir la pièce jointe",
|
||||||
"notifications_attachment_link_expires": "le lien expire {{date}}",
|
"notifications_attachment_link_expires": "le lien expire {{date}}",
|
||||||
"message_bar_error_publishing": "Erreur lors de la publication de la notification",
|
"message_bar_error_publishing": "Notification d'erreur de publication",
|
||||||
"nav_button_all_notifications": "Toutes les notifications",
|
"nav_button_all_notifications": "Toutes les notifications",
|
||||||
"nav_button_settings": "Paramètres",
|
"nav_button_settings": "Paramètres",
|
||||||
"nav_button_documentation": "Documentation",
|
"nav_button_documentation": "Documentation",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"subscribe_dialog_login_title": "Connexion nécessaire",
|
"subscribe_dialog_login_title": "Connexion nécessaire",
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
|
"prefs_notifications_min_priority_low_and_higher": "Priorité basse et au-dessus",
|
||||||
"prefs_users_dialog_button_cancel": "Annuler",
|
"prefs_users_dialog_button_cancel": "Annuler",
|
||||||
"error_boundary_button_copy_stack_trace": "Copier la trace d'appels",
|
"error_boundary_button_copy_stack_trace": "Copier la stack strace",
|
||||||
"publish_dialog_attached_file_title": "Fichier joint :",
|
"publish_dialog_attached_file_title": "Fichier joint :",
|
||||||
"publish_dialog_checkbox_publish_another": "Publier un autre",
|
"publish_dialog_checkbox_publish_another": "Publier un autre",
|
||||||
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
|
"publish_dialog_attached_file_filename_placeholder": "Nom du fichier joint",
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
"prefs_users_table_user_header": "Utilisateur",
|
"prefs_users_table_user_header": "Utilisateur",
|
||||||
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
|
"prefs_users_dialog_title_edit": "Éditer l'utilisateur",
|
||||||
"prefs_users_dialog_button_add": "Ajouter",
|
"prefs_users_dialog_button_add": "Ajouter",
|
||||||
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matric</matrixLink>.",
|
||||||
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
|
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
|
||||||
"error_boundary_stack_trace": "Trace de pile d'appels",
|
"error_boundary_stack_trace": "Trace de pile d'appels",
|
||||||
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
"publish_dialog_topic_label": "Emnenavn",
|
"publish_dialog_topic_label": "Emnenavn",
|
||||||
"prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag",
|
"prefs_notifications_delete_after_one_day_description": "Merknader slettes automatisk etter én dag",
|
||||||
"notifications_click_copy_url_button": "Kopier lenke",
|
"notifications_click_copy_url_button": "Kopier lenke",
|
||||||
"error_boundary_title": "Oida, ntfy krasjet",
|
"error_boundary_title": "Oida. Ntfy krasjet.",
|
||||||
"publish_dialog_message_placeholder": "Skriv en melding her",
|
"publish_dialog_message_placeholder": "Skriv en melding her",
|
||||||
"publish_dialog_button_cancel": "Avbryt",
|
"publish_dialog_button_cancel": "Avbryt",
|
||||||
"prefs_notifications_min_priority_title": "Minimumsprioritet",
|
"prefs_notifications_min_priority_title": "Minimumsprioritet",
|
||||||
@@ -118,74 +118,9 @@
|
|||||||
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
"prefs_users_table_base_url_header": "Tjeneste-nettadresse",
|
||||||
"prefs_users_dialog_button_cancel": "Avbryt",
|
"prefs_users_dialog_button_cancel": "Avbryt",
|
||||||
"prefs_users_dialog_button_add": "Legg til",
|
"prefs_users_dialog_button_add": "Legg til",
|
||||||
"publish_dialog_chip_attach_url_label": "Legg til fil med nettadresse",
|
"publish_dialog_chip_attach_url_label": "Legg ved fil per nettadresse",
|
||||||
"publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi",
|
"publish_dialog_tags_placeholder": "Kommainndelt liste over etiketter, f.eks. advarsel, srv1-sikkerhetskopi",
|
||||||
"prefs_notifications_sound_description_none": "Merknader spiller ikke lyd når de mottas",
|
"prefs_notifications_sound_description_none": "Merknader er lydløse når de mottas",
|
||||||
"subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler",
|
"subscribe_dialog_subscribe_topic_placeholder": "Emnenavn, f.eks. phil_varsler",
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere",
|
"prefs_notifications_min_priority_default_and_higher": "Forvalgt prioritet og høyere"
|
||||||
"notifications_no_subscriptions_title": "Det ser ut til at du ikke har noen abonnementer ennå.",
|
|
||||||
"publish_dialog_attachment_limits_file_and_quota_reached": "overskrider {{fileSizeLimit}} filgrense og kvote, {{remainingBytes}} gjenstår",
|
|
||||||
"publish_dialog_attachment_limits_file_reached": "overskrider filgrensen på {{fileSizeLimit}}",
|
|
||||||
"publish_dialog_title_label": "Tittel",
|
|
||||||
"publish_dialog_title_placeholder": "Varslingstittel, f.eks. Diskplassvarsel",
|
|
||||||
"publish_dialog_topic_placeholder": "Emnenavn, f.eks. halgeir_varsler",
|
|
||||||
"publish_dialog_chip_click_label": "Klikk URL",
|
|
||||||
"publish_dialog_chip_delay_label": "Forsink leveringen",
|
|
||||||
"publish_dialog_details_examples_description": "For eksempler og en detaljert beskrivelse av alle sendefunksjoner, se <docsLink>dokumentasjonen</docsLink>.",
|
|
||||||
"publish_dialog_base_url_placeholder": "Tjeneste-URL, f.eks. https://example.com",
|
|
||||||
"alert_grant_description": "Gi nettleseren din tillatelse til å vise skrivebordsvarsler.",
|
|
||||||
"alert_not_supported_description": "Varsler støttes ikke i nettleseren din.",
|
|
||||||
"notifications_attachment_file_app": "Android-app-fil",
|
|
||||||
"notifications_no_subscriptions_description": "Klikk på \"{{linktext}}\"-koblingen for å opprette eller abonnere på et emne. Etter det kan du sende meldinger via PUT eller POST, og du vil motta varsler her.",
|
|
||||||
"notifications_actions_http_request_title": "Send HTTP {{metode}} til {{url}}",
|
|
||||||
"notifications_none_for_any_description": "For å sende varsler til et emne, bare PUT eller POST til emne-URLen. Her er et eksempel som bruker et av emnene dine.",
|
|
||||||
"notifications_more_details": "For mer informasjon, sjekk ut <websiteLink>nettstedet</websiteLink> eller <docsLink>dokumentasjonen</docsLink>.",
|
|
||||||
"publish_dialog_attachment_limits_quota_reached": "overskrider kvoten, {{remainingBytes}} gjenstår",
|
|
||||||
"publish_dialog_click_reset": "Fjern klikk-URL",
|
|
||||||
"publish_dialog_delay_placeholder": "Forsinket levering, f.eks. {{unixTimestamp}}, {{relativeTime}} eller \"{{naturalLanguage}}\" (bare på engelsk)",
|
|
||||||
"emoji_picker_search_clear": "Tøm søk",
|
|
||||||
"subscribe_dialog_subscribe_description": "Det kan hende emner ikke er passordsbeskyttet, så velg et navn som ikke er enkelt å gjette. Når du har abonnert kan du utføre PUT/POST av merknader.",
|
|
||||||
"publish_dialog_checkbox_publish_another": "Publiser enda en",
|
|
||||||
"subscribe_dialog_login_description": "Dette emnet er passordbeskyttet. Vennligst skriv inn brukernavn og passord for å abonnere.",
|
|
||||||
"prefs_notifications_sound_play": "Spill av valgt lyd",
|
|
||||||
"subscribe_dialog_error_user_not_authorized": "Bruker {{brukernavn}} ikke autorisert",
|
|
||||||
"prefs_users_delete_button": "Slett bruker",
|
|
||||||
"error_boundary_unsupported_indexeddb_description": "ntfy-nettappen trenger IndexedDB for å fungere, og nettleseren din støtter ikke IndexedDB i privat nettlesingsmodus.<br/><br/>Selv om dette er uheldig, gir det heller ikke så mye mening å bruke ntfy-nettappen i privat surfemodus uansett, fordi alt er lagret i nettleserlagringen. Du kan lese mer om det <githubLink>i denne GitHub-feilmeldingen</githubLink>, eller snakk med oss på <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
|
|
||||||
"action_bar_show_menu": "Vis meny",
|
|
||||||
"action_bar_toggle_mute": "Aktiver/deaktiver notifikasjoner",
|
|
||||||
"prefs_notifications_min_priority_description_max": "Vis merknader hvis prioritet er 5 (maks.)",
|
|
||||||
"prefs_notifications_min_priority_any": "Hvilken som helst prioritet",
|
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Lav prioritet og høyere",
|
|
||||||
"prefs_users_description": "Legg til/fjern brukere for dine beskyttede emner her. Vær oppmerksom på at brukernavn og passord er lagret i nettleserens lokale lagring.",
|
|
||||||
"error_boundary_description": "Dette skal åpenbart ikke skje. Beklager dette.<br/>Hvis du har et minutt, vennligst <githubLink>rapporter dette på GitHub</githubLink>, eller gi oss beskjed via <discordLink>Discord</discordLink> eller <matrixLink>Matrix</matrixLink>.",
|
|
||||||
"action_bar_logo_alt": "ntfy logo",
|
|
||||||
"message_bar_publish": "Publiser melding",
|
|
||||||
"action_bar_toggle_action_menu": "Åpne/lukk handlingsmeny",
|
|
||||||
"message_bar_show_dialog": "Vis publiseringsdialog",
|
|
||||||
"nav_button_muted": "Varsler dempet",
|
|
||||||
"nav_button_connecting": "kobler til",
|
|
||||||
"notifications_list": "Varslingsliste",
|
|
||||||
"notifications_list_item": "Varsling",
|
|
||||||
"notifications_mark_read": "Merk som lest",
|
|
||||||
"notifications_delete": "Slett",
|
|
||||||
"notifications_priority_x": "Prioritet {{prioritet}}",
|
|
||||||
"notifications_new_indicator": "Nytt varsel",
|
|
||||||
"notifications_attachment_image": "Vedlagt bilde",
|
|
||||||
"notifications_attachment_file_image": "bildefil",
|
|
||||||
"notifications_attachment_file_video": "videofil",
|
|
||||||
"notifications_attachment_file_audio": "lydfil",
|
|
||||||
"notifications_attachment_file_document": "annet dokument",
|
|
||||||
"notifications_actions_not_supported": "Handling støttes ikke i nettappen",
|
|
||||||
"notifications_none_for_topic_description": "For å sende varsler til dette emnet, bare PUT eller POST til emne-URLen.",
|
|
||||||
"publish_dialog_emoji_picker_show": "Velg emoji",
|
|
||||||
"publish_dialog_topic_reset": "Tilbakestill emne",
|
|
||||||
"publish_dialog_click_label": "Klikk URL",
|
|
||||||
"publish_dialog_email_reset": "Fjern videresending av e-post",
|
|
||||||
"publish_dialog_attach_reset": "Fjern URL vedlegg",
|
|
||||||
"publish_dialog_delay_reset": "Fjern forsinket levering",
|
|
||||||
"publish_dialog_attached_file_remove": "Fjern vedlagt fil",
|
|
||||||
"subscribe_dialog_subscribe_base_url_label": "Tjeneste-URL",
|
|
||||||
"prefs_users_table": "Brukertabell",
|
|
||||||
"prefs_users_edit_button": "Rediger bruker",
|
|
||||||
"error_boundary_unsupported_indexeddb_title": "Privat surfing støttes ikke"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"action_bar_settings": "Instellingen",
|
"action_bar_settings": "Instellingen",
|
||||||
"action_bar_send_test_notification": "Verstuur testnotificatie.",
|
"action_bar_send_test_notification": "Stuur test notificatie",
|
||||||
"action_bar_clear_notifications": "Wis alle notificaties",
|
"action_bar_clear_notifications": "Wis alle notificaties",
|
||||||
"message_bar_type_message": "Typ hier een bericht",
|
"message_bar_type_message": "Typ hier een bericht",
|
||||||
"action_bar_unsubscribe": "Afmelden",
|
"action_bar_unsubscribe": "Afmelden",
|
||||||
"message_bar_error_publishing": "Fout bij publiceren notificatie",
|
"message_bar_error_publishing": "Fout bij publiceren notificatie",
|
||||||
"nav_topics_title": "Geabonneerde onderwerpen",
|
"nav_topics_title": "Geabonneerde onderwerpen",
|
||||||
"nav_button_settings": "Instellingen",
|
"nav_button_settings": "Instellingen",
|
||||||
"alert_not_supported_description": "Notificaties worden niet ondersteund door je browser.",
|
"alert_not_supported_description": "Notificaties worden niet ondersteund in je browser.",
|
||||||
"notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.",
|
"notifications_none_for_any_title": "Je hebt nog geen notificaties ontvangen.",
|
||||||
"publish_dialog_tags_label": "Tags",
|
"publish_dialog_tags_label": "Tags",
|
||||||
"publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen",
|
"publish_dialog_chip_attach_file_label": "Lokaal bestand bijvoegen",
|
||||||
@@ -26,20 +26,20 @@
|
|||||||
"action_bar_show_menu": "Toon menu",
|
"action_bar_show_menu": "Toon menu",
|
||||||
"action_bar_logo_alt": "ntfy logo",
|
"action_bar_logo_alt": "ntfy logo",
|
||||||
"action_bar_toggle_mute": "Notificaties dempen/opheffen",
|
"action_bar_toggle_mute": "Notificaties dempen/opheffen",
|
||||||
"action_bar_toggle_action_menu": "Open/Sluit actiemenu",
|
"action_bar_toggle_action_menu": "Actie menu openen/sluiten",
|
||||||
"message_bar_show_dialog": "Toon publicatie venster",
|
"message_bar_show_dialog": "Toon publicatie venster",
|
||||||
"message_bar_publish": "Bericht publiceren",
|
"message_bar_publish": "Bericht publiceren",
|
||||||
"nav_button_all_notifications": "Alle notificaties",
|
"nav_button_all_notifications": "Alle notificaties",
|
||||||
"nav_button_documentation": "Documentatie",
|
"nav_button_documentation": "Documentatie",
|
||||||
"nav_button_publish_message": "Notificatie publiceren",
|
"nav_button_publish_message": "Notificatie publiceren",
|
||||||
"nav_button_subscribe": "Abonneer op onderwerp",
|
"nav_button_subscribe": "Onderwerp abonneren",
|
||||||
"nav_button_muted": "Notificaties gedempt",
|
"nav_button_muted": "Notificaties gedempt",
|
||||||
"nav_button_connecting": "verbinden",
|
"nav_button_connecting": "verbinden",
|
||||||
"alert_grant_title": "Notificaties zijn uitgeschakeld",
|
"alert_grant_title": "Notificaties zijn uitgeschakeld",
|
||||||
"alert_grant_description": "Verleen je browser toestemming voor het weergeven van notificaties.",
|
"alert_grant_description": "Geef je browser toestemming om meldingen weer te geven.",
|
||||||
"alert_grant_button": "Nu toestaan",
|
"alert_grant_button": "Nu toestaan",
|
||||||
"alert_not_supported_title": "Notificaties zijn niet ondersteund",
|
"alert_not_supported_title": "Notificaties zijn niet ondersteund",
|
||||||
"notifications_list": "Notificatielijst",
|
"notifications_list": "Notificaties lijst",
|
||||||
"notifications_list_item": "Notificatie",
|
"notifications_list_item": "Notificatie",
|
||||||
"notifications_mark_read": "Markeer als gelezen",
|
"notifications_mark_read": "Markeer als gelezen",
|
||||||
"notifications_delete": "Verwijder",
|
"notifications_delete": "Verwijder",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"notifications_attachment_file_audio": "audiobestand",
|
"notifications_attachment_file_audio": "audiobestand",
|
||||||
"notifications_attachment_file_app": "Android app bestand",
|
"notifications_attachment_file_app": "Android app bestand",
|
||||||
"notifications_attachment_file_document": "overig document",
|
"notifications_attachment_file_document": "overig document",
|
||||||
"notifications_click_copy_url_title": "link URL naar klembord kopiëren",
|
"notifications_click_copy_url_title": "URL naar klembord kopiëren",
|
||||||
"notifications_click_copy_url_button": "Link kopiëren",
|
"notifications_click_copy_url_button": "Link kopiëren",
|
||||||
"notifications_click_open_button": "Link openen",
|
"notifications_click_open_button": "Link openen",
|
||||||
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.",
|
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.",
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
"publish_dialog_title_no_topic": "Notificatie publiceren",
|
"publish_dialog_title_no_topic": "Notificatie publiceren",
|
||||||
"publish_dialog_progress_uploading": "Uploaden …",
|
"publish_dialog_progress_uploading": "Uploaden …",
|
||||||
"notifications_actions_open_url_title": "Ga naar {{url}}",
|
"notifications_actions_open_url_title": "Ga naar {{url}}",
|
||||||
"notifications_actions_not_supported": "Actie wordt niet ondersteund in de webapplicatie",
|
"notifications_actions_not_supported": "Deze actie is niet ondersteund in de web applicatie",
|
||||||
"notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}",
|
"notifications_actions_http_request_title": "Stuur HTTP {{method}} naar {{url}}",
|
||||||
"notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.",
|
"notifications_none_for_topic_title": "Je hebt nog geen notificaties ontvangen voor dit onderwerp.",
|
||||||
"publish_dialog_priority_low": "Lage prioriteit",
|
"publish_dialog_priority_low": "Lage prioriteit",
|
||||||
|
|||||||
@@ -1,191 +1 @@
|
|||||||
{
|
{}
|
||||||
"action_bar_clear_notifications": "Limpar todas as notificações",
|
|
||||||
"action_bar_send_test_notification": "Enviar notificação de teste",
|
|
||||||
"action_bar_unsubscribe": "Anular subscrição",
|
|
||||||
"action_bar_toggle_mute": "Ativa/Desativa notificações",
|
|
||||||
"action_bar_toggle_action_menu": "Abrir/fechar menu de ação",
|
|
||||||
"message_bar_type_message": "Escreva uma mensagem aqui",
|
|
||||||
"message_bar_error_publishing": "Erro ao publicar notificação",
|
|
||||||
"message_bar_publish": "Publicar mensagem",
|
|
||||||
"nav_topics_title": "Tópicos subscritos",
|
|
||||||
"nav_button_all_notifications": "Todas notificações",
|
|
||||||
"nav_button_settings": "Configurações",
|
|
||||||
"nav_button_documentation": "Documentação",
|
|
||||||
"nav_button_publish_message": "Publicar notificação",
|
|
||||||
"nav_button_subscribe": "Subscrever tópico",
|
|
||||||
"nav_button_muted": "Notificações desativadas",
|
|
||||||
"nav_button_connecting": "A ligar",
|
|
||||||
"alert_grant_title": "As notificações estão desativadas",
|
|
||||||
"alert_grant_description": "Conceder permissão ao seu navegador para mostrar notificações.",
|
|
||||||
"alert_not_supported_title": "Notificações não suportadas",
|
|
||||||
"notifications_list": "Lista de notificações",
|
|
||||||
"alert_not_supported_description": "As notificações não são suportadas pelo seu navegador.",
|
|
||||||
"notifications_list_item": "Notificação",
|
|
||||||
"notifications_mark_read": "Marcar como lido",
|
|
||||||
"notifications_delete": "Apagar",
|
|
||||||
"notifications_copied_to_clipboard": "Copiado para a área de transferência",
|
|
||||||
"notifications_tags": "Etiquetas",
|
|
||||||
"notifications_priority_x": "Prioridade {{priority}}",
|
|
||||||
"notifications_new_indicator": "Nova notificação",
|
|
||||||
"notifications_attachment_image": "Imagem anexada",
|
|
||||||
"notifications_attachment_copy_url_title": "Copiar URL do anexo para a área de transferência",
|
|
||||||
"notifications_attachment_copy_url_button": "Copiar URL",
|
|
||||||
"notifications_attachment_open_title": "Ir para {{url}}",
|
|
||||||
"notifications_attachment_link_expired": "a ligação de transferência expirou",
|
|
||||||
"notifications_attachment_open_button": "Abrir anexo",
|
|
||||||
"notifications_attachment_link_expires": "a ligação expira em {{date}}",
|
|
||||||
"notifications_attachment_file_image": "ficheiro de imagem",
|
|
||||||
"notifications_attachment_file_video": "ficheiro de vídeo",
|
|
||||||
"notifications_attachment_file_audio": "ficheiro de áudio",
|
|
||||||
"notifications_attachment_file_app": "ficheiro apk Android",
|
|
||||||
"notifications_attachment_file_document": "outros documentos",
|
|
||||||
"notifications_click_copy_url_title": "Copiar URL da ligação para a área de transferência",
|
|
||||||
"notifications_click_copy_url_button": "Copiar ligação",
|
|
||||||
"notifications_click_open_button": "Abrir ligação",
|
|
||||||
"notifications_actions_open_url_title": "Ir para {{url}}",
|
|
||||||
"notifications_actions_not_supported": "Ação não suportada na app web",
|
|
||||||
"notifications_actions_http_request_title": "Enviar HTTP {{method}} para {{url}}",
|
|
||||||
"notifications_none_for_topic_title": "Ainda não recebeu nenhuma notificação deste tópico.",
|
|
||||||
"notifications_none_for_topic_description": "Para enviar notificações deste tópico, basta usar os métodos PUT ou POST no URL do tópico.",
|
|
||||||
"notifications_none_for_any_title": "Ainda não recebeu nenhuma notificação.",
|
|
||||||
"notifications_none_for_any_description": "Para enviar notificações dum tópico, basta usar os métodos PUT ou POST no URL do tópico. Eis um exemplo usando um dos seus tópicos.",
|
|
||||||
"notifications_no_subscriptions_title": "Parece que ainda não tem nenhuma inscrição.",
|
|
||||||
"notifications_no_subscriptions_description": "Clique na ligação \"{{linktext}}\" para criar ou subscrever um tópico. Depois, poderá enviar mensagens via PUT ou POST e receberá notificações aqui.",
|
|
||||||
"notifications_example": "Exemplo",
|
|
||||||
"notifications_more_details": "Para mais informações, aceda ao <websiteLink>site</websiteLink> ou à <docsLink>documentação</docsLink>.",
|
|
||||||
"notifications_loading": "A carregar notificações…",
|
|
||||||
"publish_dialog_title_topic": "Publicar em {{topic}}",
|
|
||||||
"publish_dialog_title_no_topic": "Publicar notificação",
|
|
||||||
"publish_dialog_progress_uploading": "A enviar …",
|
|
||||||
"publish_dialog_progress_uploading_detail": "A enviar {{loaded}}/{{total}} ({{percent}}%)…",
|
|
||||||
"publish_dialog_message_published": "Notificação publicada",
|
|
||||||
"publish_dialog_attachment_limits_file_and_quota_reached": "excede limite de ficheiro de {{fileSizeLimit}} e cota, {{remainingBytes}} restante(s)",
|
|
||||||
"publish_dialog_attachment_limits_quota_reached": "excede a cota, {{remainingBytes}} restante(s)",
|
|
||||||
"publish_dialog_priority_min": "Prioridade mínima",
|
|
||||||
"publish_dialog_priority_low": "Prioridade baixa",
|
|
||||||
"publish_dialog_priority_default": "Prioridade padrão",
|
|
||||||
"publish_dialog_priority_high": "Prioridade alta",
|
|
||||||
"publish_dialog_base_url_label": "URL de serviço",
|
|
||||||
"publish_dialog_base_url_placeholder": "URL de serviço, por exemplo: https://exemplo.com",
|
|
||||||
"publish_dialog_topic_label": "Nome do tópico",
|
|
||||||
"publish_dialog_topic_placeholder": "Nome do tópico, por exemplo: \"avisos_do_filipe\"",
|
|
||||||
"publish_dialog_topic_reset": "Limpar tópico",
|
|
||||||
"publish_dialog_title_placeholder": "Título da notificação, por exemplo: \"Alerta de espaço em disco\"",
|
|
||||||
"publish_dialog_message_label": "Mensagem",
|
|
||||||
"publish_dialog_message_placeholder": "Escreva uma mensagem aqui",
|
|
||||||
"publish_dialog_tags_label": "Etiquetas",
|
|
||||||
"publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: aviso, srv1-backup",
|
|
||||||
"publish_dialog_priority_label": "Prioridade",
|
|
||||||
"publish_dialog_click_label": "URL de clique",
|
|
||||||
"publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada",
|
|
||||||
"publish_dialog_click_reset": "Remover URL de clique",
|
|
||||||
"publish_dialog_email_label": "Email",
|
|
||||||
"publish_dialog_filename_placeholder": "Nome do ficheiro anexado",
|
|
||||||
"publish_dialog_email_placeholder": "Endereça para o qual encaminhar a notificação, por exemplo: filipe@exemplo.com",
|
|
||||||
"publish_dialog_email_reset": "Remover encaminhamento por email",
|
|
||||||
"publish_dialog_attach_label": "URL de anexo",
|
|
||||||
"publish_dialog_attach_placeholder": "Anexar ficheiro por URL, por exemplo: https://f-droid.org/F-Droid.apk",
|
|
||||||
"publish_dialog_attach_reset": "Remover URL de anexo",
|
|
||||||
"publish_dialog_filename_label": "Nome do ficheiro",
|
|
||||||
"publish_dialog_delay_label": "Atraso",
|
|
||||||
"publish_dialog_delay_placeholder": "Atraso na entrega, por exemplo \"{{{unixTimestamp}}\", \"{{relativeTime}}\", ou \"{{naturalLanguage}}\" (apenas em Inglês)",
|
|
||||||
"publish_dialog_other_features": "Outras funcionalidades:",
|
|
||||||
"publish_dialog_chip_click_label": "URL de clique",
|
|
||||||
"publish_dialog_chip_topic_label": "Alterar tópico",
|
|
||||||
"publish_dialog_details_examples_description": "Para obter exemplos e uma descrição detalhada de todas as funcionalidades de envio, consulte a <docsLink>documentação</docsLink>.",
|
|
||||||
"publish_dialog_button_cancel_sending": "Cancelar o envio",
|
|
||||||
"publish_dialog_attached_file_filename_placeholder": "Nome do ficheiro anexado",
|
|
||||||
"publish_dialog_attached_file_remove": "Remover ficheiro anexado",
|
|
||||||
"emoji_picker_search_clear": "Limpar pesquisa",
|
|
||||||
"subscribe_dialog_subscribe_description": "Os tópicos podem não ser protegidos por palavra-passe, por isso escolha um nome que não seja fácil de adivinhar. Uma vez subscrito, pode usar os métodos PUT/POST para publicar notificações.",
|
|
||||||
"subscribe_dialog_subscribe_use_another_label": "Usar outro servidor",
|
|
||||||
"subscribe_dialog_error_user_not_authorized": "Utilizador {{username}} não autorizado",
|
|
||||||
"prefs_notifications_min_priority_description_max": "Mostrar notificações se prioridade for 5 (máxima)",
|
|
||||||
"prefs_notifications_delete_after_one_week": "Após uma semana",
|
|
||||||
"prefs_notifications_delete_after_one_month": "Após um mês",
|
|
||||||
"prefs_notifications_delete_after_never_description": "As notificações nunca serão eliminadas automaticamente",
|
|
||||||
"prefs_notifications_delete_after_one_week_description": "As notificações serão eliminadas automaticamente após uma semana",
|
|
||||||
"prefs_notifications_delete_after_one_month_description": "As notificações serão eliminadas automaticamente após um mês",
|
|
||||||
"prefs_users_dialog_username_label": "Utilizador, por exemplo: \"filipe\"",
|
|
||||||
"prefs_users_dialog_password_label": "Palavra-passe",
|
|
||||||
"prefs_users_dialog_button_cancel": "Cancelar",
|
|
||||||
"prefs_users_dialog_button_add": "Adicionar",
|
|
||||||
"error_boundary_description": "Obviamente, isto não devia acontecer, lamentamos o sucedido.<br/>Se tiver um minuto, por favor <githubLink>relate isto no GitHub</githubLink>, ou informe-nos através de <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
|
||||||
"error_boundary_stack_trace": "Erro (\"stack trace\")",
|
|
||||||
"error_boundary_gathering_info": "A recolher mais informações …",
|
|
||||||
"error_boundary_unsupported_indexeddb_title": "Navegação anónima não suportada",
|
|
||||||
"error_boundary_unsupported_indexeddb_description": "A aplicação web ntfy necessita da \"IndexedDB\" para funcionar e o seu navegador não a suporta no modo de navegação privada.<br/><br/>Embora isso seja inconveniente, também não faz muito sentido usar a aplicação no modo de navegação privada de qualquer maneira, visto que tudo é guardado no armazenamento do navegador. Pode ler mais sobre isso <githubLink>nesta questão no GitHub</githubLink>, ou falar connosco por <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
|
||||||
"action_bar_show_menu": "Mostrar menu",
|
|
||||||
"action_bar_logo_alt": "logótipo do ntfy",
|
|
||||||
"action_bar_settings": "Configurações",
|
|
||||||
"message_bar_show_dialog": "Mostrar caixa de publicação",
|
|
||||||
"alert_grant_button": "Conceder agora",
|
|
||||||
"publish_dialog_attachment_limits_file_reached": "excede o limite de ficheiro de {{fileSizeLimit}}",
|
|
||||||
"publish_dialog_emoji_picker_show": "Escolher emoji",
|
|
||||||
"publish_dialog_priority_max": "Prioridade máxima",
|
|
||||||
"publish_dialog_title_label": "Título",
|
|
||||||
"publish_dialog_delay_reset": "Remover atraso de entrega",
|
|
||||||
"publish_dialog_chip_email_label": "Encaminhar para email",
|
|
||||||
"publish_dialog_chip_attach_url_label": "Anexar ficheiro por URL",
|
|
||||||
"publish_dialog_chip_attach_file_label": "Anexar ficheiro local",
|
|
||||||
"publish_dialog_chip_delay_label": "Atraso de entrega",
|
|
||||||
"publish_dialog_button_cancel": "Cancelar",
|
|
||||||
"publish_dialog_button_send": "Enviar",
|
|
||||||
"publish_dialog_checkbox_publish_another": "Publicar outra",
|
|
||||||
"publish_dialog_attached_file_title": "Ficheiro anexado:",
|
|
||||||
"publish_dialog_drop_file_here": "Arraste o ficheiro para aqui",
|
|
||||||
"emoji_picker_search_placeholder": "Pesquisar emoji",
|
|
||||||
"subscribe_dialog_subscribe_title": "Subscrever tópico",
|
|
||||||
"subscribe_dialog_subscribe_topic_placeholder": "Nome do tópico, por exemplo: \"alertas_do_filipe\"",
|
|
||||||
"subscribe_dialog_subscribe_base_url_label": "URL de serviço",
|
|
||||||
"subscribe_dialog_subscribe_button_cancel": "Cancelar",
|
|
||||||
"subscribe_dialog_subscribe_button_subscribe": "Subscrever",
|
|
||||||
"subscribe_dialog_login_title": "Autenticação necessária",
|
|
||||||
"subscribe_dialog_login_description": "Esse tópico é protegido por palavra-passe. Por favor insira um nome de utilizador e palavra-passe para subscrever.",
|
|
||||||
"subscribe_dialog_login_username_label": "Nome, por exemplo: \"filipe\"",
|
|
||||||
"subscribe_dialog_login_password_label": "Palavra-passe",
|
|
||||||
"subscribe_dialog_login_button_back": "Voltar",
|
|
||||||
"subscribe_dialog_login_button_login": "Autenticar",
|
|
||||||
"subscribe_dialog_error_user_anonymous": "anónimo",
|
|
||||||
"prefs_notifications_title": "Notificações",
|
|
||||||
"prefs_notifications_sound_title": "Som de notificações",
|
|
||||||
"prefs_notifications_sound_description_none": "Notificações não reproduzem nenhum som quando chegam",
|
|
||||||
"prefs_notifications_sound_description_some": "Notificações reproduzem som {{sound}} quando chegam",
|
|
||||||
"prefs_notifications_sound_no_sound": "Sem som",
|
|
||||||
"prefs_notifications_sound_play": "Reproduzir som selecionado",
|
|
||||||
"prefs_notifications_min_priority_title": "Prioridade mínima",
|
|
||||||
"prefs_notifications_min_priority_description_any": "A mostrar todas as notificações, independentemente da prioridade",
|
|
||||||
"prefs_notifications_min_priority_description_x_or_higher": "Mostrar notificações se prioridade for {{number}} ({{name}}) ou acima",
|
|
||||||
"prefs_notifications_min_priority_any": "Qualquer prioridade",
|
|
||||||
"prefs_notifications_min_priority_low_and_higher": "Prioridade baixa e acima",
|
|
||||||
"prefs_notifications_min_priority_default_and_higher": "Prioridade padrão e acima",
|
|
||||||
"prefs_notifications_min_priority_high_and_higher": "Prioridade alta e acima",
|
|
||||||
"prefs_notifications_min_priority_max_only": "Apenas prioridade máxima",
|
|
||||||
"prefs_notifications_delete_after_title": "Eliminar notificações",
|
|
||||||
"prefs_notifications_delete_after_never": "Nunca",
|
|
||||||
"prefs_notifications_delete_after_three_hours": "Após três horas",
|
|
||||||
"prefs_notifications_delete_after_one_day": "Após um dia",
|
|
||||||
"prefs_notifications_delete_after_three_hours_description": "As notificações serão eliminadas automaticamente após três horas",
|
|
||||||
"prefs_notifications_delete_after_one_day_description": "As notificações serão eliminadas automaticamente após um dia",
|
|
||||||
"prefs_users_title": "Gerir utilizadores",
|
|
||||||
"prefs_users_description": "Adicionar/remover utilizadores aos seus tópicos protegidos. Note que o utilizador e palavra-passe são guardados no armazenamento local do navegador.",
|
|
||||||
"prefs_users_table": "Tabela de utilizadores",
|
|
||||||
"prefs_users_add_button": "Adicionar utilizador",
|
|
||||||
"prefs_users_edit_button": "Editar utilizador",
|
|
||||||
"prefs_users_delete_button": "Apagar utilizador",
|
|
||||||
"prefs_users_table_user_header": "Utilizador",
|
|
||||||
"prefs_users_table_base_url_header": "URL de serviço",
|
|
||||||
"prefs_users_dialog_title_add": "Adicionar utilizador",
|
|
||||||
"prefs_users_dialog_title_edit": "Editar utilizador",
|
|
||||||
"prefs_users_dialog_base_url_label": "URL de serviço, por exemplo: https://ntfy.sh",
|
|
||||||
"prefs_users_dialog_button_save": "Gravar",
|
|
||||||
"prefs_appearance_title": "Aparência",
|
|
||||||
"prefs_appearance_language_title": "Idioma",
|
|
||||||
"priority_min": "mínima",
|
|
||||||
"priority_low": "baixa",
|
|
||||||
"priority_default": "padrão",
|
|
||||||
"priority_high": "alta",
|
|
||||||
"priority_max": "máxima",
|
|
||||||
"error_boundary_title": "Oh não, o ntfy parou de funcionar",
|
|
||||||
"error_boundary_button_copy_stack_trace": "Copiar erro (\"stack trace\")"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"action_bar_show_menu": "Afișează meniu",
|
|
||||||
"action_bar_send_test_notification": "Trimite notificare de probă",
|
|
||||||
"action_bar_clear_notifications": "Șterge toate notificările",
|
|
||||||
"action_bar_settings": "Setări",
|
|
||||||
"action_bar_unsubscribe": "Dezabonare",
|
|
||||||
"action_bar_logo_alt": "logo-ul ntfy",
|
|
||||||
"action_bar_toggle_mute": "Oprire/activare notificări",
|
|
||||||
"message_bar_type_message": "Scrie un mesaj aici",
|
|
||||||
"message_bar_error_publishing": "Eroare la publicarea notificării"
|
|
||||||
}
|
|
||||||
404
web/src/app/AccountApi.js
Normal file
404
web/src/app/AccountApi.js
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import {
|
||||||
|
accountReservationSingleUrl,
|
||||||
|
accountReservationUrl,
|
||||||
|
accountPasswordUrl,
|
||||||
|
accountSettingsUrl,
|
||||||
|
accountSubscriptionSingleUrl,
|
||||||
|
accountSubscriptionUrl,
|
||||||
|
accountTokenUrl,
|
||||||
|
accountUrl, maybeWithAuth, topicUrl,
|
||||||
|
withBasicAuth,
|
||||||
|
withBearerAuth, accountBillingSubscriptionUrl, accountBillingPortalUrl, tiersUrl
|
||||||
|
} from "./utils";
|
||||||
|
import session from "./Session";
|
||||||
|
import subscriptionManager from "./SubscriptionManager";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import prefs from "./Prefs";
|
||||||
|
import routes from "../components/routes";
|
||||||
|
import userManager from "./UserManager";
|
||||||
|
|
||||||
|
const delayMillis = 45000; // 45 seconds
|
||||||
|
const intervalMillis = 900000; // 15 minutes
|
||||||
|
|
||||||
|
class AccountApi {
|
||||||
|
constructor() {
|
||||||
|
this.timer = null;
|
||||||
|
this.listener = null; // Fired when account is fetched from remote
|
||||||
|
}
|
||||||
|
|
||||||
|
registerListener(listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetListener() {
|
||||||
|
this.listener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(user) {
|
||||||
|
const url = accountTokenUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Checking auth for ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBasicAuth({}, user.username, user.password)
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
const json = await response.json();
|
||||||
|
if (!json.token) {
|
||||||
|
throw new Error(`Unexpected server response: Cannot find token`);
|
||||||
|
}
|
||||||
|
return json.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
const url = accountTokenUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Logging out from ${url} using token ${session.token()}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(username, password) {
|
||||||
|
const url = accountUrl(config.base_url);
|
||||||
|
const body = JSON.stringify({
|
||||||
|
username: username,
|
||||||
|
password: password
|
||||||
|
});
|
||||||
|
console.log(`[AccountApi] Creating user account ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
if (response.status === 409) {
|
||||||
|
throw new UsernameTakenError(username);
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
throw new AccountCreateLimitReachedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get() {
|
||||||
|
const url = accountUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Fetching user account ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
const account = await response.json();
|
||||||
|
console.log(`[AccountApi] Account`, account);
|
||||||
|
if (this.listener) {
|
||||||
|
this.listener(account);
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete() {
|
||||||
|
const url = accountUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Deleting user account ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(newPassword) {
|
||||||
|
const url = accountPasswordUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Changing account password ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: newPassword
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async extendToken() {
|
||||||
|
const url = accountTokenUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Extending user access token ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSettings(payload) {
|
||||||
|
const url = accountSettingsUrl(config.base_url);
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
console.log(`[AccountApi] Updating user account ${url}: ${body}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addSubscription(payload) {
|
||||||
|
const url = accountSubscriptionUrl(config.base_url);
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
console.log(`[AccountApi] Adding user subscription ${url}: ${body}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
const subscription = await response.json();
|
||||||
|
console.log(`[AccountApi] Subscription`, subscription);
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscription(remoteId, payload) {
|
||||||
|
const url = accountSubscriptionSingleUrl(config.base_url, remoteId);
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
console.log(`[AccountApi] Updating user subscription ${url}: ${body}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
const subscription = await response.json();
|
||||||
|
console.log(`[AccountApi] Subscription`, subscription);
|
||||||
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSubscription(remoteId) {
|
||||||
|
const url = accountSubscriptionSingleUrl(config.base_url, remoteId);
|
||||||
|
console.log(`[AccountApi] Removing user subscription ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertReservation(topic, everyone) {
|
||||||
|
const url = accountReservationUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Upserting user access to topic ${topic}, everyone=${everyone}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
topic: topic,
|
||||||
|
everyone: everyone
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status === 409) {
|
||||||
|
throw new TopicReservedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteReservation(topic) {
|
||||||
|
const url = accountReservationSingleUrl(config.base_url, topic);
|
||||||
|
console.log(`[AccountApi] Removing topic reservation ${url}`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async billingTiers() {
|
||||||
|
const url = tiersUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Fetching billing tiers`);
|
||||||
|
const response = await fetch(url); // No auth needed!
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBillingSubscription(tier) {
|
||||||
|
console.log(`[AccountApi] Creating billing subscription with ${tier}`);
|
||||||
|
return await this.upsertBillingSubscription("POST", tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBillingSubscription(tier) {
|
||||||
|
console.log(`[AccountApi] Updating billing subscription with ${tier}`);
|
||||||
|
return await this.upsertBillingSubscription("PUT", tier)
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertBillingSubscription(method, tier) {
|
||||||
|
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: withBearerAuth({}, session.token()),
|
||||||
|
body: JSON.stringify({
|
||||||
|
tier: tier
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBillingSubscription() {
|
||||||
|
const url = accountBillingSubscriptionUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Cancelling billing subscription`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBillingPortalSession() {
|
||||||
|
const url = accountBillingPortalUrl(config.base_url);
|
||||||
|
console.log(`[AccountApi] Creating billing portal session`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: withBearerAuth({}, session.token())
|
||||||
|
});
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new UnauthorizedError();
|
||||||
|
} else if (response.status !== 200) {
|
||||||
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync() {
|
||||||
|
try {
|
||||||
|
if (!session.token()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.log(`[AccountApi] Syncing account`);
|
||||||
|
const account = await this.get();
|
||||||
|
if (account.language) {
|
||||||
|
await i18n.changeLanguage(account.language);
|
||||||
|
}
|
||||||
|
if (account.notification) {
|
||||||
|
if (account.notification.sound) {
|
||||||
|
await prefs.setSound(account.notification.sound);
|
||||||
|
}
|
||||||
|
if (account.notification.delete_after) {
|
||||||
|
await prefs.setDeleteAfter(account.notification.delete_after);
|
||||||
|
}
|
||||||
|
if (account.notification.min_priority) {
|
||||||
|
await prefs.setMinPriority(account.notification.min_priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (account.subscriptions) {
|
||||||
|
await subscriptionManager.syncFromRemote(account.subscriptions, account.reservations);
|
||||||
|
}
|
||||||
|
return account;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[AccountApi] Error fetching account`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startWorker() {
|
||||||
|
if (this.timer !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[AccountApi] Starting worker`);
|
||||||
|
this.timer = setInterval(() => this.runWorker(), intervalMillis);
|
||||||
|
setTimeout(() => this.runWorker(), delayMillis);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runWorker() {
|
||||||
|
if (!session.token()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[AccountApi] Extending user access token`);
|
||||||
|
try {
|
||||||
|
await this.extendToken();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[AccountApi] Error extending user access token`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UsernameTakenError extends Error {
|
||||||
|
constructor(username) {
|
||||||
|
super("Username taken");
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TopicReservedError extends Error {
|
||||||
|
constructor(topic) {
|
||||||
|
super("Topic already reserved");
|
||||||
|
this.topic = topic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AccountCreateLimitReachedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Account creation limit reached");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Unauthorized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountApi = new AccountApi();
|
||||||
|
export default accountApi;
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
fetchLinesIterator,
|
fetchLinesIterator,
|
||||||
maybeWithBasicAuth,
|
maybeWithAuth,
|
||||||
topicShortUrl,
|
topicShortUrl,
|
||||||
topicUrl,
|
topicUrl,
|
||||||
topicUrlAuth,
|
topicUrlAuth,
|
||||||
topicUrlJsonPoll,
|
topicUrlJsonPoll,
|
||||||
topicUrlJsonPollWithSince,
|
topicUrlJsonPollWithSince
|
||||||
userStatsUrl
|
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import userManager from "./UserManager";
|
import userManager from "./UserManager";
|
||||||
|
|
||||||
@@ -18,7 +17,7 @@ class Api {
|
|||||||
? topicUrlJsonPollWithSince(baseUrl, topic, since)
|
? topicUrlJsonPollWithSince(baseUrl, topic, since)
|
||||||
: topicUrlJsonPoll(baseUrl, topic);
|
: topicUrlJsonPoll(baseUrl, topic);
|
||||||
const messages = [];
|
const messages = [];
|
||||||
const headers = maybeWithBasicAuth({}, user);
|
const headers = maybeWithAuth({}, user);
|
||||||
console.log(`[Api] Polling ${url}`);
|
console.log(`[Api] Polling ${url}`);
|
||||||
for await (let line of fetchLinesIterator(url, headers)) {
|
for await (let line of fetchLinesIterator(url, headers)) {
|
||||||
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
||||||
@@ -39,7 +38,7 @@ class Api {
|
|||||||
const response = await fetch(baseUrl, {
|
const response = await fetch(baseUrl, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
headers: maybeWithBasicAuth(headers, user)
|
headers: maybeWithAuth(headers, user)
|
||||||
});
|
});
|
||||||
if (response.status < 200 || response.status > 299) {
|
if (response.status < 200 || response.status > 299) {
|
||||||
throw new Error(`Unexpected response: ${response.status}`);
|
throw new Error(`Unexpected response: ${response.status}`);
|
||||||
@@ -72,7 +71,7 @@ class Api {
|
|||||||
xhr.setRequestHeader(key, value);
|
xhr.setRequestHeader(key, value);
|
||||||
}
|
}
|
||||||
xhr.upload.addEventListener("progress", onProgress);
|
xhr.upload.addEventListener("progress", onProgress);
|
||||||
xhr.addEventListener('readystatechange', (ev) => {
|
xhr.addEventListener('readystatechange', () => {
|
||||||
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status <= 299) {
|
||||||
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
|
console.log(`[Api] Publish successful (HTTP ${xhr.status})`, xhr.response);
|
||||||
resolve(xhr.response);
|
resolve(xhr.response);
|
||||||
@@ -101,11 +100,11 @@ class Api {
|
|||||||
return send;
|
return send;
|
||||||
}
|
}
|
||||||
|
|
||||||
async auth(baseUrl, topic, user) {
|
async topicAuth(baseUrl, topic, user) {
|
||||||
const url = topicUrlAuth(baseUrl, topic);
|
const url = topicUrlAuth(baseUrl, topic);
|
||||||
console.log(`[Api] Checking auth for ${url}`);
|
console.log(`[Api] Checking auth for ${url}`);
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: maybeWithBasicAuth({}, user)
|
headers: maybeWithAuth({}, user)
|
||||||
});
|
});
|
||||||
if (response.status >= 200 && response.status <= 299) {
|
if (response.status >= 200 && response.status <= 299) {
|
||||||
return true;
|
return true;
|
||||||
@@ -116,18 +115,6 @@ class Api {
|
|||||||
}
|
}
|
||||||
throw new Error(`Unexpected server response ${response.status}`);
|
throw new Error(`Unexpected server response ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async userStats(baseUrl) {
|
|
||||||
const url = userStatsUrl(baseUrl);
|
|
||||||
console.log(`[Api] Fetching user stats ${url}`);
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error(`Unexpected server response ${response.status}`);
|
|
||||||
}
|
|
||||||
const stats = await response.json();
|
|
||||||
console.log(`[Api] Stats`, stats);
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = new Api();
|
const api = new Api();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
|
import {basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
|
||||||
|
|
||||||
const retryBackoffSeconds = [5, 10, 15, 20, 30];
|
const retryBackoffSeconds = [5, 10, 15, 20, 30];
|
||||||
|
|
||||||
@@ -96,12 +96,18 @@ class Connection {
|
|||||||
params.push(`since=${this.since}`);
|
params.push(`since=${this.since}`);
|
||||||
}
|
}
|
||||||
if (this.user) {
|
if (this.user) {
|
||||||
const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password));
|
params.push(`auth=${this.authParam()}`);
|
||||||
params.push(`auth=${auth}`);
|
|
||||||
}
|
}
|
||||||
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
||||||
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
|
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
authParam() {
|
||||||
|
if (this.user.password) {
|
||||||
|
return encodeBase64Url(basicAuth(this.user.username, this.user.password));
|
||||||
|
}
|
||||||
|
return encodeBase64Url(bearerAuth(this.user.token));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConnectionState {
|
export class ConnectionState {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ConnectionManager {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
|
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
|
||||||
this.stateListener = null; // Fired when connection state changes
|
this.stateListener = null; // Fired when connection state changes
|
||||||
this.notificationListener = null; // Fired when new notifications arrive
|
this.messageListener = null; // Fired when new notifications arrive
|
||||||
}
|
}
|
||||||
|
|
||||||
registerStateListener(listener) {
|
registerStateListener(listener) {
|
||||||
@@ -22,12 +22,12 @@ class ConnectionManager {
|
|||||||
this.stateListener = null;
|
this.stateListener = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
registerNotificationListener(listener) {
|
registerMessageListener(listener) {
|
||||||
this.notificationListener = listener;
|
this.messageListener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
resetNotificationListener() {
|
resetMessageListener() {
|
||||||
this.notificationListener = null;
|
this.messageListener = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,9 +97,9 @@ class ConnectionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
notificationReceived(subscriptionId, notification) {
|
notificationReceived(subscriptionId, notification) {
|
||||||
if (this.notificationListener) {
|
if (this.messageListener) {
|
||||||
try {
|
try {
|
||||||
this.notificationListener(subscriptionId, notification);
|
this.messageListener(subscriptionId, notification);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
|
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ class ConnectionManager {
|
|||||||
|
|
||||||
const makeConnectionId = async (subscription, user) => {
|
const makeConnectionId = async (subscription, user) => {
|
||||||
return (user)
|
return (user)
|
||||||
? hashCode(`${subscription.id}|${user.username}|${user.password}`)
|
? hashCode(`${subscription.id}|${user.username}|${user.password ?? ""}|${user.token ?? ""}`)
|
||||||
: hashCode(`${subscription.id}`);
|
: hashCode(`${subscription.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
web/src/app/Session.js
Normal file
31
web/src/app/Session.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class Session {
|
||||||
|
store(username, token) {
|
||||||
|
localStorage.setItem("user", username);
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAndRedirect(url) {
|
||||||
|
this.reset();
|
||||||
|
window.location.href = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
exists() {
|
||||||
|
return this.username() && this.token();
|
||||||
|
}
|
||||||
|
|
||||||
|
username() {
|
||||||
|
return localStorage.getItem("user");
|
||||||
|
}
|
||||||
|
|
||||||
|
token() {
|
||||||
|
return localStorage.getItem("token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = new Session();
|
||||||
|
export default session;
|
||||||
@@ -18,17 +18,52 @@ class SubscriptionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async add(baseUrl, topic) {
|
async add(baseUrl, topic) {
|
||||||
|
const id = topicUrl(baseUrl, topic);
|
||||||
|
const existingSubscription = await this.get(id);
|
||||||
|
if (existingSubscription) {
|
||||||
|
return existingSubscription;
|
||||||
|
}
|
||||||
const subscription = {
|
const subscription = {
|
||||||
id: topicUrl(baseUrl, topic),
|
id: topicUrl(baseUrl, topic),
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
topic: topic,
|
topic: topic,
|
||||||
mutedUntil: 0,
|
mutedUntil: 0,
|
||||||
last: null
|
last: null,
|
||||||
|
remoteId: null,
|
||||||
|
internal: false
|
||||||
};
|
};
|
||||||
await db.subscriptions.put(subscription);
|
await db.subscriptions.put(subscription);
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async syncFromRemote(remoteSubscriptions, remoteReservations) {
|
||||||
|
console.log(`[SubscriptionManager] Syncing subscriptions from remote`, remoteSubscriptions);
|
||||||
|
|
||||||
|
// Add remote subscriptions
|
||||||
|
let remoteIds = [];
|
||||||
|
for (let i = 0; i < remoteSubscriptions.length; i++) {
|
||||||
|
const remote = remoteSubscriptions[i];
|
||||||
|
const local = await this.add(remote.base_url, remote.topic);
|
||||||
|
const reservation = remoteReservations?.find(r => remote.base_url === config.base_url && remote.topic === r.topic) || null;
|
||||||
|
await this.update(local.id, {
|
||||||
|
remoteId: remote.id,
|
||||||
|
displayName: remote.display_name,
|
||||||
|
reservation: reservation // May be null!
|
||||||
|
});
|
||||||
|
remoteIds.push(remote.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove local subscriptions that do not exist remotely
|
||||||
|
const localSubscriptions = await db.subscriptions.toArray();
|
||||||
|
for (let i = 0; i < localSubscriptions.length; i++) {
|
||||||
|
const local = localSubscriptions[i];
|
||||||
|
const remoteExists = local.remoteId && remoteIds.includes(local.remoteId);
|
||||||
|
if (!local.internal && !remoteExists) {
|
||||||
|
await this.remove(local.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateState(subscriptionId, state) {
|
async updateState(subscriptionId, state) {
|
||||||
db.subscriptions.update(subscriptionId, { state: state });
|
db.subscriptions.update(subscriptionId, { state: state });
|
||||||
}
|
}
|
||||||
@@ -139,6 +174,22 @@ class SubscriptionManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setRemoteId(subscriptionId, remoteId) {
|
||||||
|
await db.subscriptions.update(subscriptionId, {
|
||||||
|
remoteId: remoteId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setReservation(subscriptionId, reservation) {
|
||||||
|
await db.subscriptions.update(subscriptionId, {
|
||||||
|
reservation: reservation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(subscriptionId, params) {
|
||||||
|
await db.subscriptions.update(subscriptionId, params);
|
||||||
|
}
|
||||||
|
|
||||||
async pruneNotifications(thresholdTimestamp) {
|
async pruneNotifications(thresholdTimestamp) {
|
||||||
await db.notifications
|
await db.notifications
|
||||||
.where("time").below(thresholdTimestamp)
|
.where("time").below(thresholdTimestamp)
|
||||||
|
|||||||
@@ -1,21 +1,46 @@
|
|||||||
import db from "./db";
|
import db from "./db";
|
||||||
|
import session from "./Session";
|
||||||
|
|
||||||
class UserManager {
|
class UserManager {
|
||||||
async all() {
|
async all() {
|
||||||
return db.users.toArray();
|
const users = await db.users.toArray();
|
||||||
|
if (session.exists()) {
|
||||||
|
users.unshift(this.localUser());
|
||||||
|
}
|
||||||
|
return users;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(baseUrl) {
|
async get(baseUrl) {
|
||||||
|
if (session.exists() && baseUrl === config.base_url) {
|
||||||
|
return this.localUser();
|
||||||
|
}
|
||||||
return db.users.get(baseUrl);
|
return db.users.get(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(user) {
|
async save(user) {
|
||||||
|
if (session.exists() && user.baseUrl === config.base_url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await db.users.put(user);
|
await db.users.put(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(baseUrl) {
|
async delete(baseUrl) {
|
||||||
|
if (session.exists() && baseUrl === config.base_url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await db.users.delete(baseUrl);
|
await db.users.delete(baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
localUser() {
|
||||||
|
if (!session.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseUrl: config.base_url,
|
||||||
|
username: session.username(),
|
||||||
|
token: session.token() // Not "password"!
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userManager = new UserManager();
|
const userManager = new UserManager();
|
||||||
|
|||||||
@@ -1,2 +1,9 @@
|
|||||||
const config = window.config;
|
const config = window.config;
|
||||||
|
|
||||||
|
// The backend returns an empty base_url for the config struct,
|
||||||
|
// so the frontend (hey, that's us!) can use the current location.
|
||||||
|
if (!config.base_url || config.base_url === "") {
|
||||||
|
config.base_url = window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Dexie from 'dexie';
|
import Dexie from 'dexie';
|
||||||
|
import session from "./Session";
|
||||||
|
|
||||||
// Uses Dexie.js
|
// Uses Dexie.js
|
||||||
// https://dexie.org/docs/API-Reference#quick-reference
|
// https://dexie.org/docs/API-Reference#quick-reference
|
||||||
@@ -6,7 +7,9 @@ import Dexie from 'dexie';
|
|||||||
// Notes:
|
// Notes:
|
||||||
// - As per docs, we only declare the indexable columns, not all columns
|
// - As per docs, we only declare the indexable columns, not all columns
|
||||||
|
|
||||||
const db = new Dexie('ntfy');
|
// The IndexedDB database name is based on the logged-in user
|
||||||
|
const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy";
|
||||||
|
const db = new Dexie(dbName);
|
||||||
|
|
||||||
db.version(1).stores({
|
db.version(1).stores({
|
||||||
subscriptions: '&id,baseUrl',
|
subscriptions: '&id,baseUrl',
|
||||||
|
|||||||
@@ -18,7 +18,17 @@ export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, top
|
|||||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
||||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
||||||
export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
|
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
|
||||||
|
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
|
||||||
|
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
|
||||||
|
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
|
||||||
|
export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
|
||||||
|
export const accountSubscriptionSingleUrl = (baseUrl, id) => `${baseUrl}/v1/account/subscription/${id}`;
|
||||||
|
export const accountReservationUrl = (baseUrl) => `${baseUrl}/v1/account/reservation`;
|
||||||
|
export const accountReservationSingleUrl = (baseUrl, topic) => `${baseUrl}/v1/account/reservation/${topic}`;
|
||||||
|
export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/billing/subscription`;
|
||||||
|
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
|
||||||
|
export const tiersUrl = (baseUrl) => `${baseUrl}/v1/tiers`;
|
||||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||||
export const expandSecureUrl = (url) => `https://${url}`;
|
export const expandSecureUrl = (url) => `https://${url}`;
|
||||||
@@ -35,13 +45,13 @@ export const validTopic = (topic) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const disallowedTopic = (topic) => {
|
export const disallowedTopic = (topic) => {
|
||||||
return config.disallowedTopics.includes(topic);
|
return config.disallowed_topics.includes(topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const topicDisplayName = (subscription) => {
|
export const topicDisplayName = (subscription) => {
|
||||||
if (subscription.displayName) {
|
if (subscription.displayName) {
|
||||||
return subscription.displayName;
|
return subscription.displayName;
|
||||||
} else if (subscription.baseUrl === window.location.origin) {
|
} else if (subscription.baseUrl === config.base_url) {
|
||||||
return subscription.topic;
|
return subscription.topic;
|
||||||
}
|
}
|
||||||
return topicShortUrl(subscription.baseUrl, subscription.topic);
|
return topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||||
@@ -94,17 +104,33 @@ export const unmatchedTags = (tags) => {
|
|||||||
else return tags.filter(tag => !(tag in emojis));
|
else return tags.filter(tag => !(tag in emojis));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const maybeWithBasicAuth = (headers, user) => {
|
export const maybeWithAuth = (headers, user) => {
|
||||||
if (user) {
|
if (user && user.password) {
|
||||||
headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`;
|
return withBasicAuth(headers, user.username, user.password);
|
||||||
|
} else if (user && user.token) {
|
||||||
|
return withBearerAuth(headers, user.token);
|
||||||
}
|
}
|
||||||
return headers;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const withBasicAuth = (headers, username, password) => {
|
||||||
|
headers['Authorization'] = basicAuth(username, password);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
export const basicAuth = (username, password) => {
|
export const basicAuth = (username, password) => {
|
||||||
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const withBearerAuth = (headers, token) => {
|
||||||
|
headers['Authorization'] = bearerAuth(token);
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bearerAuth = (token) => {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const encodeBase64 = (s) => {
|
export const encodeBase64 = (s) => {
|
||||||
return Base64.encode(s);
|
return Base64.encode(s);
|
||||||
}
|
}
|
||||||
@@ -159,6 +185,11 @@ export const formatShortDateTime = (timestamp) => {
|
|||||||
.format(new Date(timestamp * 1000));
|
.format(new Date(timestamp * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatShortDate = (timestamp) => {
|
||||||
|
return new Intl.DateTimeFormat('default', {dateStyle: 'short'})
|
||||||
|
.format(new Date(timestamp * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
export const formatBytes = (bytes, decimals = 2) => {
|
export const formatBytes = (bytes, decimals = 2) => {
|
||||||
if (bytes === 0) return '0 bytes';
|
if (bytes === 0) return '0 bytes';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -168,6 +199,13 @@ export const formatBytes = (bytes, decimals = 2) => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatNumber = (n) => {
|
||||||
|
if (n % 1000 === 0) {
|
||||||
|
return `${n/1000}k`;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
export const openUrl = (url) => {
|
export const openUrl = (url) => {
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
};
|
};
|
||||||
|
|||||||
471
web/src/components/Account.js
Normal file
471
web/src/components/Account.js
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {useContext, useState} from 'react';
|
||||||
|
import {Alert, LinearProgress, Stack, useMediaQuery} from "@mui/material";
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import Container from "@mui/material/Container";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import theme from "./theme";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import routes from "./routes";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import {formatBytes, formatShortDate, formatShortDateTime} from "../app/utils";
|
||||||
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import {Pref, PrefGroup} from "./Pref";
|
||||||
|
import db from "../app/db";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import humanizeDuration from "humanize-duration";
|
||||||
|
import UpgradeDialog from "./UpgradeDialog";
|
||||||
|
import CelebrationIcon from "@mui/icons-material/Celebration";
|
||||||
|
import {AccountContext} from "./App";
|
||||||
|
import {Warning, WarningAmber} from "@mui/icons-material";
|
||||||
|
|
||||||
|
const Account = () => {
|
||||||
|
if (!session.exists()) {
|
||||||
|
window.location.href = routes.app;
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Basics/>
|
||||||
|
<Stats/>
|
||||||
|
<Delete/>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Basics = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Card sx={{p: 3}} aria-label={t("account_basics_title")}>
|
||||||
|
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||||
|
{t("account_basics_title")}
|
||||||
|
</Typography>
|
||||||
|
<PrefGroup>
|
||||||
|
<Username/>
|
||||||
|
<ChangePassword/>
|
||||||
|
<AccountType/>
|
||||||
|
</PrefGroup>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Username = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
const labelId = "prefUsername";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pref labelId={labelId} title={t("account_basics_username_title")} description={t("account_basics_username_description")}>
|
||||||
|
<div aria-labelledby={labelId}>
|
||||||
|
{session.username()}
|
||||||
|
{account?.role === "admin"
|
||||||
|
? <>{" "}<Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></>
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</Pref>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChangePassword = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const labelId = "prefChangePassword";
|
||||||
|
|
||||||
|
const handleDialogOpen = () => {
|
||||||
|
setDialogKey(prev => prev+1);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogCancel = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogSubmit = async (newPassword) => {
|
||||||
|
try {
|
||||||
|
await accountApi.changePassword(newPassword);
|
||||||
|
setDialogOpen(false);
|
||||||
|
console.debug(`[Account] Password changed`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error changing password`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
// TODO show error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pref labelId={labelId} title={t("account_basics_password_title")} description={t("account_basics_password_description")}>
|
||||||
|
<div aria-labelledby={labelId}>
|
||||||
|
<Typography color="gray" sx={{float: "left", fontSize: "0.7rem", lineHeight: "3.5"}}>⬤⬤⬤⬤⬤⬤⬤⬤⬤⬤</Typography>
|
||||||
|
<IconButton onClick={handleDialogOpen} aria-label={t("account_basics_password_description")}>
|
||||||
|
<EditIcon/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<ChangePasswordDialog
|
||||||
|
key={`changePasswordDialog${dialogKey}`}
|
||||||
|
open={dialogOpen}
|
||||||
|
onCancel={handleDialogCancel}
|
||||||
|
onSubmit={handleDialogSubmit}
|
||||||
|
/>
|
||||||
|
</Pref>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChangePasswordDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const changeButtonEnabled = (() => {
|
||||||
|
return newPassword.length > 0 && newPassword === confirmPassword;
|
||||||
|
})();
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{t("account_basics_password_dialog_title")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="new-password"
|
||||||
|
label={t("account_basics_password_dialog_new_password_label")}
|
||||||
|
aria-label={t("account_basics_password_dialog_new_password_label")}
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={ev => setNewPassword(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="confirm"
|
||||||
|
label={t("account_basics_password_dialog_confirm_password_label")}
|
||||||
|
aria-label={t("account_basics_password_dialog_confirm_password_label")}
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={ev => setConfirmPassword(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>{t("account_basics_password_dialog_button_cancel")}</Button>
|
||||||
|
<Button onClick={() => props.onSubmit(newPassword)} disabled={!changeButtonEnabled}>{t("account_basics_password_dialog_button_submit")}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountType = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
const [upgradeDialogKey, setUpgradeDialogKey] = useState(0);
|
||||||
|
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpgradeClick = () => {
|
||||||
|
setUpgradeDialogKey(k => k + 1);
|
||||||
|
setUpgradeDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManageBilling = async () => {
|
||||||
|
try {
|
||||||
|
const response = await accountApi.createBillingPortalSession();
|
||||||
|
window.open(response.redirect_url, "billing_portal");
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error changing password`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
// TODO show error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let accountType;
|
||||||
|
if (account.role === "admin") {
|
||||||
|
const tierSuffix = (account.tier) ? `(with ${account.tier.name} tier)` : `(no tier)`;
|
||||||
|
accountType = `${t("account_usage_tier_admin")} ${tierSuffix}`;
|
||||||
|
} else if (!account.tier) {
|
||||||
|
accountType = (config.enable_payments) ? t("account_usage_tier_free") : t("account_usage_tier_basic");
|
||||||
|
} else {
|
||||||
|
accountType = account.tier.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pref
|
||||||
|
alignTop={account.billing?.status === "past_due" || account.billing?.cancel_at > 0}
|
||||||
|
title={t("account_usage_tier_title")}
|
||||||
|
description={t("account_usage_tier_description")}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{accountType}
|
||||||
|
{account.billing?.paid_until && !account.billing?.cancel_at &&
|
||||||
|
<Tooltip title={t("account_usage_tier_paid_until", { date: formatShortDate(account.billing?.paid_until) })}>
|
||||||
|
<span><InfoIcon/></span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
{config.enable_payments && account.role === "user" && !account.billing?.subscription &&
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<CelebrationIcon sx={{ color: "#55b86e" }}/>}
|
||||||
|
onClick={handleUpgradeClick}
|
||||||
|
sx={{ml: 1}}
|
||||||
|
>{t("account_usage_tier_upgrade_button")}</Button>
|
||||||
|
}
|
||||||
|
{config.enable_payments && account.role === "user" && account.billing?.subscription &&
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={handleUpgradeClick}
|
||||||
|
sx={{ml: 1}}
|
||||||
|
>{t("account_usage_tier_change_button")}</Button>
|
||||||
|
}
|
||||||
|
{config.enable_payments && account.role === "user" && account.billing?.customer &&
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={handleManageBilling}
|
||||||
|
sx={{ml: 1}}
|
||||||
|
>{t("account_usage_manage_billing_button")}</Button>
|
||||||
|
}
|
||||||
|
<UpgradeDialog
|
||||||
|
key={`upgradeDialogFromAccount${upgradeDialogKey}`}
|
||||||
|
open={upgradeDialogOpen}
|
||||||
|
onCancel={() => setUpgradeDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{account.billing?.status === "past_due" &&
|
||||||
|
<Alert severity="error" sx={{mt: 1}}>{t("account_usage_tier_payment_overdue")}</Alert>
|
||||||
|
}
|
||||||
|
{account.billing?.cancel_at > 0 &&
|
||||||
|
<Alert severity="warning" sx={{mt: 1}}>{t("account_usage_tier_canceled_subscription", { date: formatShortDate(account.billing.cancel_at) })}</Alert>
|
||||||
|
}
|
||||||
|
</Pref>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stats = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalize = (value, max) => {
|
||||||
|
return Math.min(value / max * 100, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{p: 3}} aria-label={t("account_usage_title")}>
|
||||||
|
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||||
|
{t("account_usage_title")}
|
||||||
|
</Typography>
|
||||||
|
<PrefGroup>
|
||||||
|
{account.role !== "admin" &&
|
||||||
|
<Pref title={t("account_usage_reservations_title")}>
|
||||||
|
{account.limits.reservations > 0 &&
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Typography variant="body2"
|
||||||
|
sx={{float: "left"}}>{account.stats.reservations}</Typography>
|
||||||
|
<Typography variant="body2"
|
||||||
|
sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", {limit: account.limits.reservations}) : t("account_usage_unlimited")}</Typography>
|
||||||
|
</div>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={account.limits.reservations > 0 ? normalize(account.stats.reservations, account.limits.reservations) : 100}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{account.limits.reservations === 0 &&
|
||||||
|
<em>No reserved topics for this account</em>
|
||||||
|
}
|
||||||
|
</Pref>
|
||||||
|
}
|
||||||
|
<Pref title={
|
||||||
|
<>
|
||||||
|
{t("account_usage_messages_title")}
|
||||||
|
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<div>
|
||||||
|
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
|
||||||
|
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
|
||||||
|
</div>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={account.role === "user" ? normalize(account.stats.messages, account.limits.messages) : 100}
|
||||||
|
/>
|
||||||
|
</Pref>
|
||||||
|
<Pref title={
|
||||||
|
<>
|
||||||
|
{t("account_usage_emails_title")}
|
||||||
|
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
<div>
|
||||||
|
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography>
|
||||||
|
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography>
|
||||||
|
</div>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={account.role === "user" ? normalize(account.stats.emails, account.limits.emails) : 100}
|
||||||
|
/>
|
||||||
|
</Pref>
|
||||||
|
<Pref
|
||||||
|
alignTop
|
||||||
|
title={t("account_usage_attachment_storage_title")}
|
||||||
|
description={t("account_usage_attachment_storage_description", {
|
||||||
|
filesize: formatBytes(account.limits.attachment_file_size),
|
||||||
|
expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, {
|
||||||
|
language: i18n.language,
|
||||||
|
fallbacks: ["en"]
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography>
|
||||||
|
<Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography>
|
||||||
|
</div>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={account.role === "user" ? normalize(account.stats.attachment_total_size, account.limits.attachment_total_size) : 100}
|
||||||
|
/>
|
||||||
|
</Pref>
|
||||||
|
</PrefGroup>
|
||||||
|
{account.role === "user" && account.limits.basis === "ip" &&
|
||||||
|
<Typography variant="body1">
|
||||||
|
{t("account_usage_basis_ip_description")}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const InfoIcon = () => {
|
||||||
|
return (
|
||||||
|
<InfoOutlinedIcon sx={{
|
||||||
|
verticalAlign: "middle",
|
||||||
|
width: "18px",
|
||||||
|
marginLeft: "4px",
|
||||||
|
color: "gray"
|
||||||
|
}}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Delete = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Card sx={{p: 3}} aria-label={t("account_delete_title")}>
|
||||||
|
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||||
|
{t("account_delete_title")}
|
||||||
|
</Typography>
|
||||||
|
<PrefGroup>
|
||||||
|
<DeleteAccount/>
|
||||||
|
</PrefGroup>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteAccount = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDialogOpen = () => {
|
||||||
|
setDialogKey(prev => prev+1);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogCancel = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogSubmit = async () => {
|
||||||
|
try {
|
||||||
|
await accountApi.delete();
|
||||||
|
await db.delete();
|
||||||
|
setDialogOpen(false);
|
||||||
|
console.debug(`[Account] Account deleted`);
|
||||||
|
session.resetAndRedirect(routes.app);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Account] Error deleting account`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
// TODO show error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pref title={t("account_delete_title")} description={t("account_delete_description")}>
|
||||||
|
<div>
|
||||||
|
<Button fullWidth={false} variant="outlined" color="error" startIcon={<DeleteOutlineIcon />} onClick={handleDialogOpen}>
|
||||||
|
{t("account_delete_title")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DeleteAccountDialog
|
||||||
|
key={`deleteAccountDialog${dialogKey}`}
|
||||||
|
open={dialogOpen}
|
||||||
|
onCancel={handleDialogCancel}
|
||||||
|
onSubmit={handleDialogSubmit}
|
||||||
|
/>
|
||||||
|
</Pref>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const DeleteAccountDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const buttonEnabled = username === session.username();
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{t("account_delete_title")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1">
|
||||||
|
{t("account_delete_dialog_description", { username: session.username()})}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="account-delete-confirm"
|
||||||
|
label={t("account_delete_dialog_label", { username: session.username()})}
|
||||||
|
aria-label={t("account_delete_dialog_label", { username: session.username()})}
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={ev => setUsername(ev.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>
|
||||||
|
{account?.billing?.subscription &&
|
||||||
|
<Alert severity="warning" sx={{mt: 1}}>{t("account_delete_dialog_billing_warning")}</Alert>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>{t("account_delete_dialog_button_cancel")}</Button>
|
||||||
|
<Button onClick={props.onSubmit} color="error" disabled={!buttonEnabled}>{t("account_delete_dialog_button_submit")}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Account;
|
||||||
@@ -5,16 +5,12 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import MenuIcon from "@mui/icons-material/Menu";
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {useEffect, useRef, useState} from "react";
|
import {useState} from "react";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import {formatShortDateTime, shuffle, topicDisplayName, topicShortUrl} from "../app/utils";
|
import {formatShortDateTime, shuffle, topicDisplayName} from "../app/utils";
|
||||||
|
import db from "../app/db";
|
||||||
import {useLocation, useNavigate} from "react-router-dom";
|
import {useLocation, useNavigate} from "react-router-dom";
|
||||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
|
||||||
import Grow from '@mui/material/Grow';
|
|
||||||
import Paper from '@mui/material/Paper';
|
|
||||||
import Popper from '@mui/material/Popper';
|
|
||||||
import MenuItem from '@mui/material/MenuItem';
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
import MenuList from '@mui/material/MenuList';
|
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||||
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
|
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
|
||||||
@@ -25,6 +21,14 @@ import logo from "../img/ntfy.svg";
|
|||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
import {Portal, Snackbar} from "@mui/material";
|
import {Portal, Snackbar} from "@mui/material";
|
||||||
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
|
import SubscriptionSettingsDialog from "./SubscriptionSettingsDialog";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import {Logout, Person, Settings} from "@mui/icons-material";
|
||||||
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import PopupMenu from "./PopupMenu";
|
||||||
|
|
||||||
const ActionBar = (props) => {
|
const ActionBar = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,8 +36,10 @@ const ActionBar = (props) => {
|
|||||||
let title = "ntfy";
|
let title = "ntfy";
|
||||||
if (props.selected) {
|
if (props.selected) {
|
||||||
title = topicDisplayName(props.selected);
|
title = topicDisplayName(props.selected);
|
||||||
} else if (location.pathname === "/settings") {
|
} else if (location.pathname === routes.settings) {
|
||||||
title = t("action_bar_settings");
|
title = t("action_bar_settings");
|
||||||
|
} else if (location.pathname === routes.account) {
|
||||||
|
title = t("action_bar_account");
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AppBar position="fixed" sx={{
|
<AppBar position="fixed" sx={{
|
||||||
@@ -41,7 +47,10 @@ const ActionBar = (props) => {
|
|||||||
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
|
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
|
||||||
ml: { sm: `${Navigation.width}px` }
|
ml: { sm: `${Navigation.width}px` }
|
||||||
}}>
|
}}>
|
||||||
<Toolbar sx={{pr: '24px'}}>
|
<Toolbar sx={{
|
||||||
|
pr: '24px',
|
||||||
|
background: "linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%)"
|
||||||
|
}}>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
edge="start"
|
edge="start"
|
||||||
@@ -69,23 +78,23 @@ const ActionBar = (props) => {
|
|||||||
subscription={props.selected}
|
subscription={props.selected}
|
||||||
onUnsubscribe={props.onUnsubscribe}
|
onUnsubscribe={props.onUnsubscribe}
|
||||||
/>}
|
/>}
|
||||||
|
<ProfileIcon/>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Originally from https://mui.com/components/menus/#MenuListComposition.js
|
|
||||||
const SettingsIcons = (props) => {
|
const SettingsIcons = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
const [snackOpen, setSnackOpen] = useState(false);
|
const [snackOpen, setSnackOpen] = useState(false);
|
||||||
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] = useState(false);
|
const [subscriptionSettingsOpen, setSubscriptionSettingsOpen] = useState(false);
|
||||||
const anchorRef = useRef(null);
|
|
||||||
const subscription = props.subscription;
|
const subscription = props.subscription;
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
|
||||||
const handleToggleOpen = () => {
|
const handleToggleOpen = (event) => {
|
||||||
setOpen((prevOpen) => !prevOpen);
|
setAnchorEl(event.currentTarget);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleMute = async () => {
|
const handleToggleMute = async () => {
|
||||||
@@ -93,28 +102,33 @@ const SettingsIcons = (props) => {
|
|||||||
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
|
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = (event) => {
|
const handleClose = () => {
|
||||||
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
setAnchorEl(null);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = async (event) => {
|
const handleClearAll = async (event) => {
|
||||||
handleClose(event);
|
|
||||||
console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`);
|
console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`);
|
||||||
await subscriptionManager.deleteNotifications(props.subscription.id);
|
await subscriptionManager.deleteNotifications(props.subscription.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnsubscribe = async (event) => {
|
const handleUnsubscribe = async (event) => {
|
||||||
console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`);
|
console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`, props.subscription);
|
||||||
handleClose(event);
|
|
||||||
await subscriptionManager.remove(props.subscription.id);
|
await subscriptionManager.remove(props.subscription.id);
|
||||||
|
if (session.exists() && props.subscription.remoteId) {
|
||||||
|
try {
|
||||||
|
await accountApi.deleteSubscription(props.subscription.remoteId);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[ActionBar] Error unsubscribing`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const newSelected = await subscriptionManager.first(); // May be undefined
|
const newSelected = await subscriptionManager.first(); // May be undefined
|
||||||
if (newSelected) {
|
if (newSelected) {
|
||||||
navigate(routes.forSubscription(newSelected));
|
navigate(routes.forSubscription(newSelected));
|
||||||
} else {
|
} else {
|
||||||
navigate(routes.root);
|
navigate(routes.app);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -162,61 +176,27 @@ const SettingsIcons = (props) => {
|
|||||||
console.log(`[ActionBar] Error publishing message`, e);
|
console.log(`[ActionBar] Error publishing message`, e);
|
||||||
setSnackOpen(true);
|
setSnackOpen(true);
|
||||||
}
|
}
|
||||||
setOpen(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleListKeyDown = (event) => {
|
|
||||||
if (event.key === 'Tab') {
|
|
||||||
event.preventDefault();
|
|
||||||
setOpen(false);
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// return focus to the button when we transitioned from !open -> open
|
|
||||||
const prevOpen = useRef(open);
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevOpen.current === true && open === false) {
|
|
||||||
anchorRef.current.focus();
|
|
||||||
}
|
|
||||||
prevOpen.current = open;
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}} aria-label={t("action_bar_toggle_mute")}>
|
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} aria-label={t("action_bar_toggle_mute")}>
|
||||||
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
|
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
|
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleOpen} aria-label={t("action_bar_toggle_action_menu")}>
|
||||||
<MoreVertIcon/>
|
<MoreVertIcon/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Popper
|
<PopupMenu
|
||||||
|
horizontal="right"
|
||||||
|
anchorEl={anchorEl}
|
||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorRef.current}
|
onClose={handleClose}
|
||||||
role={undefined}
|
|
||||||
placement="bottom-start"
|
|
||||||
transition
|
|
||||||
disablePortal
|
|
||||||
>
|
>
|
||||||
{({TransitionProps, placement}) => (
|
<MenuItem onClick={handleSubscriptionSettings}>{t("action_bar_subscription_settings")}</MenuItem>
|
||||||
<Grow
|
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
|
||||||
{...TransitionProps}
|
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
|
||||||
style={{transformOrigin: placement === 'bottom-start' ? 'left top' : 'left bottom'}}
|
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
|
||||||
>
|
</PopupMenu>
|
||||||
<Paper>
|
|
||||||
<ClickAwayListener onClickAway={handleClose}>
|
|
||||||
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
|
|
||||||
<MenuItem onClick={handleSubscriptionSettings}>{t("action_bar_subscription_settings")}</MenuItem>
|
|
||||||
<MenuItem onClick={handleSendTestMessage}>{t("action_bar_send_test_notification")}</MenuItem>
|
|
||||||
<MenuItem onClick={handleClearAll}>{t("action_bar_clear_notifications")}</MenuItem>
|
|
||||||
<MenuItem onClick={handleUnsubscribe}>{t("action_bar_unsubscribe")}</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</ClickAwayListener>
|
|
||||||
</Paper>
|
|
||||||
</Grow>
|
|
||||||
)}
|
|
||||||
</Popper>
|
|
||||||
<Portal>
|
<Portal>
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={snackOpen}
|
open={snackOpen}
|
||||||
@@ -237,4 +217,74 @@ const SettingsIcons = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ProfileIcon = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleClick = (event) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await accountApi.logout();
|
||||||
|
await db.delete();
|
||||||
|
} finally {
|
||||||
|
session.resetAndRedirect(routes.app);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{session.exists() &&
|
||||||
|
<IconButton color="inherit" size="large" edge="end" onClick={handleClick} aria-label={t("action_bar_profile_title")}>
|
||||||
|
<AccountCircleIcon/>
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
{!session.exists() && config.enable_login &&
|
||||||
|
<Button color="inherit" variant="text" onClick={() => navigate(routes.login)} sx={{m: 1}} aria-label={t("action_bar_sign_in")}>
|
||||||
|
{t("action_bar_sign_in")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
{!session.exists() && config.enable_signup &&
|
||||||
|
<Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)} aria-label={t("action_bar_sign_up")}>
|
||||||
|
{t("action_bar_sign_up")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
<PopupMenu
|
||||||
|
horizontal="right"
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => navigate(routes.account)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Person />
|
||||||
|
</ListItemIcon>
|
||||||
|
<b>{session.username()}</b>
|
||||||
|
</MenuItem>
|
||||||
|
<Divider />
|
||||||
|
<MenuItem onClick={() => navigate(routes.settings)}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Settings fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
{t("action_bar_profile_settings")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={handleLogout}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Logout fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
{t("action_bar_profile_logout")}
|
||||||
|
</MenuItem>
|
||||||
|
</PopupMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ActionBar;
|
export default ActionBar;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Suspense } from "react";
|
import {createContext, Suspense, useContext, useEffect, useState} from 'react';
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import {ThemeProvider} from '@mui/material/styles';
|
import {ThemeProvider} from '@mui/material/styles';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import Toolbar from '@mui/material/Toolbar';
|
import Toolbar from '@mui/material/Toolbar';
|
||||||
import Notifications from "./Notifications";
|
import {AllSubscriptions, SingleSubscription} from "./Notifications";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import Navigation from "./Navigation";
|
import Navigation from "./Navigation";
|
||||||
import ActionBar from "./ActionBar";
|
import ActionBar from "./ActionBar";
|
||||||
@@ -14,78 +13,96 @@ import Preferences from "./Preferences";
|
|||||||
import {useLiveQuery} from "dexie-react-hooks";
|
import {useLiveQuery} from "dexie-react-hooks";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from "react-router-dom";
|
import {BrowserRouter, Outlet, Route, Routes, useParams} from "react-router-dom";
|
||||||
import {expandUrl} from "../app/utils";
|
import {expandUrl} from "../app/utils";
|
||||||
import ErrorBoundary from "./ErrorBoundary";
|
import ErrorBoundary from "./ErrorBoundary";
|
||||||
import routes from "./routes";
|
import routes from "./routes";
|
||||||
import {useAutoSubscribe, useBackgroundProcesses, useConnectionListeners} from "./hooks";
|
import {useAccountListener, useBackgroundProcesses, useConnectionListeners} from "./hooks";
|
||||||
import PublishDialog from "./PublishDialog";
|
import PublishDialog from "./PublishDialog";
|
||||||
import Messaging from "./Messaging";
|
import Messaging from "./Messaging";
|
||||||
import "./i18n"; // Translations!
|
import "./i18n"; // Translations!
|
||||||
import {Backdrop, CircularProgress} from "@mui/material";
|
import {Backdrop, CircularProgress} from "@mui/material";
|
||||||
|
import Home from "./Home";
|
||||||
|
import Login from "./Login";
|
||||||
|
import Pricing from "./Pricing";
|
||||||
|
import Signup from "./Signup";
|
||||||
|
import Account from "./Account";
|
||||||
|
import accountApi from "../app/AccountApi";
|
||||||
|
|
||||||
// TODO races when two tabs are open
|
export const AccountContext = createContext(null);
|
||||||
// TODO investigate service workers
|
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
|
const [account, setAccount] = useState(null);
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<Loader />}>
|
<Suspense fallback={<Loader />}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline/>
|
<AccountContext.Provider value={{ account, setAccount }}>
|
||||||
<ErrorBoundary>
|
<CssBaseline/>
|
||||||
<Routes>
|
<ErrorBoundary>
|
||||||
<Route element={<Layout/>}>
|
<Routes>
|
||||||
<Route path={routes.root} element={<AllSubscriptions/>}/>
|
<Route path={routes.home} element={<Home/>}/>
|
||||||
<Route path={routes.settings} element={<Preferences/>}/>
|
<Route path={routes.pricing} element={<Pricing/>}/>
|
||||||
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
<Route path={routes.login} element={<Login/>}/>
|
||||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
<Route path={routes.signup} element={<Signup/>}/>
|
||||||
</Route>
|
<Route element={<Layout/>}>
|
||||||
</Routes>
|
<Route path={routes.app} element={<AllSubscriptions/>}/>
|
||||||
</ErrorBoundary>
|
<Route path={routes.account} element={<Account/>}/>
|
||||||
|
<Route path={routes.settings} element={<Preferences/>}/>
|
||||||
|
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
||||||
|
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</AccountContext.Provider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllSubscriptions = () => {
|
|
||||||
const { subscriptions } = useOutletContext();
|
|
||||||
return <Notifications mode="all" subscriptions={subscriptions}/>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SingleSubscription = () => {
|
|
||||||
const { subscriptions, selected } = useOutletContext();
|
|
||||||
useAutoSubscribe(subscriptions, selected);
|
|
||||||
return <Notifications mode="one" subscription={selected}/>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const { account, setAccount } = useContext(AccountContext);
|
||||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||||
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
|
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
|
||||||
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
const [sendDialogOpenMode, setSendDialogOpenMode] = useState("");
|
||||||
const users = useLiveQuery(() => userManager.all());
|
const users = useLiveQuery(() => userManager.all());
|
||||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||||
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
const subscriptionsWithoutInternal = subscriptions?.filter(s => !s.internal);
|
||||||
const [selected] = (subscriptions || []).filter(s => {
|
const newNotificationsCount = subscriptionsWithoutInternal?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||||
|
const [selected] = (subscriptionsWithoutInternal || []).filter(s => {
|
||||||
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic)
|
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic)
|
||||||
|| (window.location.origin === s.baseUrl && params.topic === s.topic)
|
|| (config.base_url === s.baseUrl && params.topic === s.topic)
|
||||||
});
|
});
|
||||||
|
|
||||||
useConnectionListeners(subscriptions, users);
|
useConnectionListeners(subscriptions, users);
|
||||||
|
useAccountListener(setAccount)
|
||||||
useBackgroundProcesses();
|
useBackgroundProcesses();
|
||||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!account || !account.sync_topic) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
const subscription = await subscriptionManager.add(config.base_url, account.sync_topic);
|
||||||
|
if (!subscription.hidden) {
|
||||||
|
await subscriptionManager.update(subscription.id, {
|
||||||
|
internal: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [account]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{display: 'flex'}}>
|
<Box sx={{display: 'flex'}}>
|
||||||
<CssBaseline/>
|
|
||||||
<ActionBar
|
<ActionBar
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||||
/>
|
/>
|
||||||
<Navigation
|
<Navigation
|
||||||
subscriptions={subscriptions}
|
subscriptions={subscriptionsWithoutInternal}
|
||||||
selectedSubscription={selected}
|
selectedSubscription={selected}
|
||||||
notificationsGranted={notificationsGranted}
|
notificationsGranted={notificationsGranted}
|
||||||
mobileDrawerOpen={mobileDrawerOpen}
|
mobileDrawerOpen={mobileDrawerOpen}
|
||||||
@@ -95,7 +112,10 @@ const Layout = () => {
|
|||||||
/>
|
/>
|
||||||
<Main>
|
<Main>
|
||||||
<Toolbar/>
|
<Toolbar/>
|
||||||
<Outlet context={{ subscriptions, selected }}/>
|
<Outlet context={{
|
||||||
|
subscriptions: subscriptionsWithoutInternal,
|
||||||
|
selected: selected
|
||||||
|
}}/>
|
||||||
</Main>
|
</Main>
|
||||||
<Messaging
|
<Messaging
|
||||||
selected={selected}
|
selected={selected}
|
||||||
|
|||||||
29
web/src/components/AvatarBox.js
Normal file
29
web/src/components/AvatarBox.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {Avatar} from "@mui/material";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import logo from "../img/ntfy2.svg";
|
||||||
|
|
||||||
|
const AvatarBox = (props) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
|
||||||
|
src={logo}
|
||||||
|
variant="rounded"
|
||||||
|
/>
|
||||||
|
{props.children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AvatarBox;
|
||||||
152
web/src/components/Home.js
Normal file
152
web/src/components/Home.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import SiteLayout from "./SiteLayout";
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
<h1>Send push notifications to your phone or desktop via PUT/POST</h1>
|
||||||
|
<p>
|
||||||
|
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a
|
||||||
|
href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification
|
||||||
|
service.
|
||||||
|
It allows you to send notifications to your phone or desktop via scripts from any computer,
|
||||||
|
entirely <b>without signup, cost or setup</b>. It's also <a
|
||||||
|
href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
|
||||||
|
</p>
|
||||||
|
<div id="screenshots">
|
||||||
|
<a href="static/img/screenshot-curl.png"><img src="static/img/screenshot-curl.png"/></a>
|
||||||
|
<a href="static/img/screenshot-web-detail.png"><img src="static/img/screenshot-web-detail.png"/></a>
|
||||||
|
<span className="nowrap">
|
||||||
|
<a href="static/img/screenshot-phone-main.jpg"><img src="static/img/screenshot-phone-main.jpg"/></a>
|
||||||
|
<a href="static/img/screenshot-phone-detail.jpg"><img src="static/img/screenshot-phone-detail.jpg"/></a>
|
||||||
|
<a href="static/img/screenshot-phone-notification.jpg"><img src="static/img/screenshot-phone-notification.jpg"/></a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="publish" className="anchor">Publishing messages</h2>
|
||||||
|
<p>
|
||||||
|
<a href="docs/publish/">Publishing messages</a> can be done via PUT or POST. Topics are created on
|
||||||
|
the fly by subscribing or publishing to them.
|
||||||
|
Because there is no sign-up, <b>the topic is essentially a password</b>, so pick something that's
|
||||||
|
not easily guessable.
|
||||||
|
</p>
|
||||||
|
<p className="smallMarginBottom">
|
||||||
|
Here's an example showing how to publish a message using a POST request (via <tt>curl -d</tt>):
|
||||||
|
</p>
|
||||||
|
<code>
|
||||||
|
curl -d "Backup successful 😀" <span className="ntfyUrl">ntfy.sh</span>/mytopic
|
||||||
|
</code>
|
||||||
|
<p className="smallMarginBottom">
|
||||||
|
There are <a href="docs/publish/">more features</a> related to publishing messages: You can set a
|
||||||
|
<a href="docs/publish/#message-priority">notification priority</a>, a <a
|
||||||
|
href="docs/publish/#message-title">title</a>,
|
||||||
|
and <a href="docs/publish/#tags-emojis">tag messages</a>.
|
||||||
|
Here's an example using some of them together:
|
||||||
|
</p>
|
||||||
|
<code>
|
||||||
|
curl \<br/>
|
||||||
|
-H "Title: Unauthorized access detected" \<br/>
|
||||||
|
-H "Priority: urgent" \<br/>
|
||||||
|
-H "Tags: warning,skull" \<br/>
|
||||||
|
-d "Remote access to $(hostname) detected. Act right away." \<br/>
|
||||||
|
<span className="ntfyUrl">ntfy.sh</span>/mytopic
|
||||||
|
</code>
|
||||||
|
<p>
|
||||||
|
Here's what that looks like in the <a href="docs/subscribe/phone/">Android app</a>:
|
||||||
|
</p>
|
||||||
|
<figure>
|
||||||
|
<img src="static/img/screenshot-phone-popover.png" style={{maxHeight: "200px"}}/>
|
||||||
|
<figcaption>Urgent notification with pop-over</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h2 id="subscribe" className="anchor">Subscribe to a topic</h2>
|
||||||
|
<p>
|
||||||
|
You can create and subscribe to a topic either <a href="docs/subscribe/phone/">using your phone</a>,
|
||||||
|
in <a href="docs/subscribe/web/">this web UI</a>, or in your own app by <a
|
||||||
|
href="docs/subscribe/api/">subscribing via the API</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 id="subscribe-phone" className="anchor">Subscribe from your phone</h3>
|
||||||
|
<p>
|
||||||
|
Simply get the app and start <a href="docs/publish/">publishing messages</a>. To learn more about
|
||||||
|
the app,
|
||||||
|
<a href="docs/subscribe/phone/">check out the documentation</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy"><img src="static/img/badge-googleplay.png"/></a>
|
||||||
|
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/"><img src="static/img/badge-fdroid.png"/></a>
|
||||||
|
<a href="https://apps.apple.com/us/app/ntfy/id1625396347"><img src="static/img/badge-appstore.png"/></a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Here's a video showing the app in action:
|
||||||
|
</p>
|
||||||
|
<figure>
|
||||||
|
<video controls muted autoPlay loop src="static/img/android-video-overview.mp4" style={{maxWidth: "650px"}}></video>
|
||||||
|
<figcaption>Sending push notifications to your Android phone</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3 id="subscribe-web" className="anchor">Subscribe via web app</h3>
|
||||||
|
<p>
|
||||||
|
Subscribe to topics in the <a href="app">web app</a> and receive messages as <b>desktop
|
||||||
|
notification</b>.
|
||||||
|
It is available at <b><a href="app"><span className="ntfyUrl">ntfy.sh</span>/app</a></b>.
|
||||||
|
</p>
|
||||||
|
<figure>
|
||||||
|
<a href="app"><img src="static/img/screenshot-web-detail.png" width="100%"/></a>
|
||||||
|
<figcaption>ntfy web app, available at <a href="app"><span
|
||||||
|
className="ntfyUrl">ntfy.sh</span>/app</a></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3 id="subscribe-api" className="anchor">Subscribe using the API</h3>
|
||||||
|
<p>
|
||||||
|
There's a super simple API that you can use to integrate your own app. You can consume
|
||||||
|
a <a href="docs/subscribe/api/#subscribe-as-json-stream">JSON stream</a>,
|
||||||
|
an <a href="docs/subscribe/api/#subscribe-as-sse-stream">SSE/EventSource stream</a>,
|
||||||
|
a <a href="docs/subscribe/api/#subscribe-as-raw-stream">plain text stream</a>,
|
||||||
|
or <a href="docs/subscribe/api/#websockets">via WebSockets</a>.
|
||||||
|
</p>
|
||||||
|
<p className="smallMarginBottom">
|
||||||
|
Here's an example for JSON. The <b>connection stays open</b>, so you can retrieve messages as they
|
||||||
|
come in:
|
||||||
|
</p>
|
||||||
|
<code>
|
||||||
|
$ curl -s <span className="ntfyUrl">ntfy.sh</span>/mytopic/json<br/>
|
||||||
|
{`{"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"}`}<br/>
|
||||||
|
{`{"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"}`}<br/>
|
||||||
|
{`{"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}`}<br/>
|
||||||
|
...
|
||||||
|
</code>
|
||||||
|
<p>
|
||||||
|
Here's a short video demonstrating it in action:
|
||||||
|
</p>
|
||||||
|
<figure>
|
||||||
|
<video controls muted autoPlay loop src="static/img/android-video-subscribe-api.mp4" style={{maxWidth: "650px"}}></video>
|
||||||
|
<figcaption>Subscribing to the JSON stream with <tt>curl</tt></figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3 id="docs" className="anchor">Check out the docs!</h3>
|
||||||
|
<p>
|
||||||
|
ntfy has so many more features and you can learn about all of them <a href="docs/">in the
|
||||||
|
documentation</a>
|
||||||
|
(I tried my very best to make it the best docs ever 😉, not sure if I succeeded, hehe).
|
||||||
|
</p>
|
||||||
|
<figure>
|
||||||
|
<a href="docs/"><img width="100%" src="static/img/screenshot-docs.png"/></a>
|
||||||
|
<figcaption>Check out the documentation</figcaption>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<h3 id="free-software" className="anchor">100% open source & forever free</h3>
|
||||||
|
<p>
|
||||||
|
I love free software, and I'm doing this because it's fun. I have no bad intentions, and I will
|
||||||
|
never monetize or sell your information. This service will always stay
|
||||||
|
<a href="https://github.com/binwiederhier/ntfy">free and open</a>.
|
||||||
|
You can read more in the <a href="docs/faq/">FAQs</a> and in the <a href="docs/privacy/">privacy
|
||||||
|
policy</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
123
web/src/components/Login.js
Normal file
123
web/src/components/Login.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import routes from "./routes";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import {NavLink} from "react-router-dom";
|
||||||
|
import AvatarBox from "./AvatarBox";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import {InputAdornment} from "@mui/material";
|
||||||
|
import {Visibility, VisibilityOff} from "@mui/icons-material";
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const user = { username, password };
|
||||||
|
try {
|
||||||
|
const token = await accountApi.login(user);
|
||||||
|
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`);
|
||||||
|
session.store(user.username, token);
|
||||||
|
window.location.href = routes.app;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Login] User auth for user ${user.username} failed`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
setError(t("Login failed: Invalid username or password"));
|
||||||
|
} else if (e.message) {
|
||||||
|
setError(e.message);
|
||||||
|
} else {
|
||||||
|
setError(t("Unknown error. Check logs for details."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!config.enable_login) {
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<Typography sx={{ typography: 'h6' }}>{t("Login is disabled")}</Typography>
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<Typography sx={{ typography: 'h6' }}>
|
||||||
|
{t("login_title")}
|
||||||
|
</Typography>
|
||||||
|
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="username"
|
||||||
|
label={t("signup_form_username")}
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={ev => setUsername(ev.target.value.trim())}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
name="password"
|
||||||
|
label={t("signup_form_password")}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
id="password"
|
||||||
|
value={password}
|
||||||
|
onChange={ev => setPassword(ev.target.value.trim())}
|
||||||
|
autoComplete="current-password"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label={t("signup_form_toggle_password_visibility")}
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
disabled={username === "" || password === ""}
|
||||||
|
sx={{mt: 2, mb: 2}}
|
||||||
|
>
|
||||||
|
{t("login_form_button_submit")}
|
||||||
|
</Button>
|
||||||
|
{error &&
|
||||||
|
<Box sx={{
|
||||||
|
mb: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<WarningAmberIcon color="error" sx={{mr: 1}}/>
|
||||||
|
<Typography sx={{color: 'error.main'}}>{error}</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
<Box sx={{width: "100%"}}>
|
||||||
|
{/* This is where the password reset link would go */}
|
||||||
|
{config.enable_signup && <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">{t("login_link_signup")}</NavLink></div>}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@@ -38,7 +38,7 @@ const Messaging = (props) => {
|
|||||||
<PublishDialog
|
<PublishDialog
|
||||||
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
|
key={`publishDialog${dialogKey}`} // Resets dialog when canceled/closed
|
||||||
openMode={dialogOpenMode}
|
openMode={dialogOpenMode}
|
||||||
baseUrl={subscription?.baseUrl ?? window.location.origin}
|
baseUrl={subscription?.baseUrl ?? config.base_url}
|
||||||
topic={subscription?.topic ?? ""}
|
topic={subscription?.topic ?? ""}
|
||||||
message={message}
|
message={message}
|
||||||
onClose={handleDialogClose}
|
onClose={handleDialogClose}
|
||||||
@@ -83,7 +83,7 @@ const MessageBar = (props) => {
|
|||||||
margin="dense"
|
margin="dense"
|
||||||
placeholder={t("message_bar_type_message")}
|
placeholder={t("message_bar_type_message")}
|
||||||
aria-label={t("message_bar_type_message")}
|
aria-label={t("message_bar_type_message")}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
type="text"
|
type="text"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="standard"
|
variant="standard"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import Drawer from "@mui/material/Drawer";
|
import Drawer from "@mui/material/Drawer";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {useState} from "react";
|
import {useContext, useState} from "react";
|
||||||
import ListItemButton from "@mui/material/ListItemButton";
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
import ListItemIcon from "@mui/material/ListItemIcon";
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
import ChatBubbleOutlineIcon from "@mui/icons-material/ChatBubbleOutline";
|
||||||
|
import Person from "@mui/icons-material/Person";
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import Toolbar from "@mui/material/Toolbar";
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
@@ -11,7 +12,7 @@ import List from "@mui/material/List";
|
|||||||
import SettingsIcon from "@mui/icons-material/Settings";
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import SubscribeDialog from "./SubscribeDialog";
|
import SubscribeDialog from "./SubscribeDialog";
|
||||||
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader} from "@mui/material";
|
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader, Tooltip} from "@mui/material";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
|
import {openUrl, topicDisplayName, topicUrl} from "../app/utils";
|
||||||
@@ -19,12 +20,17 @@ import routes from "./routes";
|
|||||||
import {ConnectionState} from "../app/Connection";
|
import {ConnectionState} from "../app/Connection";
|
||||||
import {useLocation, useNavigate} from "react-router-dom";
|
import {useLocation, useNavigate} from "react-router-dom";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import {ChatBubble, NotificationsOffOutlined, Send} from "@mui/icons-material";
|
import {ChatBubble, Lock, NotificationsOffOutlined, Public, PublicOff, Send} from "@mui/icons-material";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
import config from "../app/config";
|
import config from "../app/config";
|
||||||
import ArticleIcon from '@mui/icons-material/Article';
|
import ArticleIcon from '@mui/icons-material/Article';
|
||||||
import {Trans, useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import accountApi from "../app/AccountApi";
|
||||||
|
import CelebrationIcon from '@mui/icons-material/Celebration';
|
||||||
|
import UpgradeDialog from "./UpgradeDialog";
|
||||||
|
import {AccountContext} from "./App";
|
||||||
|
|
||||||
const navWidth = 280;
|
const navWidth = 280;
|
||||||
|
|
||||||
@@ -71,6 +77,7 @@ const NavList = (props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
|
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
|
||||||
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
|
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -90,6 +97,14 @@ const NavList = (props) => {
|
|||||||
notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted))
|
notifier.maybeRequestPermission(granted => props.onNotificationGranted(granted))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAccountClick = () => {
|
||||||
|
accountApi.sync(); // Dangle!
|
||||||
|
navigate(routes.account);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAdmin = account?.role === "admin";
|
||||||
|
const isPaid = account?.billing?.subscription;
|
||||||
|
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid;
|
||||||
const showSubscriptionsList = props.subscriptions?.length > 0;
|
const showSubscriptionsList = props.subscriptions?.length > 0;
|
||||||
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
|
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
|
||||||
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
||||||
@@ -104,14 +119,14 @@ const NavList = (props) => {
|
|||||||
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
|
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
|
||||||
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
|
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
|
||||||
{!showSubscriptionsList &&
|
{!showSubscriptionsList &&
|
||||||
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
|
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
|
||||||
<ListItemIcon><ChatBubble/></ListItemIcon>
|
<ListItemIcon><ChatBubble/></ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_all_notifications")}/>
|
<ListItemText primary={t("nav_button_all_notifications")}/>
|
||||||
</ListItemButton>}
|
</ListItemButton>}
|
||||||
{showSubscriptionsList &&
|
{showSubscriptionsList &&
|
||||||
<>
|
<>
|
||||||
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
|
<ListSubheader>{t("nav_topics_title")}</ListSubheader>
|
||||||
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
|
<ListItemButton onClick={() => navigate(routes.app)} selected={location.pathname === config.app_root}>
|
||||||
<ListItemIcon><ChatBubble/></ListItemIcon>
|
<ListItemIcon><ChatBubble/></ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_all_notifications")}/>
|
<ListItemText primary={t("nav_button_all_notifications")}/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
@@ -121,6 +136,12 @@ const NavList = (props) => {
|
|||||||
/>
|
/>
|
||||||
<Divider sx={{my: 1}}/>
|
<Divider sx={{my: 1}}/>
|
||||||
</>}
|
</>}
|
||||||
|
{session.exists() &&
|
||||||
|
<ListItemButton onClick={handleAccountClick} selected={location.pathname === routes.account}>
|
||||||
|
<ListItemIcon><Person/></ListItemIcon>
|
||||||
|
<ListItemText primary={t("nav_button_account")}/>
|
||||||
|
</ListItemButton>
|
||||||
|
}
|
||||||
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
<ListItemButton onClick={() => navigate(routes.settings)} selected={location.pathname === routes.settings}>
|
||||||
<ListItemIcon><SettingsIcon/></ListItemIcon>
|
<ListItemIcon><SettingsIcon/></ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_settings")}/>
|
<ListItemText primary={t("nav_button_settings")}/>
|
||||||
@@ -137,6 +158,9 @@ const NavList = (props) => {
|
|||||||
<ListItemIcon><AddIcon/></ListItemIcon>
|
<ListItemIcon><AddIcon/></ListItemIcon>
|
||||||
<ListItemText primary={t("nav_button_subscribe")}/>
|
<ListItemText primary={t("nav_button_subscribe")}/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
|
{showUpgradeBanner &&
|
||||||
|
<UpgradeBanner/>
|
||||||
|
}
|
||||||
</List>
|
</List>
|
||||||
<SubscribeDialog
|
<SubscribeDialog
|
||||||
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
|
key={`subscribeDialog${subscribeDialogKey}`} // Resets dialog when canceled/closed
|
||||||
@@ -149,10 +173,61 @@ const NavList = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UpgradeBanner = () => {
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setDialogKey(k => k + 1);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
position: "fixed",
|
||||||
|
width: `${Navigation.width - 1}px`,
|
||||||
|
bottom: 0,
|
||||||
|
mt: 'auto',
|
||||||
|
background: "linear-gradient(150deg, rgba(196, 228, 221, 0.46) 0%, rgb(255, 255, 255) 100%)",
|
||||||
|
}}>
|
||||||
|
<Divider/>
|
||||||
|
<ListItemButton onClick={handleClick} sx={{pt: 2, pb: 2}}>
|
||||||
|
<ListItemIcon><CelebrationIcon sx={{ color: "#55b86e" }} fontSize="large"/></ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
primary={"Upgrade to ntfy Pro"}
|
||||||
|
secondary={"Reserve topics, more messages & emails, and larger attachments"}
|
||||||
|
primaryTypographyProps={{
|
||||||
|
style: {
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "1.1rem",
|
||||||
|
background: "-webkit-linear-gradient(45deg, #09009f, #00ff95 80%)",
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
secondaryTypographyProps={{
|
||||||
|
style: {
|
||||||
|
fontSize: "1rem"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
<UpgradeDialog
|
||||||
|
key={`upgradeDialog${dialogKey}`}
|
||||||
|
open={dialogOpen}
|
||||||
|
onCancel={() => setDialogOpen(false)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const SubscriptionList = (props) => {
|
const SubscriptionList = (props) => {
|
||||||
const sortedSubscriptions = props.subscriptions.sort( (a, b) => {
|
const sortedSubscriptions = props.subscriptions
|
||||||
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
|
.filter(s => !s.internal)
|
||||||
});
|
.sort((a, b) => {
|
||||||
|
return (topicUrl(a.baseUrl, a.topic) < topicUrl(b.baseUrl, b.topic)) ? -1 : 1;
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sortedSubscriptions.map(subscription =>
|
{sortedSubscriptions.map(subscription =>
|
||||||
@@ -184,9 +259,28 @@ const SubscriptionItem = (props) => {
|
|||||||
return (
|
return (
|
||||||
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
|
<ListItemButton onClick={handleClick} selected={props.selected} aria-label={ariaLabel} aria-live="polite">
|
||||||
<ListItemIcon>{icon}</ListItemIcon>
|
<ListItemIcon>{icon}</ListItemIcon>
|
||||||
<ListItemText primary={displayName}/>
|
<ListItemText primary={displayName} primaryTypographyProps={{ style: { overflow: "hidden", textOverflow: "ellipsis" } }}/>
|
||||||
|
{subscription.reservation?.everyone &&
|
||||||
|
<ListItemIcon edge="end" sx={{ minWidth: "26px" }}>
|
||||||
|
{subscription.reservation?.everyone === "read-write" &&
|
||||||
|
<Tooltip title={t("prefs_reservations_table_everyone_read_write")}><Public fontSize="small"/></Tooltip>
|
||||||
|
}
|
||||||
|
{subscription.reservation?.everyone === "read-only" &&
|
||||||
|
<Tooltip title={t("prefs_reservations_table_everyone_read_only")}><PublicOff fontSize="small"/></Tooltip>
|
||||||
|
}
|
||||||
|
{subscription.reservation?.everyone === "write-only" &&
|
||||||
|
<Tooltip title={t("prefs_reservations_table_everyone_write_only")}><PublicOff fontSize="small"/></Tooltip>
|
||||||
|
}
|
||||||
|
{subscription.reservation?.everyone === "deny-all" &&
|
||||||
|
<Tooltip title={t("prefs_reservations_table_everyone_deny_all")}><Lock fontSize="small"/></Tooltip>
|
||||||
|
}
|
||||||
|
</ListItemIcon>
|
||||||
|
}
|
||||||
{subscription.mutedUntil > 0 &&
|
{subscription.mutedUntil > 0 &&
|
||||||
<ListItemIcon edge="end" aria-label={t("nav_button_muted")}><NotificationsOffOutlined /></ListItemIcon>}
|
<ListItemIcon edge="end" sx={{ minWidth: "26px" }} aria-label={t("nav_button_muted")}>
|
||||||
|
<Tooltip title={t("nav_button_muted")}><NotificationsOffOutlined /></Tooltip>
|
||||||
|
</ListItemIcon>
|
||||||
|
}
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
formatBytes,
|
formatBytes,
|
||||||
formatMessage,
|
formatMessage,
|
||||||
formatShortDateTime,
|
formatShortDateTime,
|
||||||
formatTitle, maybeAppendActionErrors,
|
formatTitle,
|
||||||
|
maybeAppendActionErrors,
|
||||||
openUrl,
|
openUrl,
|
||||||
shortUrl,
|
shortUrl,
|
||||||
topicShortUrl,
|
topicShortUrl,
|
||||||
@@ -41,15 +42,27 @@ import priority5 from "../img/priority-5.svg";
|
|||||||
import logoOutline from "../img/ntfy-outline.svg";
|
import logoOutline from "../img/ntfy-outline.svg";
|
||||||
import AttachmentIcon from "./AttachmentIcon";
|
import AttachmentIcon from "./AttachmentIcon";
|
||||||
import {Trans, useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
|
import {useOutletContext} from "react-router-dom";
|
||||||
|
import {useAutoSubscribe} from "./hooks";
|
||||||
|
|
||||||
const Notifications = (props) => {
|
export const AllSubscriptions = () => {
|
||||||
if (props.mode === "all") {
|
const { subscriptions } = useOutletContext();
|
||||||
return (props.subscriptions) ? <AllSubscriptions subscriptions={props.subscriptions}/> : <Loading/>;
|
if (!subscriptions) {
|
||||||
|
return <Loading/>;
|
||||||
}
|
}
|
||||||
return (props.subscription) ? <SingleSubscription subscription={props.subscription}/> : <Loading/>;
|
return <AllSubscriptionsList subscriptions={subscriptions}/>;
|
||||||
}
|
};
|
||||||
|
|
||||||
const AllSubscriptions = (props) => {
|
export const SingleSubscription = () => {
|
||||||
|
const { subscriptions, selected } = useOutletContext();
|
||||||
|
useAutoSubscribe(subscriptions, selected);
|
||||||
|
if (!selected) {
|
||||||
|
return <Loading/>;
|
||||||
|
}
|
||||||
|
return <SingleSubscriptionList subscription={selected}/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AllSubscriptionsList = (props) => {
|
||||||
const subscriptions = props.subscriptions;
|
const subscriptions = props.subscriptions;
|
||||||
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
|
const notifications = useLiveQuery(() => subscriptionManager.getAllNotifications(), []);
|
||||||
if (notifications === null || notifications === undefined) {
|
if (notifications === null || notifications === undefined) {
|
||||||
@@ -62,7 +75,7 @@ const AllSubscriptions = (props) => {
|
|||||||
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
|
return <NotificationList key="all" notifications={notifications} messageBar={false}/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SingleSubscription = (props) => {
|
const SingleSubscriptionList = (props) => {
|
||||||
const subscription = props.subscription;
|
const subscription = props.subscription;
|
||||||
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
|
const notifications = useLiveQuery(() => subscriptionManager.getNotifications(subscription.id), [subscription]);
|
||||||
if (notifications === null || notifications === undefined) {
|
if (notifications === null || notifications === undefined) {
|
||||||
@@ -84,7 +97,10 @@ const NotificationList = (props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
setMaxCount(pageSize);
|
setMaxCount(pageSize);
|
||||||
document.getElementById("main").scrollTo(0, 0);
|
const main = document.getElementById("main");
|
||||||
|
if (main) {
|
||||||
|
main.scrollTo(0, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [props.id]);
|
}, [props.id]);
|
||||||
|
|
||||||
@@ -530,5 +546,3 @@ const Loading = () => {
|
|||||||
</VerticallyCenteredContainer>
|
</VerticallyCenteredContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Notifications;
|
|
||||||
|
|||||||
47
web/src/components/PopupMenu.js
Normal file
47
web/src/components/PopupMenu.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import {Menu} from "@mui/material";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const PopupMenu = (props) => {
|
||||||
|
const horizontal = props.horizontal ?? "left";
|
||||||
|
const arrow = (horizontal === "right") ? { right: 19 } : { left: 19 };
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
anchorEl={props.anchorEl}
|
||||||
|
open={props.open}
|
||||||
|
onClose={props.onClose}
|
||||||
|
onClick={props.onClose}
|
||||||
|
PaperProps={{
|
||||||
|
elevation: 0,
|
||||||
|
sx: {
|
||||||
|
overflow: 'visible',
|
||||||
|
filter: 'drop-shadow(0px 2px 8px rgba(0,0,0,0.32))',
|
||||||
|
mt: 1.5,
|
||||||
|
'& .MuiAvatar-root': {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
ml: -0.5,
|
||||||
|
mr: 1,
|
||||||
|
},
|
||||||
|
'&:before': {
|
||||||
|
content: '""',
|
||||||
|
display: 'block',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
transform: 'translateY(-50%) rotate(45deg)',
|
||||||
|
zIndex: 0,
|
||||||
|
...arrow
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
transformOrigin={{ horizontal: horizontal, vertical: 'top' }}
|
||||||
|
anchorOrigin={{ horizontal: horizontal, vertical: 'bottom' }}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PopupMenu;
|
||||||
51
web/src/components/Pref.js
Normal file
51
web/src/components/Pref.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export const PrefGroup = (props) => {
|
||||||
|
return (
|
||||||
|
<div role="table">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Pref = (props) => {
|
||||||
|
const justifyContent = (props.alignTop) ? "normal" : "center";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
marginTop: "10px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
id={props.labelId ?? ""}
|
||||||
|
aria-label={props.title}
|
||||||
|
style={{
|
||||||
|
flex: '1 0 40%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: justifyContent,
|
||||||
|
paddingRight: '30px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div><b>{props.title}</b>{props.subtitle && <em> ({props.subtitle})</em>}</div>
|
||||||
|
{props.description && <div><em>{props.description}</em></div>}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="cell"
|
||||||
|
style={{
|
||||||
|
flex: '1 0 calc(60% - 50px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: justifyContent
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {useEffect, useState} from 'react';
|
import {useContext, useEffect, useState} from 'react';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
CardActions,
|
CardActions,
|
||||||
CardContent,
|
CardContent, Chip,
|
||||||
FormControl,
|
FormControl,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
useMediaQuery
|
useMediaQuery
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
@@ -18,6 +20,7 @@ import prefs from "../app/Prefs";
|
|||||||
import {Paragraph} from "./styles";
|
import {Paragraph} from "./styles";
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
import Container from "@mui/material/Container";
|
import Container from "@mui/material/Container";
|
||||||
@@ -32,16 +35,28 @@ import DialogTitle from "@mui/material/DialogTitle";
|
|||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import {playSound, shuffle, sounds, validUrl} from "../app/utils";
|
import {playSound, shuffle, sounds, validTopic, validUrl} from "../app/utils";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import {Pref, PrefGroup} from "./Pref";
|
||||||
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
|
import {Check, Info, Public, PublicOff} from "@mui/icons-material";
|
||||||
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
|
import ReserveTopicSelect from "./ReserveTopicSelect";
|
||||||
|
import {AccountContext} from "./App";
|
||||||
|
import {useOutletContext} from "react-router-dom";
|
||||||
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
|
|
||||||
const Preferences = () => {
|
const Preferences = () => {
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
|
<Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
|
||||||
<Stack spacing={3}>
|
<Stack spacing={3}>
|
||||||
<Notifications/>
|
<Notifications/>
|
||||||
<Appearance/>
|
<Reservations/>
|
||||||
<Users/>
|
<Users/>
|
||||||
|
<Appearance/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
@@ -69,6 +84,11 @@ const Sound = () => {
|
|||||||
const sound = useLiveQuery(async () => prefs.sound());
|
const sound = useLiveQuery(async () => prefs.sound());
|
||||||
const handleChange = async (ev) => {
|
const handleChange = async (ev) => {
|
||||||
await prefs.setSound(ev.target.value);
|
await prefs.setSound(ev.target.value);
|
||||||
|
await maybeUpdateAccountSettings({
|
||||||
|
notification: {
|
||||||
|
sound: ev.target.value
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!sound) {
|
if (!sound) {
|
||||||
return null; // While loading
|
return null; // While loading
|
||||||
@@ -102,6 +122,11 @@ const MinPriority = () => {
|
|||||||
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
const minPriority = useLiveQuery(async () => prefs.minPriority());
|
||||||
const handleChange = async (ev) => {
|
const handleChange = async (ev) => {
|
||||||
await prefs.setMinPriority(ev.target.value);
|
await prefs.setMinPriority(ev.target.value);
|
||||||
|
await maybeUpdateAccountSettings({
|
||||||
|
notification: {
|
||||||
|
min_priority: ev.target.value
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (!minPriority) {
|
if (!minPriority) {
|
||||||
return null; // While loading
|
return null; // While loading
|
||||||
@@ -145,6 +170,11 @@ const DeleteAfter = () => {
|
|||||||
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
const deleteAfter = useLiveQuery(async () => prefs.deleteAfter());
|
||||||
const handleChange = async (ev) => {
|
const handleChange = async (ev) => {
|
||||||
await prefs.setDeleteAfter(ev.target.value);
|
await prefs.setDeleteAfter(ev.target.value);
|
||||||
|
await maybeUpdateAccountSettings({
|
||||||
|
notification: {
|
||||||
|
delete_after: ev.target.value
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
|
if (deleteAfter === null || deleteAfter === undefined) { // !deleteAfter will not work with "0"
|
||||||
return null; // While loading
|
return null; // While loading
|
||||||
@@ -173,55 +203,6 @@ const DeleteAfter = () => {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
const PrefGroup = (props) => {
|
|
||||||
return (
|
|
||||||
<div role="table">
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
const Pref = (props) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="row"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "row",
|
|
||||||
marginTop: "10px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="cell"
|
|
||||||
id={props.labelId}
|
|
||||||
aria-label={props.title}
|
|
||||||
style={{
|
|
||||||
flex: '1 0 40%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingRight: '30px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div><b>{props.title}</b></div>
|
|
||||||
{props.description && <div><em>{props.description}</em></div>}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
role="cell"
|
|
||||||
style={{
|
|
||||||
flex: '1 0 calc(60% - 50px)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Users = () => {
|
const Users = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dialogKey, setDialogKey] = useState(0);
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
@@ -251,6 +232,7 @@ const Users = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
{t("prefs_users_description")}
|
{t("prefs_users_description")}
|
||||||
|
{session.exists() && <>{" " + t("prefs_users_description_no_sync")}</>}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
{users?.length > 0 && <UserTable users={users}/>}
|
{users?.length > 0 && <UserTable users={users}/>}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -274,14 +256,17 @@ const UserTable = (props) => {
|
|||||||
const [dialogKey, setDialogKey] = useState(0);
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [dialogUser, setDialogUser] = useState(null);
|
const [dialogUser, setDialogUser] = useState(null);
|
||||||
|
|
||||||
const handleEditClick = (user) => {
|
const handleEditClick = (user) => {
|
||||||
setDialogKey(prev => prev+1);
|
setDialogKey(prev => prev+1);
|
||||||
setDialogUser(user);
|
setDialogUser(user);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogCancel = () => {
|
const handleDialogCancel = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDialogSubmit = async (user) => {
|
const handleDialogSubmit = async (user) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
try {
|
try {
|
||||||
@@ -291,6 +276,7 @@ const UserTable = (props) => {
|
|||||||
console.log(`[Preferences] Error updating user.`, e);
|
console.log(`[Preferences] Error updating user.`, e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = async (user) => {
|
const handleDeleteClick = async (user) => {
|
||||||
try {
|
try {
|
||||||
await userManager.delete(user.baseUrl);
|
await userManager.delete(user.baseUrl);
|
||||||
@@ -299,6 +285,7 @@ const UserTable = (props) => {
|
|||||||
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
|
console.error(`[Preferences] Error deleting user for ${user.baseUrl}`, e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table size="small" aria-label={t("prefs_users_table")}>
|
<Table size="small" aria-label={t("prefs_users_table")}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
@@ -312,17 +299,30 @@ const UserTable = (props) => {
|
|||||||
{props.users?.map(user => (
|
{props.users?.map(user => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={user.baseUrl}
|
key={user.baseUrl}
|
||||||
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
|
sx={{'&:last-child td, &:last-child th': {border: 0}}}
|
||||||
>
|
>
|
||||||
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
|
<TableCell component="th" scope="row" sx={{paddingLeft: 0}}
|
||||||
|
aria-label={t("prefs_users_table_user_header")}>{user.username}</TableCell>
|
||||||
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
|
<TableCell aria-label={t("prefs_users_table_base_url_header")}>{user.baseUrl}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
|
{(!session.exists() || user.baseUrl !== config.base_url) &&
|
||||||
<EditIcon/>
|
<>
|
||||||
</IconButton>
|
<IconButton onClick={() => handleEditClick(user)} aria-label={t("prefs_users_edit_button")}>
|
||||||
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
|
<EditIcon/>
|
||||||
<CloseIcon />
|
</IconButton>
|
||||||
</IconButton>
|
<IconButton onClick={() => handleDeleteClick(user)} aria-label={t("prefs_users_delete_button")}>
|
||||||
|
<CloseIcon/>
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{session.exists() && user.baseUrl === config.base_url &&
|
||||||
|
<Tooltip title={t("prefs_users_table_cannot_delete_or_edit")}>
|
||||||
|
<span>
|
||||||
|
<IconButton disabled><EditIcon/></IconButton>
|
||||||
|
<IconButton disabled><CloseIcon/></IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -440,6 +440,13 @@ const Language = () => {
|
|||||||
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
|
const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" ");
|
||||||
const lang = i18n.language ?? "en";
|
const lang = i18n.language ?? "en";
|
||||||
|
|
||||||
|
const handleChange = async (ev) => {
|
||||||
|
await i18n.changeLanguage(ev.target.value);
|
||||||
|
await maybeUpdateAccountSettings({
|
||||||
|
language: ev.target.value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Remember: Flags are not languages. Don't put flags next to the language in the list.
|
// Remember: Flags are not languages. Don't put flags next to the language in the list.
|
||||||
// Languages names from: https://www.omniglot.com/language/names.htm
|
// Languages names from: https://www.omniglot.com/language/names.htm
|
||||||
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
|
// Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
|
||||||
@@ -447,7 +454,7 @@ const Language = () => {
|
|||||||
return (
|
return (
|
||||||
<Pref labelId={labelId} title={title}>
|
<Pref labelId={labelId} title={title}>
|
||||||
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
<FormControl fullWidth variant="standard" sx={{ m: 1 }}>
|
||||||
<Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}>
|
<Select value={lang} onChange={handleChange} aria-labelledby={labelId}>
|
||||||
<MenuItem value="en">English</MenuItem>
|
<MenuItem value="en">English</MenuItem>
|
||||||
<MenuItem value="id">Bahasa Indonesia</MenuItem>
|
<MenuItem value="id">Bahasa Indonesia</MenuItem>
|
||||||
<MenuItem value="bg">Български</MenuItem>
|
<MenuItem value="bg">Български</MenuItem>
|
||||||
@@ -463,7 +470,6 @@ const Language = () => {
|
|||||||
<MenuItem value="nl">Nederlands</MenuItem>
|
<MenuItem value="nl">Nederlands</MenuItem>
|
||||||
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
|
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>
|
||||||
<MenuItem value="uk">Українська</MenuItem>
|
<MenuItem value="uk">Українська</MenuItem>
|
||||||
<MenuItem value="pt">Português</MenuItem>
|
|
||||||
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
|
<MenuItem value="pt_BR">Português (Brasil)</MenuItem>
|
||||||
<MenuItem value="pl">Polski</MenuItem>
|
<MenuItem value="pl">Polski</MenuItem>
|
||||||
<MenuItem value="ru">Русский</MenuItem>
|
<MenuItem value="ru">Русский</MenuItem>
|
||||||
@@ -474,4 +480,255 @@ const Language = () => {
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const Reservations = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!config.enable_reservations || !session.exists() || !account || account.role === "admin") {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
const reservations = account.reservations || [];
|
||||||
|
const limitReached = account.role === "user" && account.stats.reservations_remaining === 0;
|
||||||
|
|
||||||
|
const handleAddClick = () => {
|
||||||
|
setDialogKey(prev => prev+1);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogCancel = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogSubmit = async (reservation) => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
try {
|
||||||
|
await accountApi.upsertReservation(reservation.topic, reservation.everyone);
|
||||||
|
await accountApi.sync();
|
||||||
|
console.debug(`[Preferences] Added topic reservation`, reservation);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Preferences] Error topic reservation.`, e);
|
||||||
|
}
|
||||||
|
// FIXME handle 401/403/409
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card sx={{ padding: 1 }} aria-label={t("prefs_reservations_title")}>
|
||||||
|
<CardContent sx={{ paddingBottom: 1 }}>
|
||||||
|
<Typography variant="h5" sx={{marginBottom: 2}}>
|
||||||
|
{t("prefs_reservations_title")}
|
||||||
|
</Typography>
|
||||||
|
<Paragraph>
|
||||||
|
{t("prefs_reservations_description")}
|
||||||
|
</Paragraph>
|
||||||
|
{reservations.length > 0 && <ReservationsTable reservations={reservations}/>}
|
||||||
|
{limitReached && <Alert severity="info">{t("prefs_reservations_limit_reached")}</Alert>}
|
||||||
|
</CardContent>
|
||||||
|
<CardActions>
|
||||||
|
<Button onClick={handleAddClick} disabled={limitReached}>{t("prefs_reservations_add_button")}</Button>
|
||||||
|
|
||||||
|
<ReservationsDialog
|
||||||
|
key={`reservationAddDialog${dialogKey}`}
|
||||||
|
open={dialogOpen}
|
||||||
|
reservation={null}
|
||||||
|
reservations={reservations}
|
||||||
|
onCancel={handleDialogCancel}
|
||||||
|
onSubmit={handleDialogSubmit}
|
||||||
|
/>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReservationsTable = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [dialogKey, setDialogKey] = useState(0);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [dialogReservation, setDialogReservation] = useState(null);
|
||||||
|
const { subscriptions } = useOutletContext();
|
||||||
|
const localSubscriptions = Object.assign(
|
||||||
|
...subscriptions
|
||||||
|
.filter(s => s.baseUrl === config.base_url)
|
||||||
|
.map(s => ({[s.topic]: s}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEditClick = (reservation) => {
|
||||||
|
setDialogKey(prev => prev+1);
|
||||||
|
setDialogReservation(reservation);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogCancel = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogSubmit = async (reservation) => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
try {
|
||||||
|
await accountApi.upsertReservation(reservation.topic, reservation.everyone);
|
||||||
|
await accountApi.sync();
|
||||||
|
console.debug(`[Preferences] Added topic reservation`, reservation);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Preferences] Error topic reservation.`, e);
|
||||||
|
}
|
||||||
|
// FIXME handle 401/403/409
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = async (reservation) => {
|
||||||
|
try {
|
||||||
|
await accountApi.deleteReservation(reservation.topic);
|
||||||
|
await accountApi.sync();
|
||||||
|
console.debug(`[Preferences] Deleted topic reservation`, reservation);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Preferences] Error topic reservation.`, e);
|
||||||
|
}
|
||||||
|
// FIXME handle 401/403
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table size="small" aria-label={t("prefs_reservations_table")}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{paddingLeft: 0}}>{t("prefs_reservations_table_topic_header")}</TableCell>
|
||||||
|
<TableCell>{t("prefs_reservations_table_access_header")}</TableCell>
|
||||||
|
<TableCell/>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{props.reservations.map(reservation => (
|
||||||
|
<TableRow
|
||||||
|
key={reservation.topic}
|
||||||
|
sx={{'&:last-child td, &:last-child th': {border: 0}}}
|
||||||
|
>
|
||||||
|
<TableCell component="th" scope="row" sx={{paddingLeft: 0}} aria-label={t("prefs_reservations_table_topic_header")}>
|
||||||
|
{reservation.topic}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell aria-label={t("prefs_reservations_table_access_header")}>
|
||||||
|
{reservation.everyone === "read-write" &&
|
||||||
|
<>
|
||||||
|
<Public fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
|
||||||
|
{t("prefs_reservations_table_everyone_read_write")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{reservation.everyone === "read-only" &&
|
||||||
|
<>
|
||||||
|
<PublicOff fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
|
||||||
|
{t("prefs_reservations_table_everyone_read_only")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{reservation.everyone === "write-only" &&
|
||||||
|
<>
|
||||||
|
<PublicOff fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
|
||||||
|
{t("prefs_reservations_table_everyone_write_only")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
{reservation.everyone === "deny-all" &&
|
||||||
|
<>
|
||||||
|
<LockIcon fontSize="small" sx={{color: "grey", verticalAlign: "bottom", mr: 0.5}}/>
|
||||||
|
{t("prefs_reservations_table_everyone_deny_all")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
{!localSubscriptions[reservation.topic] &&
|
||||||
|
<Chip icon={<Info/>} label="Not subscribed" color="primary" variant="outlined"/>
|
||||||
|
}
|
||||||
|
<IconButton onClick={() => handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}>
|
||||||
|
<EditIcon/>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton onClick={() => handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}>
|
||||||
|
<CloseIcon/>
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
<ReservationsDialog
|
||||||
|
key={`reservationEditDialog${dialogKey}`}
|
||||||
|
open={dialogOpen}
|
||||||
|
reservation={dialogReservation}
|
||||||
|
reservations={props.reservations}
|
||||||
|
onCancel={handleDialogCancel}
|
||||||
|
onSubmit={handleDialogSubmit}
|
||||||
|
/>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReservationsDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [everyone, setEveryone] = useState("deny-all");
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const editMode = props.reservation !== null;
|
||||||
|
const addButtonEnabled = (() => {
|
||||||
|
if (editMode) {
|
||||||
|
return true;
|
||||||
|
} else if (!validTopic(topic)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return props.reservations
|
||||||
|
.filter(r => r.topic === topic)
|
||||||
|
.length === 0;
|
||||||
|
})();
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
props.onSubmit({
|
||||||
|
topic: (editMode) ? props.reservation.topic : topic,
|
||||||
|
everyone: everyone
|
||||||
|
})
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (editMode) {
|
||||||
|
setTopic(props.reservation.topic);
|
||||||
|
setEveryone(props.reservation.everyone);
|
||||||
|
}
|
||||||
|
}, [editMode, props.reservation]);
|
||||||
|
return (
|
||||||
|
<Dialog open={props.open} onClose={props.onCancel} maxWidth="sm" fullWidth fullScreen={fullScreen}>
|
||||||
|
<DialogTitle>{editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
{t("prefs_reservations_dialog_description")}
|
||||||
|
</DialogContentText>
|
||||||
|
{!editMode && <TextField
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
id="topic"
|
||||||
|
label={t("prefs_reservations_dialog_topic_label")}
|
||||||
|
aria-label={t("prefs_reservations_dialog_topic_label")}
|
||||||
|
value={topic}
|
||||||
|
onChange={ev => setTopic(ev.target.value)}
|
||||||
|
type="url"
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
/>}
|
||||||
|
<ReserveTopicSelect
|
||||||
|
value={everyone}
|
||||||
|
onChange={setEveryone}
|
||||||
|
sx={{mt: 1}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={props.onCancel}>{t("prefs_users_dialog_button_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!addButtonEnabled}>{editMode ? t("prefs_users_dialog_button_save") : t("prefs_users_dialog_button_add")}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeUpdateAccountSettings = async (payload) => {
|
||||||
|
if (!session.exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await accountApi.updateSettings(payload);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Preferences] Error updating account settings`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default Preferences;
|
export default Preferences;
|
||||||
|
|||||||
12
web/src/components/Pricing.js
Normal file
12
web/src/components/Pricing.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import SiteLayout from "./SiteLayout";
|
||||||
|
|
||||||
|
const Pricing = () => {
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
pricing
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Pricing;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {useEffect, useRef, useState} from 'react';
|
import {useEffect, useRef, useState} from 'react';
|
||||||
import {NotificationItem} from "./Notifications";
|
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
|
import {Checkbox, Chip, FormControl, FormControlLabel, InputLabel, Link, Select, useMediaQuery} from "@mui/material";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
@@ -18,7 +17,15 @@ import IconButton from "@mui/material/IconButton";
|
|||||||
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
|
import InsertEmoticonIcon from '@mui/icons-material/InsertEmoticon';
|
||||||
import {Close} from "@mui/icons-material";
|
import {Close} from "@mui/icons-material";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import {basicAuth, formatBytes, maybeWithBasicAuth, topicShortUrl, topicUrl, validTopic, validUrl} from "../app/utils";
|
import {
|
||||||
|
formatBytes,
|
||||||
|
maybeWithAuth,
|
||||||
|
withBasicAuth,
|
||||||
|
topicShortUrl,
|
||||||
|
topicUrl,
|
||||||
|
validTopic,
|
||||||
|
validUrl
|
||||||
|
} from "../app/utils";
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
import AttachmentIcon from "./AttachmentIcon";
|
import AttachmentIcon from "./AttachmentIcon";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
@@ -26,6 +33,9 @@ import api from "../app/Api";
|
|||||||
import userManager from "../app/UserManager";
|
import userManager from "../app/UserManager";
|
||||||
import EmojiPicker from "./EmojiPicker";
|
import EmojiPicker from "./EmojiPicker";
|
||||||
import {Trans, useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
|
||||||
const PublishDialog = (props) => {
|
const PublishDialog = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -130,7 +140,7 @@ const PublishDialog = (props) => {
|
|||||||
const body = (attachFile) ? attachFile : message;
|
const body = (attachFile) ? attachFile : message;
|
||||||
try {
|
try {
|
||||||
const user = await userManager.get(baseUrl);
|
const user = await userManager.get(baseUrl);
|
||||||
const headers = maybeWithBasicAuth({}, user);
|
const headers = maybeWithAuth({}, user);
|
||||||
const progressFn = (ev) => {
|
const progressFn = (ev) => {
|
||||||
if (ev.loaded > 0 && ev.total > 0) {
|
if (ev.loaded > 0 && ev.total > 0) {
|
||||||
setStatus(t("publish_dialog_progress_uploading_detail", {
|
setStatus(t("publish_dialog_progress_uploading_detail", {
|
||||||
@@ -159,9 +169,9 @@ const PublishDialog = (props) => {
|
|||||||
|
|
||||||
const checkAttachmentLimits = async (file) => {
|
const checkAttachmentLimits = async (file) => {
|
||||||
try {
|
try {
|
||||||
const stats = await api.userStats(baseUrl);
|
const account = await accountApi.get();
|
||||||
const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0;
|
const fileSizeLimit = account.limits.attachment_file_size ?? 0;
|
||||||
const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0;
|
const remainingBytes = account.stats.attachment_total_size_remaining;
|
||||||
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
|
const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit;
|
||||||
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
|
const quotaReached = remainingBytes > 0 && file.size > remainingBytes;
|
||||||
if (fileSizeLimitReached && quotaReached) {
|
if (fileSizeLimitReached && quotaReached) {
|
||||||
@@ -176,8 +186,12 @@ const PublishDialog = (props) => {
|
|||||||
}
|
}
|
||||||
setAttachFileError("");
|
setAttachFileError("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(`[SendDialog] Retrieving attachment limits failed`, e);
|
console.log(`[PublishDialog] Retrieving attachment limits failed`, e);
|
||||||
setAttachFileError(""); // Reset error (rely on server-side checking)
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
} else {
|
||||||
|
setAttachFileError(""); // Reset error (rely on server-side checking)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
62
web/src/components/ReserveTopicSelect.js
Normal file
62
web/src/components/ReserveTopicSelect.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import {Checkbox, FormControl, FormControlLabel, Select, useMediaQuery} from "@mui/material";
|
||||||
|
import theme from "./theme";
|
||||||
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
|
import DialogFooter from "./DialogFooter";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
|
import {Public, PublicOff} from "@mui/icons-material";
|
||||||
|
|
||||||
|
const ReserveTopicSelect = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const sx = props.sx || {};
|
||||||
|
return (
|
||||||
|
<FormControl fullWidth variant="standard" sx={sx}>
|
||||||
|
<Select
|
||||||
|
value={props.value}
|
||||||
|
onChange={(ev) => props.onChange(ev.target.value)}
|
||||||
|
aria-label={t("prefs_reservations_dialog_access_label")}
|
||||||
|
sx={{
|
||||||
|
"& .MuiSelect-select": {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: "4px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="deny-all">
|
||||||
|
<ListItemIcon><LockIcon/></ListItemIcon>
|
||||||
|
<ListItemText primary={t("prefs_reservations_table_everyone_deny_all")}/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="read-only">
|
||||||
|
<ListItemIcon><PublicOff/></ListItemIcon>
|
||||||
|
<ListItemText primary={t("prefs_reservations_table_everyone_read_only")}/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="write-only">
|
||||||
|
<ListItemIcon><PublicOff/></ListItemIcon>
|
||||||
|
<ListItemText primary={t("prefs_reservations_table_everyone_write_only")}/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem value="read-write">
|
||||||
|
<ListItemIcon><Public/></ListItemIcon>
|
||||||
|
<ListItemText primary={t("prefs_reservations_table_everyone_read_write")}/>
|
||||||
|
</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReserveTopicSelect;
|
||||||
128
web/src/components/Signup.js
Normal file
128
web/src/components/Signup.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import routes from "./routes";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {NavLink} from "react-router-dom";
|
||||||
|
import AvatarBox from "./AvatarBox";
|
||||||
|
import {useTranslation} from "react-i18next";
|
||||||
|
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||||
|
import accountApi, {AccountCreateLimitReachedError, UsernameTakenError} from "../app/AccountApi";
|
||||||
|
import {InputAdornment} from "@mui/material";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import {Visibility, VisibilityOff} from "@mui/icons-material";
|
||||||
|
|
||||||
|
const Signup = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const user = { username, password };
|
||||||
|
try {
|
||||||
|
await accountApi.create(user.username, user.password);
|
||||||
|
const token = await accountApi.login(user);
|
||||||
|
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`);
|
||||||
|
session.store(user.username, token);
|
||||||
|
window.location.href = routes.app;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Signup] Signup for user ${user.username} failed`, e);
|
||||||
|
if ((e instanceof UsernameTakenError)) {
|
||||||
|
setError(t("signup_error_username_taken", { username: e.username }));
|
||||||
|
} else if ((e instanceof AccountCreateLimitReachedError)) {
|
||||||
|
setError(t("signup_error_creation_limit_reached"));
|
||||||
|
} else if (e.message) {
|
||||||
|
setError(e.message);
|
||||||
|
} else {
|
||||||
|
setError(t("signup_error_unknown"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!config.enable_signup) {
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<Typography sx={{ typography: 'h6' }}>{t("signup_disabled")}</Typography>
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AvatarBox>
|
||||||
|
<Typography sx={{ typography: 'h6' }}>
|
||||||
|
{t("signup_title")}
|
||||||
|
</Typography>
|
||||||
|
<Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
id="username"
|
||||||
|
label={t("signup_form_username")}
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={ev => setUsername(ev.target.value.trim())}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
required
|
||||||
|
fullWidth
|
||||||
|
name="password"
|
||||||
|
label={t("signup_form_password")}
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
id="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={ev => setPassword(ev.target.value.trim())}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label={t("signup_form_toggle_password_visibility")}
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
onMouseDown={(ev) => ev.preventDefault()}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityOff /> : <Visibility />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
disabled={username === "" || password === ""}
|
||||||
|
sx={{mt: 2, mb: 2}}
|
||||||
|
>
|
||||||
|
{t("signup_form_button_submit")}
|
||||||
|
</Button>
|
||||||
|
{error &&
|
||||||
|
<Box sx={{
|
||||||
|
mb: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<WarningAmberIcon color="error" sx={{mr: 1}}/>
|
||||||
|
<Typography sx={{color: 'error.main'}}>{error}</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
{config.enable_login &&
|
||||||
|
<Typography sx={{mb: 4}}>
|
||||||
|
<NavLink to={routes.login} variant="body1">
|
||||||
|
{t("signup_already_have_account")}
|
||||||
|
</NavLink>
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
</AvatarBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Signup;
|
||||||
31
web/src/components/SiteLayout.js
Normal file
31
web/src/components/SiteLayout.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {NavLink} from "react-router-dom";
|
||||||
|
import routes from "./routes";
|
||||||
|
import CloudOutlinedIcon from '@mui/icons-material/CloudOutlined';
|
||||||
|
import GitHubIcon from '@mui/icons-material/GitHub';
|
||||||
|
import {Link} from "@mui/material";
|
||||||
|
|
||||||
|
const SiteLayout = (props) => {
|
||||||
|
return (
|
||||||
|
<div id="site">
|
||||||
|
<nav id="header">
|
||||||
|
<div id="headerBox">
|
||||||
|
<img id="logo" src="static/img/ntfy.png" alt="logo"/>
|
||||||
|
<div id="name">ntfy</div>
|
||||||
|
<ol id="menu">
|
||||||
|
<li><NavLink to={routes.home}>Features</NavLink></li>
|
||||||
|
<li><NavLink to={routes.pricing}>Pricing</NavLink></li>
|
||||||
|
<li><NavLink to="/docs" reloadDocument={true}>Docs</NavLink></li>
|
||||||
|
<li><Link href="https://github.com/binwiederhier/ntfy" reloadDocument={true}><GitHubIcon fontSize="small" sx={{verticalAlign: "text-top"}}/> Forever open</Link></li>
|
||||||
|
<li><NavLink to={routes.app}><CloudOutlinedIcon fontSize="small" sx={{verticalAlign: "text-top"}}/> Open app</NavLink></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div id="main">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SiteLayout;
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {useState} from 'react';
|
import {useContext, useState} from 'react';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import Dialog from '@mui/material/Dialog';
|
import Dialog from '@mui/material/Dialog';
|
||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
|
import {Autocomplete, Checkbox, FormControlLabel, FormGroup, useMediaQuery} from "@mui/material";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import api from "../app/Api";
|
import api from "../app/Api";
|
||||||
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
|
import {randomAlphanumericString, topicUrl, validTopic, validUrl} from "../app/utils";
|
||||||
@@ -15,6 +15,11 @@ import subscriptionManager from "../app/SubscriptionManager";
|
|||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
|
import accountApi, {TopicReservedError, UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import ReserveTopicSelect from "./ReserveTopicSelect";
|
||||||
|
import {AccountContext} from "./App";
|
||||||
|
|
||||||
const publicBaseUrl = "https://ntfy.sh";
|
const publicBaseUrl = "https://ntfy.sh";
|
||||||
|
|
||||||
@@ -23,12 +28,30 @@ const SubscribeDialog = (props) => {
|
|||||||
const [topic, setTopic] = useState("");
|
const [topic, setTopic] = useState("");
|
||||||
const [showLoginPage, setShowLoginPage] = useState(false);
|
const [showLoginPage, setShowLoginPage] = useState(false);
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
const handleSuccess = async () => {
|
const handleSuccess = async () => {
|
||||||
const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin;
|
console.log(`[SubscribeDialog] Subscribing to topic ${topic}`);
|
||||||
|
const actualBaseUrl = (baseUrl) ? baseUrl : config.base_url;
|
||||||
const subscription = await subscriptionManager.add(actualBaseUrl, topic);
|
const subscription = await subscriptionManager.add(actualBaseUrl, topic);
|
||||||
|
if (session.exists()) {
|
||||||
|
try {
|
||||||
|
const remoteSubscription = await accountApi.addSubscription({
|
||||||
|
base_url: actualBaseUrl,
|
||||||
|
topic: topic
|
||||||
|
});
|
||||||
|
await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id);
|
||||||
|
await accountApi.sync();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[SubscribeDialog] Subscribing to topic ${topic} failed`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
poller.pollInBackground(subscription); // Dangle!
|
poller.pollInBackground(subscription); // Dangle!
|
||||||
props.onSuccess(subscription);
|
props.onSuccess(subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
|
||||||
{!showLoginPage && <SubscribePage
|
{!showLoginPage && <SubscribePage
|
||||||
@@ -53,17 +76,25 @@ const SubscribeDialog = (props) => {
|
|||||||
|
|
||||||
const SubscribePage = (props) => {
|
const SubscribePage = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { account } = useContext(AccountContext);
|
||||||
|
const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
|
||||||
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
|
const [anotherServerVisible, setAnotherServerVisible] = useState(false);
|
||||||
const [errorText, setErrorText] = useState("");
|
const [errorText, setErrorText] = useState("");
|
||||||
const baseUrl = (anotherServerVisible) ? props.baseUrl : window.location.origin;
|
const [everyone, setEveryone] = useState("deny-all");
|
||||||
|
const baseUrl = (anotherServerVisible) ? props.baseUrl : config.base_url;
|
||||||
const topic = props.topic;
|
const topic = props.topic;
|
||||||
const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic));
|
const existingTopicUrls = props.subscriptions.map(s => topicUrl(s.baseUrl, s.topic));
|
||||||
const existingBaseUrls = Array.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
|
const existingBaseUrls = Array
|
||||||
.filter(s => s !== window.location.origin);
|
.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
|
||||||
|
.filter(s => s !== config.base_url);
|
||||||
|
const reserveTopicEnabled = session.exists() && account?.role === "user" && (account?.stats.reservations_remaining || 0) > 0;
|
||||||
|
|
||||||
const handleSubscribe = async () => {
|
const handleSubscribe = async () => {
|
||||||
const user = await userManager.get(baseUrl); // May be undefined
|
const user = await userManager.get(baseUrl); // May be undefined
|
||||||
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
|
const username = (user) ? user.username : t("subscribe_dialog_error_user_anonymous");
|
||||||
const success = await api.auth(baseUrl, topic, user);
|
|
||||||
|
// Check read access to topic
|
||||||
|
const success = await api.topicAuth(baseUrl, topic, user);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -74,22 +105,43 @@ const SubscribePage = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reserve topic (if requested)
|
||||||
|
if (session.exists() && baseUrl === config.base_url && reserveTopicVisible) {
|
||||||
|
console.log(`[SubscribeDialog] Reserving topic ${topic} with everyone access ${everyone}`);
|
||||||
|
try {
|
||||||
|
await accountApi.upsertReservation(topic, everyone);
|
||||||
|
// Account sync later after it was added
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[SubscribeDialog] Error reserving topic`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
} else if ((e instanceof TopicReservedError)) {
|
||||||
|
setErrorText(t("subscribe_dialog_error_topic_already_reserved"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
|
console.log(`[SubscribeDialog] Successful login to ${topicUrl(baseUrl, topic)} for user ${username}`);
|
||||||
props.onSuccess();
|
props.onSuccess();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUseAnotherChanged = (e) => {
|
const handleUseAnotherChanged = (e) => {
|
||||||
props.setBaseUrl("");
|
props.setBaseUrl("");
|
||||||
setAnotherServerVisible(e.target.checked);
|
setAnotherServerVisible(e.target.checked);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscribeButtonEnabled = (() => {
|
const subscribeButtonEnabled = (() => {
|
||||||
if (anotherServerVisible) {
|
if (anotherServerVisible) {
|
||||||
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
|
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(baseUrl, topic));
|
||||||
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
|
return validTopic(topic) && validUrl(baseUrl) && !isExistingTopicUrl;
|
||||||
} else {
|
} else {
|
||||||
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(window.location.origin, topic));
|
const isExistingTopicUrl = existingTopicUrls.includes(topicUrl(config.base_url, topic));
|
||||||
return validTopic(topic) && !isExistingTopicUrl;
|
return validTopic(topic) && !isExistingTopicUrl;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const updateBaseUrl = (ev, newVal) => {
|
const updateBaseUrl = (ev, newVal) => {
|
||||||
if (validUrl(newVal)) {
|
if (validUrl(newVal)) {
|
||||||
props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?://
|
props.setBaseUrl(newVal.replace(/\/$/, '')); // strip trailing slash after https?://
|
||||||
@@ -97,6 +149,7 @@ const SubscribePage = (props) => {
|
|||||||
props.setBaseUrl(newVal);
|
props.setBaseUrl(newVal);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
|
<DialogTitle>{t("subscribe_dialog_subscribe_title")}</DialogTitle>
|
||||||
@@ -104,7 +157,7 @@ const SubscribePage = (props) => {
|
|||||||
<DialogContentText>
|
<DialogContentText>
|
||||||
{t("subscribe_dialog_subscribe_description")}
|
{t("subscribe_dialog_subscribe_description")}
|
||||||
</DialogContentText>
|
</DialogContentText>
|
||||||
<div style={{display: 'flex'}} role="row">
|
<div style={{display: 'flex', paddingBottom: "8px"}} role="row">
|
||||||
<TextField
|
<TextField
|
||||||
autoFocus
|
autoFocus
|
||||||
margin="dense"
|
margin="dense"
|
||||||
@@ -119,37 +172,64 @@ const SubscribePage = (props) => {
|
|||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
|
"aria-label": t("subscribe_dialog_subscribe_topic_placeholder")
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}>
|
<Button onClick={() => {props.setTopic(randomAlphanumericString(16))}} style={{flexShrink: "0", marginTop: "0.5em"}}>
|
||||||
{t("subscribe_dialog_subscribe_button_generate_topic_name")}
|
{t("subscribe_dialog_subscribe_button_generate_topic_name")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormControlLabel
|
{config.enable_reservations && session.exists() && !anotherServerVisible &&
|
||||||
sx={{pt: 1}}
|
<FormGroup>
|
||||||
control={
|
<FormControlLabel
|
||||||
<Checkbox
|
|
||||||
onChange={handleUseAnotherChanged}
|
|
||||||
inputProps={{
|
|
||||||
"aria-label": t("subscribe_dialog_subscribe_use_another_label")
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={t("subscribe_dialog_subscribe_use_another_label")} />
|
|
||||||
{anotherServerVisible && <Autocomplete
|
|
||||||
freeSolo
|
|
||||||
options={existingBaseUrls}
|
|
||||||
sx={{ maxWidth: 400 }}
|
|
||||||
inputValue={props.baseUrl}
|
|
||||||
onInputChange={updateBaseUrl}
|
|
||||||
renderInput={ (params) =>
|
|
||||||
<TextField
|
|
||||||
{...params}
|
|
||||||
placeholder={window.location.origin}
|
|
||||||
variant="standard"
|
variant="standard"
|
||||||
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
|
control={
|
||||||
|
<Checkbox
|
||||||
|
fullWidth
|
||||||
|
disabled={!reserveTopicEnabled}
|
||||||
|
checked={reserveTopicVisible}
|
||||||
|
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("subscription_settings_dialog_reserve_topic_label")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t("subscription_settings_dialog_reserve_topic_label")}
|
||||||
/>
|
/>
|
||||||
}
|
{reserveTopicVisible &&
|
||||||
/>}
|
<ReserveTopicSelect
|
||||||
|
value={everyone}
|
||||||
|
onChange={setEveryone}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</FormGroup>
|
||||||
|
}
|
||||||
|
{!reserveTopicVisible &&
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
onChange={handleUseAnotherChanged}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("subscribe_dialog_subscribe_use_another_label")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t("subscribe_dialog_subscribe_use_another_label")}/>
|
||||||
|
{anotherServerVisible && <Autocomplete
|
||||||
|
freeSolo
|
||||||
|
options={existingBaseUrls}
|
||||||
|
inputValue={props.baseUrl}
|
||||||
|
onInputChange={updateBaseUrl}
|
||||||
|
renderInput={(params) =>
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={config.base_url}
|
||||||
|
variant="standard"
|
||||||
|
aria-label={t("subscribe_dialog_subscribe_base_url_label")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>}
|
||||||
|
</FormGroup>
|
||||||
|
}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter status={errorText}>
|
<DialogFooter status={errorText}>
|
||||||
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
|
<Button onClick={props.onCancel}>{t("subscribe_dialog_subscribe_button_cancel")}</Button>
|
||||||
@@ -164,11 +244,11 @@ const LoginPage = (props) => {
|
|||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [errorText, setErrorText] = useState("");
|
const [errorText, setErrorText] = useState("");
|
||||||
const baseUrl = (props.baseUrl) ? props.baseUrl : window.location.origin;
|
const baseUrl = (props.baseUrl) ? props.baseUrl : config.base_url;
|
||||||
const topic = props.topic;
|
const topic = props.topic;
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
const user = {baseUrl, username, password};
|
const user = {baseUrl, username, password};
|
||||||
const success = await api.auth(baseUrl, topic, user);
|
const success = await api.topicAuth(baseUrl, topic, user);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
console.log(`[SubscribeDialog] Login to ${topicUrl(baseUrl, topic)} failed for user ${username}`);
|
||||||
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
setErrorText(t("subscribe_dialog_error_user_not_authorized", { username: username }));
|
||||||
|
|||||||
@@ -6,25 +6,56 @@ import Dialog from '@mui/material/Dialog';
|
|||||||
import DialogContent from '@mui/material/DialogContent';
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
import DialogContentText from '@mui/material/DialogContentText';
|
import DialogContentText from '@mui/material/DialogContentText';
|
||||||
import DialogTitle from '@mui/material/DialogTitle';
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
import {Autocomplete, Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
|
import {Checkbox, FormControlLabel, useMediaQuery} from "@mui/material";
|
||||||
import theme from "./theme";
|
import theme from "./theme";
|
||||||
import api from "../app/Api";
|
|
||||||
import {topicUrl, validTopic, validUrl} from "../app/utils";
|
|
||||||
import userManager from "../app/UserManager";
|
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import poller from "../app/Poller";
|
|
||||||
import DialogFooter from "./DialogFooter";
|
import DialogFooter from "./DialogFooter";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
|
import accountApi, {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
|
import ReserveTopicSelect from "./ReserveTopicSelect";
|
||||||
|
|
||||||
const SubscriptionSettingsDialog = (props) => {
|
const SubscriptionSettingsDialog = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const subscription = props.subscription;
|
const subscription = props.subscription;
|
||||||
|
const [reserveTopicVisible, setReserveTopicVisible] = useState(!!subscription.reservation);
|
||||||
|
const [everyone, setEveryone] = useState(subscription.reservation?.everyone || "deny-all");
|
||||||
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
|
const [displayName, setDisplayName] = useState(subscription.displayName ?? "");
|
||||||
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
// Apply locally
|
||||||
await subscriptionManager.setDisplayName(subscription.id, displayName);
|
await subscriptionManager.setDisplayName(subscription.id, displayName);
|
||||||
|
|
||||||
|
// Apply remotely
|
||||||
|
if (session.exists() && subscription.remoteId) {
|
||||||
|
try {
|
||||||
|
// Display name
|
||||||
|
console.log(`[SubscriptionSettingsDialog] Updating subscription display name to ${displayName}`);
|
||||||
|
await accountApi.updateSubscription(subscription.remoteId, { display_name: displayName });
|
||||||
|
|
||||||
|
// Reservation
|
||||||
|
if (reserveTopicVisible) {
|
||||||
|
await accountApi.upsertReservation(subscription.topic, everyone);
|
||||||
|
} else if (!reserveTopicVisible && subscription.reservation) { // Was removed
|
||||||
|
await accountApi.deleteReservation(subscription.topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync account
|
||||||
|
await accountApi.sync();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[SubscriptionSettingsDialog] Error updating subscription`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME handle 409
|
||||||
|
}
|
||||||
|
}
|
||||||
props.onClose();
|
props.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
<Dialog open={props.open} onClose={props.onClose} fullScreen={fullScreen}>
|
||||||
<DialogTitle>{t("subscription_settings_dialog_title")}</DialogTitle>
|
<DialogTitle>{t("subscription_settings_dialog_title")}</DialogTitle>
|
||||||
@@ -47,6 +78,31 @@ const SubscriptionSettingsDialog = (props) => {
|
|||||||
"aria-label": t("subscription_settings_dialog_display_name_placeholder")
|
"aria-label": t("subscription_settings_dialog_display_name_placeholder")
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{config.enable_reservations && session.exists() &&
|
||||||
|
<>
|
||||||
|
<FormControlLabel
|
||||||
|
fullWidth
|
||||||
|
variant="standard"
|
||||||
|
sx={{pt: 1}}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={reserveTopicVisible}
|
||||||
|
onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
|
||||||
|
inputProps={{
|
||||||
|
"aria-label": t("subscription_settings_dialog_reserve_topic_label")
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={t("subscription_settings_dialog_reserve_topic_label")}
|
||||||
|
/>
|
||||||
|
{reserveTopicVisible &&
|
||||||
|
<ReserveTopicSelect
|
||||||
|
value={everyone}
|
||||||
|
onChange={setEveryone}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={props.onClose}>{t("subscription_settings_button_cancel")}</Button>
|
<Button onClick={props.onClose}>{t("subscription_settings_button_cancel")}</Button>
|
||||||
|
|||||||
265
web/src/components/UpgradeDialog.js
Normal file
265
web/src/components/UpgradeDialog.js
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import {Alert, CardActionArea, CardContent, ListItem, useMediaQuery} from "@mui/material";
|
||||||
|
import theme from "./theme";
|
||||||
|
import DialogFooter from "./DialogFooter";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import accountApi, {TopicReservedError, UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import routes from "./routes";
|
||||||
|
import {useContext, useEffect, useState} from "react";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {AccountContext} from "./App";
|
||||||
|
import {formatBytes, formatNumber, formatShortDate} from "../app/utils";
|
||||||
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
|
import List from "@mui/material/List";
|
||||||
|
import {Check} from "@mui/icons-material";
|
||||||
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import {NavLink} from "react-router-dom";
|
||||||
|
|
||||||
|
const UpgradeDialog = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { account } = useContext(AccountContext); // May be undefined!
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
const [tiers, setTiers] = useState(null);
|
||||||
|
const [newTierCode, setNewTierCode] = useState(account?.tier?.code); // May be undefined
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [errorText, setErrorText] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setTiers(await accountApi.billingTiers());
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!tiers) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiersMap = Object.assign(...tiers.map(tier => ({[tier.code]: tier})));
|
||||||
|
const newTier = tiersMap[newTierCode]; // May be undefined
|
||||||
|
const currentTier = account?.tier; // May be undefined
|
||||||
|
const currentTierCode = currentTier?.code; // May be undefined
|
||||||
|
|
||||||
|
// Figure out buttons, labels and the submit action
|
||||||
|
let submitAction, submitButtonLabel, banner;
|
||||||
|
if (!account) {
|
||||||
|
submitButtonLabel = t("account_upgrade_dialog_button_redirect_signup");
|
||||||
|
submitAction = Action.REDIRECT_SIGNUP;
|
||||||
|
banner = null;
|
||||||
|
} else if (currentTierCode === newTierCode) {
|
||||||
|
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
|
||||||
|
submitAction = null;
|
||||||
|
banner = (currentTierCode) ? Banner.PRORATION_INFO : null;
|
||||||
|
} else if (!currentTierCode) {
|
||||||
|
submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
|
||||||
|
submitAction = Action.CREATE_SUBSCRIPTION;
|
||||||
|
banner = null;
|
||||||
|
} else if (!newTierCode) {
|
||||||
|
submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription");
|
||||||
|
submitAction = Action.CANCEL_SUBSCRIPTION;
|
||||||
|
banner = Banner.CANCEL_WARNING;
|
||||||
|
} else {
|
||||||
|
submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
|
||||||
|
submitAction = Action.UPDATE_SUBSCRIPTION;
|
||||||
|
banner = Banner.PRORATION_INFO;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exceptional conditions
|
||||||
|
if (loading) {
|
||||||
|
submitAction = null;
|
||||||
|
} else if (newTier?.code && account?.reservations.length > newTier?.limits.reservations) {
|
||||||
|
submitAction = null;
|
||||||
|
banner = Banner.RESERVATIONS_WARNING;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (submitAction === Action.REDIRECT_SIGNUP) {
|
||||||
|
window.location.href = routes.signup;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
if (submitAction === Action.CREATE_SUBSCRIPTION) {
|
||||||
|
const response = await accountApi.createBillingSubscription(newTierCode);
|
||||||
|
window.location.href = response.redirect_url;
|
||||||
|
} else if (submitAction === Action.UPDATE_SUBSCRIPTION) {
|
||||||
|
await accountApi.updateBillingSubscription(newTierCode);
|
||||||
|
} else if (submitAction === Action.CANCEL_SUBSCRIPTION) {
|
||||||
|
await accountApi.deleteBillingSubscription();
|
||||||
|
}
|
||||||
|
props.onCancel();
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[UpgradeDialog] Error changing billing subscription`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
// FIXME show error
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={props.open}
|
||||||
|
onClose={props.onCancel}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
>
|
||||||
|
<DialogTitle>{t("account_upgrade_dialog_title")}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
marginBottom: "8px",
|
||||||
|
width: "100%"
|
||||||
|
}}>
|
||||||
|
{tiers.map(tier =>
|
||||||
|
<TierCard
|
||||||
|
key={`tierCard${tier.code || '_free'}`}
|
||||||
|
tier={tier}
|
||||||
|
current={currentTierCode === tier.code} // tier.code or currentTierCode may be undefined!
|
||||||
|
selected={newTierCode === tier.code} // tier.code may be undefined!
|
||||||
|
onClick={() => setNewTierCode(tier.code)} // tier.code may be undefined!
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{banner === Banner.CANCEL_WARNING &&
|
||||||
|
<Alert severity="warning">
|
||||||
|
<Trans
|
||||||
|
i18nKey="account_upgrade_dialog_cancel_warning"
|
||||||
|
values={{ date: formatShortDate(account?.billing?.paid_until || 0) }} />
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
{banner === Banner.PRORATION_INFO &&
|
||||||
|
<Alert severity="info">
|
||||||
|
<Trans i18nKey="account_upgrade_dialog_proration_info" />
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
{banner === Banner.RESERVATIONS_WARNING &&
|
||||||
|
<Alert severity="warning">
|
||||||
|
<Trans
|
||||||
|
i18nKey="account_upgrade_dialog_reservations_warning"
|
||||||
|
count={account?.reservations.length - newTier?.limits.reservations}
|
||||||
|
components={{
|
||||||
|
Link: <NavLink to={routes.settings}/>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogFooter status={errorText}>
|
||||||
|
<Button onClick={props.onCancel}>{t("account_upgrade_dialog_button_cancel")}</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={!submitAction}>{submitButtonLabel}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TierCard = (props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const tier = props.tier;
|
||||||
|
let cardStyle, labelStyle, labelText;
|
||||||
|
if (props.selected) {
|
||||||
|
cardStyle = { background: "#eee", border: "2px solid #338574" };
|
||||||
|
labelStyle = { background: "#338574", color: "white" };
|
||||||
|
labelText = t("account_upgrade_dialog_tier_selected_label");
|
||||||
|
} else if (props.current) {
|
||||||
|
cardStyle = { border: "2px solid #eee" };
|
||||||
|
labelStyle = { background: "#eee", color: "black" };
|
||||||
|
labelText = t("account_upgrade_dialog_tier_current_label");
|
||||||
|
} else {
|
||||||
|
cardStyle = { border: "2px solid transparent" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{
|
||||||
|
m: "7px",
|
||||||
|
minWidth: "190px",
|
||||||
|
maxWidth: "250px",
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexBasis: 0,
|
||||||
|
borderRadius: "3px",
|
||||||
|
"&:first-of-type": { ml: 0 },
|
||||||
|
"&:last-of-type": { mr: 0 },
|
||||||
|
...cardStyle
|
||||||
|
}}>
|
||||||
|
<Card sx={{ height: "100%" }}>
|
||||||
|
<CardActionArea sx={{ height: "100%" }}>
|
||||||
|
<CardContent onClick={props.onClick} sx={{ height: "100%" }}>
|
||||||
|
{labelStyle &&
|
||||||
|
<div style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "0",
|
||||||
|
right: "15px",
|
||||||
|
padding: "2px 10px",
|
||||||
|
borderRadius: "3px",
|
||||||
|
...labelStyle
|
||||||
|
}}>{labelText}</div>
|
||||||
|
}
|
||||||
|
<Typography variant="h5" component="div">
|
||||||
|
{tier.name || t("account_usage_tier_free")}
|
||||||
|
</Typography>
|
||||||
|
<List dense>
|
||||||
|
{tier.limits.reservations > 0 && <FeatureItem>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}</FeatureItem>}
|
||||||
|
<FeatureItem>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages) })}</FeatureItem>
|
||||||
|
<FeatureItem>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails) })}</FeatureItem>
|
||||||
|
<FeatureItem>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</FeatureItem>
|
||||||
|
<FeatureItem>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</FeatureItem>
|
||||||
|
</List>
|
||||||
|
{tier.price &&
|
||||||
|
<Typography variant="subtitle1" sx={{fontWeight: 500}}>
|
||||||
|
{tier.price} / month
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
</CardContent>
|
||||||
|
</CardActionArea>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureItem = (props) => {
|
||||||
|
return (
|
||||||
|
<ListItem disableGutters sx={{m: 0, p: 0}}>
|
||||||
|
<ListItemIcon sx={{minWidth: "24px"}}>
|
||||||
|
<Check fontSize="small" sx={{ color: "#338574" }}/>
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
sx={{mt: "2px", mb: "2px"}}
|
||||||
|
primary={
|
||||||
|
<Typography variant="body2">
|
||||||
|
{props.children}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Action = {
|
||||||
|
REDIRECT_SIGNUP: 1,
|
||||||
|
CREATE_SUBSCRIPTION: 2,
|
||||||
|
UPDATE_SUBSCRIPTION: 3,
|
||||||
|
CANCEL_SUBSCRIPTION: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
const Banner = {
|
||||||
|
CANCEL_WARNING: 1,
|
||||||
|
PRORATION_INFO: 2,
|
||||||
|
RESERVATIONS_WARNING: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default UpgradeDialog;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useNavigate, useParams} from "react-router-dom";
|
import {useNavigate, useParams} from "react-router-dom";
|
||||||
import {useEffect, useState} from "react";
|
import {useContext, useEffect, useState} from "react";
|
||||||
import subscriptionManager from "../app/SubscriptionManager";
|
import subscriptionManager from "../app/SubscriptionManager";
|
||||||
import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils";
|
import {disallowedTopic, expandSecureUrl, topicUrl} from "../app/utils";
|
||||||
import notifier from "../app/Notifier";
|
import notifier from "../app/Notifier";
|
||||||
@@ -7,6 +7,10 @@ import routes from "./routes";
|
|||||||
import connectionManager from "../app/ConnectionManager";
|
import connectionManager from "../app/ConnectionManager";
|
||||||
import poller from "../app/Poller";
|
import poller from "../app/Poller";
|
||||||
import pruner from "../app/Pruner";
|
import pruner from "../app/Pruner";
|
||||||
|
import session from "../app/Session";
|
||||||
|
import {UnauthorizedError} from "../app/AccountApi";
|
||||||
|
import accountApi from "../app/AccountApi";
|
||||||
|
import {AccountContext} from "./App";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
||||||
@@ -17,6 +21,30 @@ export const useConnectionListeners = (subscriptions, users) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const handleMessage = async (subscriptionId, message) => {
|
||||||
|
const subscription = await subscriptionManager.get(subscriptionId);
|
||||||
|
if (subscription.internal) {
|
||||||
|
await handleInternalMessage(message);
|
||||||
|
} else {
|
||||||
|
await handleNotification(subscriptionId, message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInternalMessage = async (message) => {
|
||||||
|
console.log(`[ConnectionListener] Received message on sync topic`, message.message);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.message);
|
||||||
|
if (data.event === "sync") {
|
||||||
|
console.log(`[ConnectionListener] Triggering account sync`);
|
||||||
|
await accountApi.sync();
|
||||||
|
} else {
|
||||||
|
console.log(`[ConnectionListener] Unknown message type. Doing nothing.`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[ConnectionListener] Error parsing sync topic message`, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleNotification = async (subscriptionId, notification) => {
|
const handleNotification = async (subscriptionId, notification) => {
|
||||||
const added = await subscriptionManager.addNotification(subscriptionId, notification);
|
const added = await subscriptionManager.addNotification(subscriptionId, notification);
|
||||||
if (added) {
|
if (added) {
|
||||||
@@ -25,10 +53,10 @@ export const useConnectionListeners = (subscriptions, users) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
connectionManager.registerStateListener(subscriptionManager.updateState);
|
connectionManager.registerStateListener(subscriptionManager.updateState);
|
||||||
connectionManager.registerNotificationListener(handleNotification);
|
connectionManager.registerMessageListener(handleMessage);
|
||||||
return () => {
|
return () => {
|
||||||
connectionManager.resetStateListener();
|
connectionManager.resetStateListener();
|
||||||
connectionManager.resetNotificationListener();
|
connectionManager.resetMessageListener();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// We have to disable dep checking for "navigate". This is fine, it never changes.
|
// We have to disable dep checking for "navigate". This is fine, it never changes.
|
||||||
@@ -57,10 +85,24 @@ export const useAutoSubscribe = (subscriptions, selected) => {
|
|||||||
setHasRun(true);
|
setHasRun(true);
|
||||||
const eligible = params.topic && !selected && !disallowedTopic(params.topic);
|
const eligible = params.topic && !selected && !disallowedTopic(params.topic);
|
||||||
if (eligible) {
|
if (eligible) {
|
||||||
const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : window.location.origin;
|
const baseUrl = (params.baseUrl) ? expandSecureUrl(params.baseUrl) : config.base_url;
|
||||||
console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
|
console.log(`[App] Auto-subscribing to ${topicUrl(baseUrl, params.topic)}`);
|
||||||
(async () => {
|
(async () => {
|
||||||
const subscription = await subscriptionManager.add(baseUrl, params.topic);
|
const subscription = await subscriptionManager.add(baseUrl, params.topic);
|
||||||
|
if (session.exists()) {
|
||||||
|
try {
|
||||||
|
const remoteSubscription = await accountApi.addSubscription({
|
||||||
|
base_url: baseUrl,
|
||||||
|
topic: params.topic
|
||||||
|
});
|
||||||
|
await subscriptionManager.setRemoteId(subscription.id, remoteSubscription.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[App] Auto-subscribing failed`, e);
|
||||||
|
if ((e instanceof UnauthorizedError)) {
|
||||||
|
session.resetAndRedirect(routes.login);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
poller.pollInBackground(subscription); // Dangle!
|
poller.pollInBackground(subscription); // Dangle!
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -76,5 +118,16 @@ export const useBackgroundProcesses = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poller.startWorker();
|
poller.startWorker();
|
||||||
pruner.startWorker();
|
pruner.startWorker();
|
||||||
|
accountApi.startWorker();
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAccountListener = (setAccount) => {
|
||||||
|
useEffect(() => {
|
||||||
|
accountApi.registerListener(setAccount);
|
||||||
|
accountApi.sync(); // Dangle
|
||||||
|
return () => {
|
||||||
|
accountApi.resetListener();
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user