diff --git a/auth/auth.go b/auth/auth.go index e2ece681..0174b170 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -82,11 +82,12 @@ const ( ) type Plan struct { - Code string `json:"name"` - Upgradable bool `json:"upgradable"` - RequestLimit int `json:"request_limit"` - EmailsLimit int `json:"emails_limit"` - AttachmentBytesLimit int64 `json:"attachment_bytes_limit"` + Code string `json:"name"` + Upgradable bool `json:"upgradable"` + MessageLimit int64 `json:"messages_limit"` + EmailsLimit int64 `json:"emails_limit"` + AttachmentFileSizeLimit int64 `json:"attachment_file_size_limit"` + AttachmentTotalSizeLimit int64 `json:"attachment_total_size_limit"` } type UserSubscription struct { diff --git a/auth/auth_sqlite.go b/auth/auth_sqlite.go index c7b8679d..60288cc2 100644 --- a/auth/auth_sqlite.go +++ b/auth/auth_sqlite.go @@ -24,9 +24,10 @@ const ( CREATE TABLE IF NOT EXISTS plan ( id INT NOT NULL, code TEXT NOT NULL, - request_limit INT NOT NULL, + messages_limit INT NOT NULL, emails_limit INT NOT NULL, - attachment_bytes_limit INT NOT NULL, + attachment_file_size_limit INT NOT NULL, + attachment_total_size_limit INT NOT NULL, PRIMARY KEY (id) ); CREATE TABLE IF NOT EXISTS user ( @@ -61,13 +62,13 @@ const ( COMMIT; ` selectUserByNameQuery = ` - SELECT u.user, u.pass, u.role, u.settings, p.code, p.request_limit, p.emails_limit, p.attachment_bytes_limit + SELECT u.user, u.pass, u.role, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit FROM user u LEFT JOIN plan p on p.id = u.plan_id WHERE user = ? ` selectUserByTokenQuery = ` - SELECT u.user, u.pass, u.role, u.settings, p.code, p.request_limit, p.emails_limit, p.attachment_bytes_limit + SELECT u.user, u.pass, u.role, u.settings, p.code, p.messages_limit, p.emails_limit, p.attachment_file_size_limit, p.attachment_total_size_limit FROM user u JOIN user_token t on u.id = t.user_id LEFT JOIN plan p on p.id = u.plan_id @@ -325,12 +326,11 @@ func (a *SQLiteAuthManager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var username, hash, role string var prefs, planCode sql.NullString - var requestLimit, emailLimit sql.NullInt32 - var attachmentBytesLimit sql.NullInt64 + var messagesLimit, emailsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit sql.NullInt64 if !rows.Next() { return nil, ErrNotFound } - if err := rows.Scan(&username, &hash, &role, &prefs, &planCode, &requestLimit, &emailLimit, &attachmentBytesLimit); err != nil { + if err := rows.Scan(&username, &hash, &role, &prefs, &planCode, &messagesLimit, &emailsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -353,11 +353,12 @@ func (a *SQLiteAuthManager) readUser(rows *sql.Rows) (*User, error) { } if planCode.Valid { user.Plan = &Plan{ - Code: planCode.String, - Upgradable: true, // FIXME - RequestLimit: int(requestLimit.Int32), - EmailsLimit: int(emailLimit.Int32), - AttachmentBytesLimit: attachmentBytesLimit.Int64, + Code: planCode.String, + Upgradable: true, // FIXME + MessageLimit: messagesLimit.Int64, + EmailsLimit: emailsLimit.Int64, + AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, + AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, } } return user, nil diff --git a/server/server.go b/server/server.go index 0b5c2365..11ec614a 100644 --- a/server/server.go +++ b/server/server.go @@ -91,7 +91,6 @@ var ( publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`) webConfigPath = "/config.js" - userStatsPath = "/user/stats" // FIXME get rid of this in favor of /user/account accountPath = "/v1/account" accountTokenPath = "/v1/account/token" accountPasswordPath = "/v1/account/password" @@ -329,8 +328,6 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleEmpty)(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == webConfigPath { return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) - } else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { - return s.handleUserStats(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == accountPath { return s.handleAccountCreate(w, r, v) } else if r.Method == http.MethodGet && r.URL.Path == accountPath { @@ -430,19 +427,6 @@ var config = { return err } -func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visitor) error { - stats, err := v.Stats() - if err != nil { - return err - } - w.Header().Set("Content-Type", "text/json") - w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests - if err := json.NewEncoder(w).Encode(stats); err != nil { - return err - } - return nil -} - func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request, _ *visitor) error { r.URL.Path = webSiteDir + r.URL.Path util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r) @@ -531,6 +515,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes go s.sendToFirebase(v, m) } if s.smtpSender != nil && email != "" { + v.IncrEmails() go s.sendEmail(v, m, email) } if s.config.UpstreamBaseURL != "" { @@ -545,7 +530,7 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes return nil, err } } - v.requests.Inc() + v.IncrMessages() s.mu.Lock() s.messages++ s.mu.Unlock() diff --git a/server/server_account.go b/server/server_account.go index f5c4a841..6078c587 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -40,7 +40,7 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis return err } response := &apiAccountSettingsResponse{ - Usage: &apiAccountUsageLimits{}, + Usage: &apiAccountStats{}, } if v.user != nil { response.Username = v.user.Name @@ -59,43 +59,60 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis if v.user.Plan != nil { response.Usage.Basis = "account" response.Plan = &apiAccountSettingsPlan{ - Code: v.user.Plan.Code, - RequestLimit: v.user.Plan.RequestLimit, - EmailLimit: v.user.Plan.EmailsLimit, - AttachmentsBytesLimit: v.user.Plan.AttachmentBytesLimit, + Code: v.user.Plan.Code, + Upgradable: v.user.Plan.Upgradable, + } + response.Limits = &apiAccountLimits{ + MessagesLimit: v.user.Plan.MessageLimit, + EmailsLimit: v.user.Plan.EmailsLimit, + AttachmentFileSizeLimit: v.user.Plan.AttachmentFileSizeLimit, + AttachmentTotalSizeLimit: v.user.Plan.AttachmentTotalSizeLimit, } } else { if v.user.Role == auth.RoleAdmin { response.Usage.Basis = "account" response.Plan = &apiAccountSettingsPlan{ - Code: string(auth.PlanUnlimited), - RequestLimit: 0, - EmailLimit: 0, - AttachmentsBytesLimit: 0, + Code: string(auth.PlanUnlimited), + Upgradable: false, + } + response.Limits = &apiAccountLimits{ + MessagesLimit: 0, + EmailsLimit: 0, + AttachmentFileSizeLimit: 0, + AttachmentTotalSizeLimit: 0, } } else { response.Usage.Basis = "ip" response.Plan = &apiAccountSettingsPlan{ - Code: string(auth.PlanDefault), - RequestLimit: s.config.VisitorRequestLimitBurst, - EmailLimit: s.config.VisitorEmailLimitBurst, - AttachmentsBytesLimit: s.config.VisitorAttachmentTotalSizeLimit, + Code: string(auth.PlanDefault), + Upgradable: true, + } + response.Limits = &apiAccountLimits{ + MessagesLimit: int64(s.config.VisitorRequestLimitBurst), + EmailsLimit: int64(s.config.VisitorEmailLimitBurst), + AttachmentFileSizeLimit: s.config.AttachmentFileSizeLimit, + AttachmentTotalSizeLimit: s.config.VisitorAttachmentTotalSizeLimit, } } } } else { response.Username = auth.Everyone response.Role = string(auth.RoleAnonymous) - response.Usage.Basis = "account" + response.Usage.Basis = "ip" response.Plan = &apiAccountSettingsPlan{ - Code: string(auth.PlanNone), - RequestLimit: s.config.VisitorRequestLimitBurst, - EmailLimit: s.config.VisitorEmailLimitBurst, - AttachmentsBytesLimit: s.config.VisitorAttachmentTotalSizeLimit, + Code: string(auth.PlanNone), + Upgradable: true, + } + response.Limits = &apiAccountLimits{ + MessagesLimit: int64(s.config.VisitorRequestLimitBurst), + EmailsLimit: int64(s.config.VisitorEmailLimitBurst), + AttachmentFileSizeLimit: s.config.AttachmentFileSizeLimit, + AttachmentTotalSizeLimit: s.config.VisitorAttachmentTotalSizeLimit, } } - response.Usage.Requests = v.requests.Value() - response.Usage.AttachmentsBytes = stats.VisitorAttachmentBytesUsed + response.Usage.Messages = stats.Messages + response.Usage.Emails = stats.Emails + response.Usage.AttachmentsSize = stats.AttachmentBytes if err := json.NewEncoder(w).Encode(response); err != nil { return err } diff --git a/server/server_test.go b/server/server_test.go index e328cb1b..762e1d11 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1381,7 +1381,7 @@ func TestServer_PublishAttachmentUserStats(t *testing.T) { require.Nil(t, json.NewDecoder(strings.NewReader(response.Body.String())).Decode(&stats)) require.Equal(t, int64(5000), stats.AttachmentFileSizeLimit) require.Equal(t, int64(6000), stats.VisitorAttachmentBytesTotal) - require.Equal(t, int64(4999), stats.VisitorAttachmentBytesUsed) + require.Equal(t, int64(4999), stats.AttachmentBytes) require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining) } diff --git a/server/types.go b/server/types.go index 1369132b..bc9718f4 100644 --- a/server/types.go +++ b/server/types.go @@ -225,26 +225,31 @@ type apiAccountTokenResponse struct { } type apiAccountSettingsPlan struct { - Code string `json:"code"` - Upgradable bool `json:"upgradable"` - RequestLimit int `json:"request_limit"` - EmailLimit int `json:"email_limit"` - AttachmentsBytesLimit int64 `json:"attachments_bytes_limit"` + Code string `json:"code"` + Upgradable bool `json:"upgradable"` } -type apiAccountUsageLimits struct { - Basis string `json:"basis"` // "ip" or "account" - Requests int64 `json:"requests"` - Emails int `json:"emails"` - AttachmentsBytes int64 `json:"attachments_bytes"` +type apiAccountLimits struct { + MessagesLimit int64 `json:"messages"` + EmailsLimit int64 `json:"emails"` + AttachmentFileSizeLimit int64 `json:"attachment_file_size"` + AttachmentTotalSizeLimit int64 `json:"attachment_total_size"` +} + +type apiAccountStats struct { + Basis string `json:"basis"` // "ip" or "account" + Messages int64 `json:"messages"` + Emails int64 `json:"emails"` + AttachmentsSize int64 `json:"attachments_size"` } type apiAccountSettingsResponse struct { Username string `json:"username"` Role string `json:"role,omitempty"` - Plan *apiAccountSettingsPlan `json:"plan,omitempty"` Language string `json:"language,omitempty"` Notification *auth.UserNotificationPrefs `json:"notification,omitempty"` Subscriptions []*auth.UserSubscription `json:"subscriptions,omitempty"` - Usage *apiAccountUsageLimits `json:"usage,omitempty"` + Plan *apiAccountSettingsPlan `json:"plan,omitempty"` + Limits *apiAccountLimits `json:"limits,omitempty"` + Usage *apiAccountStats `json:"usage,omitempty"` } diff --git a/server/visitor.go b/server/visitor.go index 42626f92..36b68829 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -24,46 +24,47 @@ var ( // visitor represents an API user, and its associated rate.Limiter used for rate limiting type visitor struct { - config *Config - messageCache *messageCache - ip netip.Addr - user *auth.User - requests *util.AtomicCounter[int64] - requestLimiter *rate.Limiter - emails *rate.Limiter - subscriptions util.Limiter - bandwidth util.Limiter - firebase time.Time // Next allowed Firebase message - seen time.Time - mu sync.Mutex + config *Config + messageCache *messageCache + ip netip.Addr + user *auth.User + messages int64 + emails int64 + requestLimiter *rate.Limiter + emailsLimiter *rate.Limiter + subscriptionLimiter util.Limiter + bandwidthLimiter util.Limiter + firebase time.Time // Next allowed Firebase message + seen time.Time + mu sync.Mutex } type visitorStats struct { - AttachmentFileSizeLimit int64 `json:"attachmentFileSizeLimit"` - VisitorAttachmentBytesTotal int64 `json:"visitorAttachmentBytesTotal"` - VisitorAttachmentBytesUsed int64 `json:"visitorAttachmentBytesUsed"` - VisitorAttachmentBytesRemaining int64 `json:"visitorAttachmentBytesRemaining"` + Messages int64 + Emails int64 + AttachmentBytes int64 } func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr, user *auth.User) *visitor { var requestLimiter *rate.Limiter if user != nil && user.Plan != nil { - requestLimiter = rate.NewLimiter(rate.Limit(user.Plan.RequestLimit)*rate.Every(24*time.Hour), conf.VisitorRequestLimitBurst) + requestLimiter = rate.NewLimiter(rate.Limit(user.Plan.MessageLimit)*rate.Every(24*time.Hour), conf.VisitorRequestLimitBurst) } else { requestLimiter = rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst) } return &visitor{ - config: conf, - messageCache: messageCache, - ip: ip, - user: user, - requests: util.NewAtomicCounter[int64](0), - requestLimiter: requestLimiter, - emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), - subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), - bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), - firebase: time.Unix(0, 0), - seen: time.Now(), + config: conf, + messageCache: messageCache, + ip: ip, + user: user, + messages: 0, // TODO + emails: 0, // TODO + requestLimiter: requestLimiter, + emailsLimiter: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), + subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), + bandwidthLimiter: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), + firebase: time.Unix(0, 0), + seen: time.Now(), } } @@ -90,7 +91,7 @@ func (v *visitor) FirebaseTemporarilyDeny() { } func (v *visitor) EmailAllowed() error { - if !v.emails.Allow() { + if !v.emailsLimiter.Allow() { return errVisitorLimitReached } return nil @@ -99,7 +100,7 @@ func (v *visitor) EmailAllowed() error { func (v *visitor) SubscriptionAllowed() error { v.mu.Lock() defer v.mu.Unlock() - if err := v.subscriptions.Allow(1); err != nil { + if err := v.subscriptionLimiter.Allow(1); err != nil { return errVisitorLimitReached } return nil @@ -108,7 +109,7 @@ func (v *visitor) SubscriptionAllowed() error { func (v *visitor) RemoveSubscription() { v.mu.Lock() defer v.mu.Unlock() - v.subscriptions.Allow(-1) + v.subscriptionLimiter.Allow(-1) } func (v *visitor) Keepalive() { @@ -118,7 +119,7 @@ func (v *visitor) Keepalive() { } func (v *visitor) BandwidthLimiter() util.Limiter { - return v.bandwidth + return v.bandwidthLimiter } func (v *visitor) Stale() bool { @@ -127,19 +128,28 @@ func (v *visitor) Stale() bool { return time.Since(v.seen) > visitorExpungeAfter } +func (v *visitor) IncrMessages() { + v.mu.Lock() + defer v.mu.Unlock() + v.messages++ +} + +func (v *visitor) IncrEmails() { + v.mu.Lock() + defer v.mu.Unlock() + v.emails++ +} + func (v *visitor) Stats() (*visitorStats, error) { attachmentsBytesUsed, err := v.messageCache.AttachmentBytesUsed(v.ip.String()) if err != nil { return nil, err } - attachmentsBytesRemaining := v.config.VisitorAttachmentTotalSizeLimit - attachmentsBytesUsed - if attachmentsBytesRemaining < 0 { - attachmentsBytesRemaining = 0 - } + v.mu.Lock() + defer v.mu.Unlock() return &visitorStats{ - AttachmentFileSizeLimit: v.config.AttachmentFileSizeLimit, - VisitorAttachmentBytesTotal: v.config.VisitorAttachmentTotalSizeLimit, - VisitorAttachmentBytesUsed: attachmentsBytesUsed, - VisitorAttachmentBytesRemaining: attachmentsBytesRemaining, + Messages: v.messages, + Emails: v.emails, + AttachmentBytes: attachmentsBytesUsed, }, nil } diff --git a/util/atomic_counter.go b/util/atomic_counter.go deleted file mode 100644 index c6c1c20a..00000000 --- a/util/atomic_counter.go +++ /dev/null @@ -1,32 +0,0 @@ -package util - -import "sync" - -type AtomicCounter[T int | int32 | int64] struct { - value T - mu sync.Mutex -} - -func NewAtomicCounter[T int | int32 | int64](value T) *AtomicCounter[T] { - return &AtomicCounter[T]{ - value: value, - } -} -func (c *AtomicCounter[T]) Inc() T { - c.mu.Lock() - defer c.mu.Unlock() - c.value++ - return c.value -} - -func (c *AtomicCounter[T]) Value() T { - c.mu.Lock() - defer c.mu.Unlock() - return c.value -} - -func (c *AtomicCounter[T]) Reset() { - c.mu.Lock() - defer c.mu.Unlock() - c.value = 0 -} diff --git a/web/src/components/Account.js b/web/src/components/Account.js index 99585738..1c728da7 100644 --- a/web/src/components/Account.js +++ b/web/src/components/Account.js @@ -19,10 +19,14 @@ import DialogActions from "@mui/material/DialogActions"; import api from "../app/Api"; import routes from "./routes"; import IconButton from "@mui/material/IconButton"; -import {NavLink, useOutletContext} from "react-router-dom"; -import Box from "@mui/material/Box"; +import {useNavigate, useOutletContext} from "react-router-dom"; +import {formatBytes} from "../app/utils"; const Account = () => { + if (!session.exists()) { + window.location.href = routes.app; + return <>; + } return ( @@ -52,10 +56,13 @@ const Basics = () => { const Stats = () => { const { t } = useTranslation(); const { account } = useOutletContext(); - const admin = account?.role === "admin" - const usage = account?.usage; - const plan = account?.plan; - const accountType = plan?.code ?? "none"; + if (!account) { + return <>; // TODO loading + } + const accountType = account.plan.code ?? "none"; + const limits = account.limits; + const usage = account.usage; + const normalize = (value, max) => (value / max * 100); return ( @@ -69,26 +76,26 @@ const Stats = () => { : t(`account_type_${accountType}`)} - +
- {usage?.requests ?? 0} - {plan?.request_limit > 0 ? t("of {{limit}}", { limit: plan.request_limit }) : t("Unlimited")} + {usage.messages} + {limits.messages > 0 ? t("of {{limit}}", { limit: limits.messages }) : t("Unlimited")}
- + 0 ? normalize(usage.messages, limits.messages) : 100} />
- +
- 15 MB used - of 150 MB + {usage.emails} + {limits.emails > 0 ? t("of {{limit}}", { limit: limits.emails }) : t("Unlimited")}
- + 0 ? normalize(usage.emails, limits.emails) : 100} />
- +
- 2 - of 15 + {formatBytes(usage.attachments_size)} + {limits.attachment_total_size > 0 ? t("of {{limit}}", { limit: formatBytes(limits.attachment_total_size) }) : t("Unlimited")}
- + 0 ? normalize(usage.attachments_size, limits.attachment_total_size) : 100} />
diff --git a/web/src/components/PublishDialog.js b/web/src/components/PublishDialog.js index 99db9af2..528bf18a 100644 --- a/web/src/components/PublishDialog.js +++ b/web/src/components/PublishDialog.js @@ -26,6 +26,7 @@ import api from "../app/Api"; import userManager from "../app/UserManager"; import EmojiPicker from "./EmojiPicker"; import {Trans, useTranslation} from "react-i18next"; +import session from "../app/Session"; const PublishDialog = (props) => { const { t } = useTranslation(); @@ -159,9 +160,11 @@ const PublishDialog = (props) => { const checkAttachmentLimits = async (file) => { try { - const stats = await api.userStats(baseUrl); - const fileSizeLimit = stats.attachmentFileSizeLimit ?? 0; - const remainingBytes = stats.visitorAttachmentBytesRemaining ?? 0; + const account = await api.getAccount(baseUrl, session.token()); + const fileSizeLimit = account.limits.attachment_file_size ?? 0; + const totalSizeLimit = account.limits.attachment_total_size ?? 0; + const usedSize = account.usage.attachments_size ?? 0; + const remainingBytes = (totalSizeLimit > 0) ? totalSizeLimit - usedSize : 0; const fileSizeLimitReached = fileSizeLimit > 0 && file.size > fileSizeLimit; const quotaReached = remainingBytes > 0 && file.size > remainingBytes; if (fileSizeLimitReached && quotaReached) {