1
0
binwiederhier 3 жил өмнө
parent
commit
6598ce2fe4

+ 6 - 5
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 {

+ 13 - 12
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

+ 2 - 17
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()

+ 37 - 20
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
 	}

+ 1 - 1
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)
 }
 

+ 17 - 12
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"`
 }

+ 50 - 40
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
 }

+ 0 - 32
util/atomic_counter.go

@@ -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
-}

+ 25 - 18
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 (
         <Container maxWidth="md" sx={{marginTop: 3, marginBottom: 3}}>
             <Stack spacing={3}>
@@ -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 (
         <Card sx={{p: 3}} aria-label={t("xxxxxxxxx")}>
             <Typography variant="h5" sx={{marginBottom: 2}}>
@@ -69,26 +76,26 @@ const Stats = () => {
                             : t(`account_type_${accountType}`)}
                     </div>
                 </Pref>
-                <Pref labelId={"dailyMessages"} title={t("Daily messages")}>
+                <Pref labelId={"messages"} title={t("Published messages")}>
                     <div>
-                        <Typography variant="body2" sx={{float: "left"}}>{usage?.requests ?? 0}</Typography>
-                        <Typography variant="body2" sx={{float: "right"}}>{plan?.request_limit > 0 ? t("of {{limit}}", { limit: plan.request_limit }) : t("Unlimited")}</Typography>
+                        <Typography variant="body2" sx={{float: "left"}}>{usage.messages}</Typography>
+                        <Typography variant="body2" sx={{float: "right"}}>{limits.messages > 0 ? t("of {{limit}}", { limit: limits.messages }) : t("Unlimited")}</Typography>
                     </div>
-                    <LinearProgress variant="determinate" value={10} />
+                    <LinearProgress variant="determinate" value={limits.messages > 0 ? normalize(usage.messages, limits.messages) : 100} />
                 </Pref>
-                <Pref labelId={"attachmentStorage"} title={t("Attachment storage")}>
+                <Pref labelId={"emails"} title={t("Emails sent")}>
                     <div>
-                        <Typography variant="body2" sx={{float: "left"}}>15 MB used</Typography>
-                        <Typography variant="body2" sx={{float: "right"}}>of 150 MB</Typography>
+                        <Typography variant="body2" sx={{float: "left"}}>{usage.emails}</Typography>
+                        <Typography variant="body2" sx={{float: "right"}}>{limits.emails > 0 ? t("of {{limit}}", { limit: limits.emails }) : t("Unlimited")}</Typography>
                     </div>
-                    <LinearProgress variant="determinate" value={40} />
+                    <LinearProgress variant="determinate" value={limits.emails > 0 ? normalize(usage.emails, limits.emails) : 100} />
                 </Pref>
-                <Pref labelId={"emailLimits"} title={t("Emails sent")}>
+                <Pref labelId={"attachments"} title={t("Attachment storage")}>
                     <div>
-                        <Typography variant="body2" sx={{float: "left"}}>2</Typography>
-                        <Typography variant="body2" sx={{float: "right"}}>of 15</Typography>
+                        <Typography variant="body2" sx={{float: "left"}}>{formatBytes(usage.attachments_size)}</Typography>
+                        <Typography variant="body2" sx={{float: "right"}}>{limits.attachment_total_size > 0 ? t("of {{limit}}", { limit: formatBytes(limits.attachment_total_size) }) : t("Unlimited")}</Typography>
                     </div>
-                    <LinearProgress variant="determinate" value={20} />
+                    <LinearProgress variant="determinate" value={limits.attachment_total_size > 0 ? normalize(usage.attachments_size, limits.attachment_total_size) : 100} />
                 </Pref>
             </PrefGroup>
         </Card>

+ 6 - 3
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) {