From a32e8abc12849b7b00228a632f28a578dc709a62 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 7 Feb 2023 12:02:25 -0500 Subject: [PATCH] "ntfy tier" CLI command --- cmd/publish_test.go | 17 +++++++---- cmd/tier.go | 73 +++++++++++++++++++++++++++++++++++---------- cmd/tier_test.go | 46 ++++++++++++++++++++++++++++ cmd/user.go | 12 ++++---- log/event.go | 14 +++++---- log/log_test.go | 8 ++--- server/log.go | 4 ++- server/server.go | 6 ++-- 8 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 cmd/tier_test.go diff --git a/cmd/publish_test.go b/cmd/publish_test.go index f818cdc3..1c6a14a4 100644 --- a/cmd/publish_test.go +++ b/cmd/publish_test.go @@ -8,20 +8,27 @@ import ( "os" "os/exec" "strconv" + "strings" "testing" "time" ) func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) { testMessage := util.RandomString(10) - app, _, _, _ := newTestApp() 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() - require.Nil(t, app2.Run([]string{"ntfy", "subscribe", "--poll", "ntfytest"})) - require.Contains(t, stdout.String(), testMessage) + _, err := util.Retry(func() (*int, error) { + app2, _, stdout, _ := newTestApp() + 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) { diff --git a/cmd/tier.go b/cmd/tier.go index 4bddebf6..18598c36 100644 --- a/cmd/tier.go +++ b/cmd/tier.go @@ -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: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"}, }, - Description: ` -FIXME + Description: `Add a new tier to the ntfy user database. + +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: "stripe-price-id", Usage: "Stripe price ID for paid tiers (e.g. price_12345)"}, }, - Description: ` -FIXME + Description: `Updates a tier to change the limits. + +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", UsageText: "ntfy tier remove CODE", Action: execTierDel, - Description: ` -FIXME + Description: `Remove a tier from the ntfy user database. + +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"}, Usage: "Shows a list of tiers", Action: execTierList, - Description: ` -FIXME + Description: `Shows a list of all configured tiers. + +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 -to grant users higher limits based on their tier. +The command allows you to add/remove/change tiers in the ntfy user database. Tiers are used +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 -file server.yml. The command only works if 'auth-file' is properly defined. Please also refer -to the related command 'ntfy access'. - -FIXME +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 change --name="Pro" pro # Update the name of an existing tier + ntfy tier del pro # Delete an existing tier `, } diff --git a/cmd/tier_test.go b/cmd/tier_test.go new file mode 100644 index 00000000..f94f3ee9 --- /dev/null +++ b/cmd/tier_test.go @@ -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...)) +} diff --git a/cmd/user.go b/cmd/user.go index 9faa4be8..cc8e2997 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -139,22 +139,22 @@ Example: Action: execUserList, Description: `Shows a list of all configured users, including the everyone ('*') user. -This is a server-only command. It directly reads from the user.db as defined in the server config -file server.yml. The command only works if 'auth-file' is properly defined. - This command is an alias to calling 'ntfy access' (display access control list). + +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. +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 file server.yml. The command only works if 'auth-file' is properly defined. Please also refer to the related command 'ntfy access'. -The command allows you to add/remove/change users in the ntfy user database, as well as change -passwords or roles. - Examples: ntfy user list # Shows list of users (alias: 'ntfy access') ntfy user add phil # Add regular user phil diff --git a/log/event.go b/log/event.go index 31795501..a8d35c26 100644 --- a/log/event.go +++ b/log/event.go @@ -11,13 +11,14 @@ import ( ) const ( - tagField = "tag" - errorField = "error" + tagField = "tag" + errorField = "error" + timestampFormat = "2006-01-02T15:04:05.999Z07:00" ) // Event represents a single log event type Event struct { - Timestamp int64 `json:"time"` + Timestamp string `json:"time"` Level Level `json:"level"` Message string `json:"message"` fields Context @@ -25,8 +26,9 @@ type Event struct { // newEvent creates a new log event func newEvent() *Event { + now := time.Now() return &Event{ - Timestamp: time.Now().UnixMilli(), + Timestamp: now.Format(timestampFormat), fields: make(Context), } } @@ -70,8 +72,8 @@ func (e *Event) Tag(tag string) *Event { } // Time sets the time field -func (e *Event) Time(time time.Time) *Event { - e.Timestamp = time.UnixMilli() +func (e *Event) Time(t time.Time) *Event { + e.Timestamp = t.Format(timestampFormat) return e } diff --git a/log/log_test.go b/log/log_test.go index c1045fda..358c6027 100644 --- a/log/log_test.go +++ b/log/log_test.go @@ -35,7 +35,7 @@ func TestLog_TagContextFieldFields(t *testing.T) { Tag("mytag"). Field("field2", 123). Field("field1", "value1"). - Time(time.Unix(123, 0)). + Time(time.Unix(123, 999000000).UTC()). Info("hi there %s", "phil") log. Tag("not-stripe"). @@ -48,11 +48,11 @@ func TestLog_TagContextFieldFields(t *testing.T) { }). Tag("stripe"). Err(err). - Time(time.Unix(456, 0)). + Time(time.Unix(456, 123000000).UTC()). Debug("Subscription status %s", "active") - expected := `{"time":123000,"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"} + expected := `{"time":"1970-01-01T00:02:03.999Z","level":"INFO","message":"hi there phil","field1":"value1","field2":123,"tag":"mytag"} +{"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()) } diff --git a/server/log.go b/server/log.go index 0a96ac6e..0148be72 100644 --- a/server/log.go +++ b/server/log.go @@ -24,7 +24,9 @@ func logv(v *visitor) *log.Event { // logr creates a new log event with HTTP request and visitor fields 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 diff --git a/server/server.go b/server/server.go index fe7abe0a..aadc637a 100644 --- a/server/server.go +++ b/server/server.go @@ -37,7 +37,7 @@ import ( - HIGH Rate limiting: Sensitive endpoints (account/login/change-password/...) - HIGH Account limit creation triggers when account is taken! - HIGH Docs -- HIGH CLI "ntfy tier [add|list|delete]" +- HIGH make request limit independent of message limit again - HIGH Self-review - MEDIUM: Test for expiring messages after reservation removal - 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()) if log.IsFile() { - fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s, log file is %s\n", listenStr, s.config.Version, log.File()) - fmt.Fprintln(os.Stderr, "No more output is expected.") + fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version) + fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File()) } mux := http.NewServeMux() mux.HandleFunc("/", s.handle)