"ntfy tier" CLI command

pull/526/head
binwiederhier 2023-02-07 12:02:25 -05:00
parent e3b39f670f
commit a32e8abc12
8 changed files with 140 additions and 40 deletions

View File

@ -8,20 +8,27 @@ import (
"os" "os"
"os/exec" "os/exec"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
) )
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
testMessage := util.RandomString(10) testMessage := util.RandomString(10)
app, _, _, _ := newTestApp() app, _, _, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage})) require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))
time.Sleep(3 * time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
app2, _, stdout, _ := newTestApp() _, err := util.Retry(func() (*int, error) {
require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"})) app2, _, stdout, _ := newTestApp()
require.Contains(t, stdout.String(), testMessage) if err := app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"}); err != nil {
return nil, err
}
if !strings.Contains(stdout.String(), testMessage) {
return nil, fmt.Errorf("test message %s not found in topic", testMessage)
}
return util.Int(1), nil
}, time.Second, 2*time.Second, 5*time.Second) // Since #502, ntfy.sh writes messages to the cache asynchronously, after a timeout of ~1.5s
require.Nil(t, err)
} }
func TestCLI_Publish_Subscribe_Poll(t *testing.T) { func TestCLI_Publish_Subscribe_Poll(t *testing.T) {

View File

@ -56,8 +56,27 @@ var cmdTier = &cli.Command{
&cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"}, &cli.StringFlag{Name: "attachment-bandwidth-limit", Value: defaultAttachmentBandwidthLimit, Usage: "daily bandwidth limit for attachment uploads/downloads"},
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"}, &cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
}, },
Description: ` Description: `Add a new tier to the ntfy user database.
FIXME
Tiers can be used to grant users higher limits based, such as daily message limits, attachment size, or
make it possible for users to reserve topics.
This is a server-only command. It directly reads from the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Examples:
ntfy tier add pro # Add tier with code "pro", using the defaults
ntfy tier add \ # Add a tier with custom limits
--name="Pro" \
--message-limit=10000 \
--message-expiry-duration=24h \
--email-limit=50 \
--reservation-limit=10 \
--attachment-file-size-limit=100M \
--attachment-total-size-limit=1G \
--attachment-expiry-duration=12h \
--attachment-bandwidth-limit=5G \
pro
`, `,
}, },
{ {
@ -78,8 +97,20 @@ FIXME
&cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"}, &cli.StringFlag{Name: "attachment-bandwidth-limit", Usage: "daily bandwidth limit for attachment uploads/downloads"},
&cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"}, &cli.StringFlag{Name: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"},
}, },
Description: ` Description: `Updates a tier to change the limits.
FIXME
After updating a tier, you may have to restart the ntfy server to apply them
to all visitors.
This is a server-only command. It directly reads from the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Examples:
ntfy tier change --name="Pro" pro # Update the name of an existing tier
ntfy tier change \ # Update multiple limits and fields
--message-expiry-duration=24h \
--stripe-price-id=price_1234 \
pro
`, `,
}, },
{ {
@ -88,8 +119,16 @@ FIXME
Usage: "Removes a tier", Usage: "Removes a tier",
UsageText: "ntfy tier remove CODE", UsageText: "ntfy tier remove CODE",
Action: execTierDel, Action: execTierDel,
Description: ` Description: `Remove a tier from the ntfy user database.
FIXME
You cannot remove a tier if there are users associated with a tier. Use "ntfy user change-tier"
to remove or switch their tier first.
This is a server-only command. It directly reads from the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
Example:
ntfy tier del pro
`, `,
}, },
{ {
@ -97,22 +136,26 @@ FIXME
Aliases: []string{"l"}, Aliases: []string{"l"},
Usage: "Shows a list of tiers", Usage: "Shows a list of tiers",
Action: execTierList, Action: execTierList,
Description: ` Description: `Shows a list of all configured tiers.
FIXME
This is a server-only command. It directly reads from the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
`, `,
}, },
}, },
Description: `Manage tier of the ntfy server. Description: `Manage tiers of the ntfy server.
The command allows you to add/remove/change tier in the ntfy user database. Tiers are used The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used
to grant users higher limits based on their tier. to grant users higher limits, such as daily message limits, attachment size, or make it
possible for users to reserve topics.
This is a server-only command. It directly manages the user.db as defined in the server config This is a server-only command. It directly manages the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer file server.yml. The command only works if 'auth-file' is properly defined.
to the related command 'ntfy access'.
FIXME
Examples:
ntfy tier add pro # Add tier with code "pro", using the defaults
ntfy tier change --name="Pro" pro # Update the name of an existing tier
ntfy tier del pro # Delete an existing tier
`, `,
} }

46
cmd/tier_test.go 100644
View File

@ -0,0 +1,46 @@
package cmd
import (
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/server"
"heckel.io/ntfy/test"
"testing"
)
func TestCLI_Tier_AddListChangeDelete(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, _, _, stderr := newTestApp()
require.Nil(t, runTierCommand(app, conf, "add", "--name", "Pro", "--message-limit", "1234", "pro"))
require.Contains(t, stderr.String(), "tier added\n\ntier pro (id: ti_")
err := runTierCommand(app, conf, "add", "pro")
require.NotNil(t, err)
require.Equal(t, "tier pro already exists", err.Error())
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "list"))
require.Contains(t, stderr.String(), "tier pro (id: ti_")
require.Contains(t, stderr.String(), "- Name: Pro")
require.Contains(t, stderr.String(), "- Message limit: 1234")
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "change", "--message-limit", "999", "pro"))
require.Contains(t, stderr.String(), "- Message limit: 999")
app, _, _, stderr = newTestApp()
require.Nil(t, runTierCommand(app, conf, "remove", "pro"))
require.Contains(t, stderr.String(), "tier pro removed")
}
func runTierCommand(app *cli.App, conf *server.Config, args ...string) error {
userArgs := []string{
"ntfy",
"tier",
"--auth-file=" + conf.AuthFile,
"--auth-default-access=" + conf.AuthDefault.String(),
}
return app.Run(append(userArgs, args...))
}

View File

@ -139,22 +139,22 @@ Example:
Action: execUserList, Action: execUserList,
Description: `Shows a list of all configured users, including the everyone ('*') user. Description: `Shows a list of all configured users, including the everyone ('*') user.
This is a server-only command. It directly reads from the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
This command is an alias to calling 'ntfy access' (display access control list). This command is an alias to calling 'ntfy access' (display access control list).
This is a server-only command. It directly reads from the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
`, `,
}, },
}, },
Description: `Manage users of the ntfy server. Description: `Manage users of the ntfy server.
The command allows you to add/remove/change users in the ntfy user database, as well as change
passwords or roles.
This is a server-only command. It directly manages the user.db as defined in the server config This is a server-only command. It directly manages the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
to the related command 'ntfy access'. to the related command 'ntfy access'.
The command allows you to add/remove/change users in the ntfy user database, as well as change
passwords or roles.
Examples: Examples:
ntfy user list # Shows list of users (alias: 'ntfy access') ntfy user list # Shows list of users (alias: 'ntfy access')
ntfy user add phil # Add regular user phil ntfy user add phil # Add regular user phil

View File

@ -11,13 +11,14 @@ import (
) )
const ( const (
tagField = "tag" tagField = "tag"
errorField = "error" errorField = "error"
timestampFormat = "2006-01-02T15:04:05.999Z07:00"
) )
// Event represents a single log event // Event represents a single log event
type Event struct { type Event struct {
Timestamp int64 `json:"time"` Timestamp string `json:"time"`
Level Level `json:"level"` Level Level `json:"level"`
Message string `json:"message"` Message string `json:"message"`
fields Context fields Context
@ -25,8 +26,9 @@ type Event struct {
// newEvent creates a new log event // newEvent creates a new log event
func newEvent() *Event { func newEvent() *Event {
now := time.Now()
return &Event{ return &Event{
Timestamp: time.Now().UnixMilli(), Timestamp: now.Format(timestampFormat),
fields: make(Context), fields: make(Context),
} }
} }
@ -70,8 +72,8 @@ func (e *Event) Tag(tag string) *Event {
} }
// Time sets the time field // Time sets the time field
func (e *Event) Time(time time.Time) *Event { func (e *Event) Time(t time.Time) *Event {
e.Timestamp = time.UnixMilli() e.Timestamp = t.Format(timestampFormat)
return e return e
} }

View File

@ -35,7 +35,7 @@ func TestLog_TagContextFieldFields(t *testing.T) {
Tag("mytag"). Tag("mytag").
Field("field2", 123). Field("field2", 123).
Field("field1", "value1"). Field("field1", "value1").
Time(time.Unix(123, 0)). Time(time.Unix(123, 999000000).UTC()).
Info("hi there %s", "phil") Info("hi there %s", "phil")
log. log.
Tag("not-stripe"). Tag("not-stripe").
@ -48,11 +48,11 @@ func TestLog_TagContextFieldFields(t *testing.T) {
}). }).
Tag("stripe"). Tag("stripe").
Err(err). Err(err).
Time(time.Unix(456, 0)). Time(time.Unix(456, 123000000).UTC()).
Debug("Subscription status %s", "active") Debug("Subscription status %s", "active")
expected := `{"time":123000,"level":"INFO","message":"hi there phil","field1":"value1","field2":123,"tag":"mytag"} expected := `{"time":"1970-01-01T00:02:03.999Z","level":"INFO","message":"hi there phil","field1":"value1","field2":123,"tag":"mytag"}
{"time":456000,"level":"DEBUG","message":"Subscription status active","error":"some error","error_code":123,"stripe_customer_id":"acct_123","stripe_subscription_id":"sub_123","tag":"stripe","user_id":"u_abc","visitor_ip":"1.2.3.4"} {"time":"1970-01-01T00:07:36.123Z","level":"DEBUG","message":"Subscription status active","error":"some error","error_code":123,"stripe_customer_id":"acct_123","stripe_subscription_id":"sub_123","tag":"stripe","user_id":"u_abc","visitor_ip":"1.2.3.4"}
` `
require.Equal(t, expected, out.String()) require.Equal(t, expected, out.String())
} }

View File

@ -24,7 +24,9 @@ func logv(v *visitor) *log.Event {
// logr creates a new log event with HTTP request and visitor fields // logr creates a new log event with HTTP request and visitor fields
func logvr(v *visitor, r *http.Request) *log.Event { func logvr(v *visitor, r *http.Request) *log.Event {
return logv(v).Fields(httpContext(r)) return logv(v).
Fields(httpContext(r)).
Fields(requestLimiterFields(v.RequestLimiter()))
} }
// logvrm creates a new log event with HTTP request, visitor fields and message fields // logvrm creates a new log event with HTTP request, visitor fields and message fields

View File

@ -37,7 +37,7 @@ import (
- HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...) - HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...)
- HIGH Account limit creation triggers when account is taken! - HIGH Account limit creation triggers when account is taken!
- HIGH Docs - HIGH Docs
- HIGH CLI "ntfy tier [add|list|delete]" - HIGH make request limit independent of message limit again
- HIGH Self-review - HIGH Self-review
- MEDIUM: Test for expiring messages after reservation removal - MEDIUM: Test for expiring messages after reservation removal
- MEDIUM: Test new token endpoints & never-expiring token - MEDIUM: Test new token endpoints & never-expiring token
@ -235,8 +235,8 @@ func (s *Server) Run() error {
} }
log.Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String()) log.Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
if log.IsFile() { if log.IsFile() {
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s, log file is %s\n", listenStr, s.config.Version, log.File()) fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version)
fmt.Fprintln(os.Stderr, "No more output is expected.") fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
} }
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/", s.handle) mux.HandleFunc("/", s.handle)