Fix a bunch of FIXMEs
This commit is contained in:
parent
f945fb4cdd
commit
3bd6518309
15 changed files with 269 additions and 182 deletions
9
go.mod
9
go.mod
|
@ -25,7 +25,10 @@ require (
|
||||||
|
|
||||||
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.107.0 // indirect
|
cloud.google.com/go v0.107.0 // indirect
|
||||||
|
@ -46,10 +49,6 @@ require (
|
||||||
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/stripe/stripe-go/v74 v74.5.0 // indirect
|
|
||||||
github.com/tidwall/gjson v1.14.4 // indirect
|
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
|
||||||
github.com/tidwall/pretty v1.2.1 // 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.4.0 // indirect
|
golang.org/x/net v0.4.0 // indirect
|
||||||
|
|
7
go.sum
7
go.sum
|
@ -102,13 +102,6 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
|
||||||
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/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
|
github.com/stripe/stripe-go/v74 v74.5.0 h1:YyqTvVQdS34KYGCfVB87EMn9eDV3FCFkSwfdOQhiVL4=
|
||||||
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
github.com/stripe/stripe-go/v74 v74.5.0/go.mod h1:5PoXNp30AJ3tGq57ZcFuaMylzNi8KpwlrYAFmO1fHZw=
|
||||||
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
|
||||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
|
||||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
|
||||||
github.com/urfave/cli/v2 v2.23.7 h1:YHDQ46s3VghFHFf1DdF+Sh7H4RqhcM+t0TmZRJx4oJY=
|
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/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=
|
||||||
|
|
|
@ -19,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
|
||||||
|
@ -112,10 +113,12 @@ type Config struct {
|
||||||
BehindProxy bool
|
BehindProxy bool
|
||||||
StripeSecretKey string
|
StripeSecretKey string
|
||||||
StripeWebhookKey string
|
StripeWebhookKey string
|
||||||
|
StripePriceCacheDuration time.Duration
|
||||||
EnableWeb bool
|
EnableWeb bool
|
||||||
EnableSignup bool // Enable creation of accounts via API and UI
|
EnableSignup bool // Enable creation of accounts via API and UI
|
||||||
EnableLogin bool
|
EnableLogin bool
|
||||||
EnableReservations bool // Allow users with role "user" to own/reserve topics
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,9 +135,11 @@ func NewConfig() *Config {
|
||||||
FirebaseKeyFile: "",
|
FirebaseKeyFile: "",
|
||||||
CacheFile: "",
|
CacheFile: "",
|
||||||
CacheDuration: DefaultCacheDuration,
|
CacheDuration: DefaultCacheDuration,
|
||||||
|
CacheStartupQueries: "",
|
||||||
CacheBatchSize: 0,
|
CacheBatchSize: 0,
|
||||||
CacheBatchTimeout: 0,
|
CacheBatchTimeout: 0,
|
||||||
AuthFile: "",
|
AuthFile: "",
|
||||||
|
AuthStartupQueries: "",
|
||||||
AuthDefault: user.NewPermission(true, true),
|
AuthDefault: user.NewPermission(true, true),
|
||||||
AttachmentCacheDir: "",
|
AttachmentCacheDir: "",
|
||||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||||
|
@ -142,14 +147,24 @@ func NewConfig() *Config {
|
||||||
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,
|
||||||
|
@ -162,7 +177,14 @@ func NewConfig() *Config {
|
||||||
VisitorAccountCreateLimitReplenish: DefaultVisitorAccountCreateLimitReplenish,
|
VisitorAccountCreateLimitReplenish: DefaultVisitorAccountCreateLimitReplenish,
|
||||||
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
VisitorStatsResetTime: DefaultVisitorStatsResetTime,
|
||||||
BehindProxy: false,
|
BehindProxy: false,
|
||||||
|
StripeSecretKey: "",
|
||||||
|
StripeWebhookKey: "",
|
||||||
|
StripePriceCacheDuration: DefaultStripePriceCacheDuration,
|
||||||
EnableWeb: true,
|
EnableWeb: true,
|
||||||
|
EnableSignup: false,
|
||||||
|
EnableLogin: false,
|
||||||
|
EnableReservations: false,
|
||||||
|
AccessControlAllowOrigin: "*",
|
||||||
Version: "",
|
Version: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,21 +39,18 @@ import (
|
||||||
payments:
|
payments:
|
||||||
- send dunning emails when overdue
|
- send dunning emails when overdue
|
||||||
- payment methods
|
- payment methods
|
||||||
- unmarshal to stripe.Subscription instead of gjson
|
|
||||||
- delete subscription when account deleted
|
- delete subscription when account deleted
|
||||||
- delete messages + reserved topics on ResetTier
|
- delete messages + reserved topics on ResetTier
|
||||||
|
|
||||||
- move v1/account/tiers to v1/tiers
|
|
||||||
|
|
||||||
Limits & rate limiting:
|
Limits & rate limiting:
|
||||||
users without tier: should the stats be persisted? are they meaningful?
|
users without tier: should the stats be persisted? are they meaningful?
|
||||||
-> test that the visitor is based on the IP address!
|
-> test that the visitor is based on the IP address!
|
||||||
login/account endpoints
|
login/account endpoints
|
||||||
when ResetStats() is run, reset messagesLimiter (and others)?
|
when ResetStats() is run, reset messagesLimiter (and others)?
|
||||||
update last_seen when API is accessed
|
|
||||||
Make sure account endpoints make sense for admins
|
Make sure account endpoints make sense for admins
|
||||||
|
|
||||||
UI:
|
UI:
|
||||||
|
- revert home page change
|
||||||
- flicker of upgrade banner
|
- flicker of upgrade banner
|
||||||
- JS constants
|
- JS constants
|
||||||
Sync:
|
Sync:
|
||||||
|
@ -82,7 +79,7 @@ type Server struct {
|
||||||
userManager *user.Manager // Might be nil!
|
userManager *user.Manager // Might be nil!
|
||||||
messageCache *messageCache
|
messageCache *messageCache
|
||||||
fileCache *fileCache
|
fileCache *fileCache
|
||||||
priceCache map[string]string // Stripe price ID -> formatted price
|
priceCache *util.LookupCache[map[string]string] // Stripe price ID -> formatted price
|
||||||
closeChan chan bool
|
closeChan chan bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
@ -144,7 +141,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
|
||||||
|
@ -201,7 +199,7 @@ func New(conf *Config) (*Server, error) {
|
||||||
topics: topics,
|
topics: topics,
|
||||||
userManager: userManager,
|
userManager: userManager,
|
||||||
visitors: make(map[string]*visitor),
|
visitors: make(map[string]*visitor),
|
||||||
priceCache: make(map[string]string),
|
priceCache: util.NewLookupCache(fetchStripePrices, conf.StripePriceCacheDuration),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -454,22 +452,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 {
|
||||||
|
@ -620,12 +610,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 {
|
||||||
|
@ -1175,7 +1160,7 @@ 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, PATCH, DELETE")
|
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
|
||||||
}
|
}
|
||||||
|
@ -1482,7 +1467,7 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||||
// 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 {
|
||||||
m, err := readJSONWithLimit[publishMessage](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
|
||||||
}
|
}
|
||||||
|
@ -1650,3 +1635,12 @@ func (s *Server) visitorFromIP(ip netip.Addr) *visitor {
|
||||||
func (s *Server) visitorFromUser(user *user.User, ip netip.Addr) *visitor {
|
func (s *Server) visitorFromUser(user *user.User, ip netip.Addr) *visitor {
|
||||||
return s.visitorFromID(fmt.Sprintf("user:%s", user.Name), ip, user)
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -10,7 +10,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
jsonBodyBytesLimit = 4096
|
|
||||||
subscriptionIDLength = 16
|
subscriptionIDLength = 16
|
||||||
createdByAPI = "api"
|
createdByAPI = "api"
|
||||||
syncTopicAccountSyncEvent = "sync"
|
syncTopicAccountSyncEvent = "sync"
|
||||||
|
@ -38,9 +37,7 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||||
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, createdByAPI); err != nil { // TODO this should return a User
|
if err := s.userManager.AddUser(newAccount.Username, newAccount.Password, user.RoleUser, createdByAPI); err != nil { // TODO this should return a User
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
@ -118,21 +115,14 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
|
||||||
response.Username = user.Everyone
|
response.Username = user.Everyone
|
||||||
response.Role = string(user.RoleAnonymous)
|
response.Role = string(user.RoleAnonymous)
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, response)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
if err := s.userManager.RemoveUser(v.user.Name); err != nil {
|
if err := s.userManager.RemoveUser(v.user.Name); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
@ -143,9 +133,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
|
||||||
if err := s.userManager.ChangePassword(v.user.Name, newPassword.Password); err != nil {
|
if err := s.userManager.ChangePassword(v.user.Name, newPassword.Password); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
@ -154,16 +142,11 @@ func (s *Server) handleAccountTokenIssue(w http.ResponseWriter, _ *http.Request,
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
response := &apiAccountTokenResponse{
|
response := &apiAccountTokenResponse{
|
||||||
Token: token.Value,
|
Token: token.Value,
|
||||||
Expires: token.Expires.Unix(),
|
Expires: token.Expires.Unix(),
|
||||||
}
|
}
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
return s.writeJSON(w, response)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
@ -177,16 +160,11 @@ func (s *Server) handleAccountTokenExtend(w http.ResponseWriter, _ *http.Request
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
response := &apiAccountTokenResponse{
|
response := &apiAccountTokenResponse{
|
||||||
Token: token.Value,
|
Token: token.Value,
|
||||||
Expires: token.Expires.Unix(),
|
Expires: token.Expires.Unix(),
|
||||||
}
|
}
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
return s.writeJSON(w, response)
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request, v *visitor) error {
|
||||||
|
@ -197,8 +175,7 @@ func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, _ *http.Request
|
||||||
if err := s.userManager.RemoveToken(v.user); err != nil {
|
if err := s.userManager.RemoveToken(v.user); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
@ -230,9 +207,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ
|
||||||
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
@ -257,12 +232,7 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSubscription)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
if err := json.NewEncoder(w).Encode(newSubscription); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
@ -292,12 +262,7 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http.
|
||||||
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
if err := s.userManager.ChangeSettings(v.user); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, subscription)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
if err := json.NewEncoder(w).Encode(subscription); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
@ -321,9 +286,7 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
@ -366,9 +329,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
||||||
if err := s.userManager.AllowAccess(owner, user.Everyone, req.Topic, everyone.IsRead(), everyone.IsWrite()); err != nil {
|
if err := s.userManager.AllowAccess(owner, user.Everyone, req.Topic, everyone.IsRead(), everyone.IsWrite()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
@ -392,9 +353,7 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
|
||||||
if err := s.userManager.ResetAccess(user.Everyone, topic); err != nil {
|
if err := s.userManager.ResetAccess(user.Everyone, topic); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
@ -11,19 +12,15 @@ import (
|
||||||
"github.com/stripe/stripe-go/v74/price"
|
"github.com/stripe/stripe-go/v74/price"
|
||||||
"github.com/stripe/stripe-go/v74/subscription"
|
"github.com/stripe/stripe-go/v74/subscription"
|
||||||
"github.com/stripe/stripe-go/v74/webhook"
|
"github.com/stripe/stripe-go/v74/webhook"
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
"heckel.io/ntfy/log"
|
"heckel.io/ntfy/log"
|
||||||
"heckel.io/ntfy/user"
|
"heckel.io/ntfy/user"
|
||||||
"heckel.io/ntfy/util"
|
"heckel.io/ntfy/util"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
stripeBodyBytesLimit = 16384
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errNotAPaidTier = errors.New("tier does not have billing price identifier")
|
errNotAPaidTier = errors.New("tier does not have billing price identifier")
|
||||||
errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions")
|
errMultipleBillingSubscriptions = errors.New("cannot have multiple billing subscriptions")
|
||||||
|
@ -52,22 +49,14 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tier := range tiers {
|
prices, err := s.priceCache.Value()
|
||||||
if tier.StripePriceID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
priceStr, ok := s.priceCache[tier.StripePriceID]
|
|
||||||
if !ok {
|
|
||||||
p, err := price.Get(tier.StripePriceID, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if p.UnitAmount%100 == 0 {
|
for _, tier := range tiers {
|
||||||
priceStr = fmt.Sprintf("$%d", p.UnitAmount/100)
|
priceStr, ok := prices[tier.StripePriceID]
|
||||||
} else {
|
if tier.StripePriceID == "" || !ok {
|
||||||
priceStr = fmt.Sprintf("$%.2f", float64(p.UnitAmount)/100)
|
continue
|
||||||
}
|
|
||||||
s.priceCache[tier.StripePriceID] = priceStr // FIXME race, make this sync.Map or something
|
|
||||||
}
|
}
|
||||||
response = append(response, &apiAccountBillingTier{
|
response = append(response, &apiAccountBillingTier{
|
||||||
Code: tier.Code,
|
Code: tier.Code,
|
||||||
|
@ -84,12 +73,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, response)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier
|
// handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier
|
||||||
|
@ -143,12 +127,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
|
||||||
response := &apiAccountBillingSubscriptionCreateResponse{
|
response := &apiAccountBillingSubscriptionCreateResponse{
|
||||||
RedirectURL: sess.URL,
|
RedirectURL: sess.URL,
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, response)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, _ *visitor) error {
|
||||||
|
@ -219,12 +198,7 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
if err := json.NewEncoder(w).Encode(newSuccessResponse()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user,
|
// handleAccountBillingSubscriptionDelete facilitates downgrading a paid user to a tier-less user,
|
||||||
|
@ -239,12 +213,7 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, newSuccessResponse())
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
if err := json.NewEncoder(w).Encode(newSuccessResponse()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||||
|
@ -262,12 +231,7 @@ func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter,
|
||||||
response := &apiAccountBillingPortalRedirectResponse{
|
response := &apiAccountBillingPortalRedirectResponse{
|
||||||
RedirectURL: ps.URL,
|
RedirectURL: ps.URL,
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
return s.writeJSON(w, response)
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
|
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync
|
// handleAccountBillingWebhook handles incoming Stripe webhooks. It mainly keeps the local user database in sync
|
||||||
|
@ -278,7 +242,7 @@ func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Requ
|
||||||
if stripeSignature == "" {
|
if stripeSignature == "" {
|
||||||
return errHTTPBadRequestBillingRequestInvalid
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
}
|
}
|
||||||
body, err := util.Peek(r.Body, stripeBodyBytesLimit)
|
body, err := util.Peek(r.Body, jsonBodyBytesLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if body.LimitReached {
|
} else if body.LimitReached {
|
||||||
|
@ -302,25 +266,23 @@ func (s *Server) handleAccountBillingWebhook(w http.ResponseWriter, r *http.Requ
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMessage) error {
|
func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMessage) error {
|
||||||
subscriptionID := gjson.GetBytes(event, "id")
|
r, err := util.UnmarshalJSON[apiStripeSubscriptionUpdatedEvent](io.NopCloser(bytes.NewReader(event)))
|
||||||
customerID := gjson.GetBytes(event, "customer")
|
if err != nil {
|
||||||
status := gjson.GetBytes(event, "status")
|
return err
|
||||||
currentPeriodEnd := gjson.GetBytes(event, "current_period_end")
|
} 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 == "" {
|
||||||
cancelAt := gjson.GetBytes(event, "cancel_at")
|
|
||||||
priceID := gjson.GetBytes(event, "items.data.0.price.id")
|
|
||||||
if !subscriptionID.Exists() || !status.Exists() || !currentPeriodEnd.Exists() || !cancelAt.Exists() || !priceID.Exists() {
|
|
||||||
return errHTTPBadRequestBillingRequestInvalid
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
}
|
}
|
||||||
log.Info("Stripe: customer %s: Updating subscription to status %s, with price %s", customerID.String(), status, priceID)
|
subscriptionID, priceID := r.ID, r.Items.Data[0].Price.ID
|
||||||
u, err := s.userManager.UserByStripeCustomer(customerID.String())
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tier, err := s.userManager.TierByStripePrice(priceID.String())
|
tier, err := s.userManager.TierByStripePrice(priceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.updateSubscriptionAndTier(u, customerID.String(), subscriptionID.String(), status.String(), currentPeriodEnd.Int(), cancelAt.Int(), tier.Code); err != nil {
|
if err := s.updateSubscriptionAndTier(u, r.Customer, subscriptionID, r.Status, r.CurrentPeriodEnd, r.CancelAt, tier.Code); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
||||||
|
@ -328,16 +290,18 @@ func (s *Server) handleAccountBillingWebhookSubscriptionUpdated(event json.RawMe
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(event json.RawMessage) error {
|
func (s *Server) handleAccountBillingWebhookSubscriptionDeleted(event json.RawMessage) error {
|
||||||
customerID := gjson.GetBytes(event, "customer")
|
r, err := util.UnmarshalJSON[apiStripeSubscriptionDeletedEvent](io.NopCloser(bytes.NewReader(event)))
|
||||||
if !customerID.Exists() {
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if r.Customer == "" {
|
||||||
return errHTTPBadRequestBillingRequestInvalid
|
return errHTTPBadRequestBillingRequestInvalid
|
||||||
}
|
}
|
||||||
log.Info("Stripe: customer %s: subscription deleted, downgrading to unpaid tier", customerID.String())
|
log.Info("Stripe: customer %s: subscription deleted, downgrading to unpaid tier", r.Customer)
|
||||||
u, err := s.userManager.UserByStripeCustomer(customerID.String())
|
u, err := s.userManager.UserByStripeCustomer(r.Customer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.updateSubscriptionAndTier(u, customerID.String(), "", "", 0, 0, ""); err != nil {
|
if err := s.updateSubscriptionAndTier(u, r.Customer, "", "", 0, 0, ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
s.publishSyncEventAsync(s.visitorFromUser(u, netip.IPv4Unspecified()))
|
||||||
|
@ -364,3 +328,27 @@ func (s *Server) updateSubscriptionAndTier(u *user.User, customerID, subscriptio
|
||||||
}
|
}
|
||||||
return nil
|
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 fetchStripePrices() (map[string]string, error) {
|
||||||
|
log.Debug("Caching prices from Stripe API")
|
||||||
|
prices := make(map[string]string)
|
||||||
|
iter := price.List(&stripe.PriceListParams{
|
||||||
|
Active: stripe.Bool(true),
|
||||||
|
})
|
||||||
|
for iter.Next() {
|
||||||
|
p := iter.Price()
|
||||||
|
if p.UnitAmount%100 == 0 {
|
||||||
|
prices[p.ID] = fmt.Sprintf("$%d", p.UnitAmount/100)
|
||||||
|
} else {
|
||||||
|
prices[p.ID] = fmt.Sprintf("$%.2f", float64(p.UnitAmount)/100)
|
||||||
|
}
|
||||||
|
log.Trace("- Caching price %s = %v", p.ID, prices[p.ID])
|
||||||
|
}
|
||||||
|
if iter.Err() != nil {
|
||||||
|
log.Warn("Fetching Stripe prices failed: %s", iter.Err().Error())
|
||||||
|
return nil, iter.Err()
|
||||||
|
}
|
||||||
|
return prices, nil
|
||||||
|
}
|
||||||
|
|
|
@ -1463,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)
|
||||||
|
|
|
@ -336,3 +336,22 @@ func newSuccessResponse() *apiSuccessResponse {
|
||||||
Success: true,
|
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"`
|
||||||
|
}
|
||||||
|
|
|
@ -66,7 +66,6 @@ const (
|
||||||
stripe_subscription_cancel_at INT,
|
stripe_subscription_cancel_at INT,
|
||||||
created_by TEXT NOT NULL,
|
created_by TEXT NOT NULL,
|
||||||
created_at INT NOT NULL,
|
created_at INT NOT NULL,
|
||||||
last_seen INT NOT NULL,
|
|
||||||
FOREIGN KEY (tier_id) REFERENCES tier (id)
|
FOREIGN KEY (tier_id) REFERENCES tier (id)
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX idx_user ON user (user);
|
CREATE UNIQUE INDEX idx_user ON user (user);
|
||||||
|
@ -93,8 +92,8 @@ const (
|
||||||
id INT PRIMARY KEY,
|
id INT PRIMARY KEY,
|
||||||
version INT NOT NULL
|
version INT NOT NULL
|
||||||
);
|
);
|
||||||
INSERT INTO user (id, user, pass, role, sync_topic, created_by, created_at, last_seen)
|
INSERT INTO user (id, user, pass, role, sync_topic, created_by, created_at)
|
||||||
VALUES (1, '*', '', 'anonymous', '', 'system', UNIXEPOCH(), 0)
|
VALUES (1, '*', '', 'anonymous', '', 'system', UNIXEPOCH())
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
`
|
`
|
||||||
createTablesQueries = `BEGIN; ` + createTablesQueriesNoTx + ` COMMIT;`
|
createTablesQueries = `BEGIN; ` + createTablesQueriesNoTx + ` COMMIT;`
|
||||||
|
@ -130,8 +129,8 @@ const (
|
||||||
`
|
`
|
||||||
|
|
||||||
insertUserQuery = `
|
insertUserQuery = `
|
||||||
INSERT INTO user (user, pass, role, sync_topic, created_by, created_at, last_seen)
|
INSERT INTO user (user, pass, role, sync_topic, created_by, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`
|
`
|
||||||
selectUsernamesQuery = `
|
selectUsernamesQuery = `
|
||||||
SELECT user
|
SELECT user
|
||||||
|
@ -257,8 +256,8 @@ const (
|
||||||
ALTER TABLE user RENAME TO user_old;
|
ALTER TABLE user RENAME TO user_old;
|
||||||
`
|
`
|
||||||
migrate1To2InsertFromOldTablesAndDropNoTx = `
|
migrate1To2InsertFromOldTablesAndDropNoTx = `
|
||||||
INSERT INTO user (user, pass, role, sync_topic, created_by, created_at, last_seen)
|
INSERT INTO user (user, pass, role, sync_topic, created_by, created_at)
|
||||||
SELECT user, pass, role, '', 'admin', UNIXEPOCH(), UNIXEPOCH() FROM user_old;
|
SELECT user, pass, role, '', 'admin', UNIXEPOCH() FROM user_old;
|
||||||
|
|
||||||
INSERT INTO user_access (user_id, topic, read, write)
|
INSERT INTO user_access (user_id, topic, read, write)
|
||||||
SELECT u.id, a.topic, a.read, a.write
|
SELECT u.id, a.topic, a.read, a.write
|
||||||
|
@ -531,7 +530,7 @@ func (a *Manager) AddUser(username, password string, role Role, createdBy string
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
syncTopic, now := util.RandomString(syncTopicLength), time.Now().Unix()
|
syncTopic, now := util.RandomString(syncTopicLength), time.Now().Unix()
|
||||||
if _, err = a.db.Exec(insertUserQuery, username, hash, role, syncTopic, createdBy, now, now); err != nil {
|
if _, err = a.db.Exec(insertUserQuery, username, hash, role, syncTopic, createdBy, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
@ -589,6 +588,7 @@ func (a *Manager) User(username string) (*User, error) {
|
||||||
return a.readUser(rows)
|
return a.readUser(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise.
|
||||||
func (a *Manager) UserByStripeCustomer(stripeCustomerID string) (*User, error) {
|
func (a *Manager) UserByStripeCustomer(stripeCustomerID string) (*User, error) {
|
||||||
rows, err := a.db.Query(selectUserByStripeCustomerIDQuery, stripeCustomerID)
|
rows, err := a.db.Query(selectUserByStripeCustomerIDQuery, stripeCustomerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -878,6 +878,7 @@ func (a *Manager) CreateTier(tier *Tier) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChangeBilling updates a user's billing fields, namely the Stripe customer ID, and subscription information
|
||||||
func (a *Manager) ChangeBilling(user *User) error {
|
func (a *Manager) ChangeBilling(user *User) error {
|
||||||
if _, err := a.db.Exec(updateBillingQuery, nullString(user.Billing.StripeCustomerID), nullString(user.Billing.StripeSubscriptionID), nullString(string(user.Billing.StripeSubscriptionStatus)), nullInt64(user.Billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(user.Billing.StripeSubscriptionCancelAt.Unix()), user.Name); err != nil {
|
if _, err := a.db.Exec(updateBillingQuery, nullString(user.Billing.StripeCustomerID), nullString(user.Billing.StripeSubscriptionID), nullString(string(user.Billing.StripeSubscriptionStatus)), nullInt64(user.Billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(user.Billing.StripeSubscriptionCancelAt.Unix()), user.Name); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -885,6 +886,7 @@ func (a *Manager) ChangeBilling(user *User) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tiers returns a list of all Tier structs
|
||||||
func (a *Manager) Tiers() ([]*Tier, error) {
|
func (a *Manager) Tiers() ([]*Tier, error) {
|
||||||
rows, err := a.db.Query(selectTiersQuery)
|
rows, err := a.db.Query(selectTiersQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -904,6 +906,7 @@ func (a *Manager) Tiers() ([]*Tier, error) {
|
||||||
return tiers, nil
|
return tiers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist
|
||||||
func (a *Manager) Tier(code string) (*Tier, error) {
|
func (a *Manager) Tier(code string) (*Tier, error) {
|
||||||
rows, err := a.db.Query(selectTierByCodeQuery, code)
|
rows, err := a.db.Query(selectTierByCodeQuery, code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -913,6 +916,7 @@ func (a *Manager) Tier(code string) (*Tier, error) {
|
||||||
return a.readTier(rows)
|
return a.readTier(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist
|
||||||
func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
|
func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
|
||||||
rows, err := a.db.Query(selectTierByPriceIDQuery, priceID)
|
rows, err := a.db.Query(selectTierByPriceIDQuery, priceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
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)
|
||||||
|
}
|
|
@ -24,11 +24,6 @@ class AccountApi {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
this.listener = null; // Fired when account is fetched from remote
|
this.listener = null; // Fired when account is fetched from remote
|
||||||
|
|
||||||
// Random ID used to identify this client when sending/receiving "sync" events
|
|
||||||
// to the sync topic of an account. This ID doesn't matter much, but it will prevent
|
|
||||||
// a client from reacting to its own message.
|
|
||||||
this.identity = Math.floor(Math.random() * 2586000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerListener(listener) {
|
registerListener(listener) {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
const config = window.config;
|
const config = window.config;
|
||||||
|
|
||||||
if (config.base_url === "") {
|
// 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;
|
config.base_url = window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ import session from "./Session";
|
||||||
// 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
|
||||||
|
|
||||||
|
// The IndexedDB database name is based on the logged-in user
|
||||||
const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy";
|
const dbName = (session.username()) ? `ntfy-${session.username()}` : "ntfy";
|
||||||
const db = new Dexie(dbName);
|
const db = new Dexie(dbName);
|
||||||
|
|
||||||
|
|
|
@ -35,12 +35,8 @@ export const useConnectionListeners = (subscriptions, users) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(message.message);
|
const data = JSON.parse(message.message);
|
||||||
if (data.event === "sync") {
|
if (data.event === "sync") {
|
||||||
if (data.source !== accountApi.identity) {
|
|
||||||
console.log(`[ConnectionListener] Triggering account sync`);
|
console.log(`[ConnectionListener] Triggering account sync`);
|
||||||
await accountApi.sync();
|
await accountApi.sync();
|
||||||
} else {
|
|
||||||
console.log(`[ConnectionListener] I triggered the account sync, ignoring message`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.log(`[ConnectionListener] Unknown message type. Doing nothing.`);
|
console.log(`[ConnectionListener] Unknown message type. Doing nothing.`);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue