Startup queries, foreign keys

This commit is contained in:
binwiederhier 2023-01-05 15:20:44 -05:00
parent 3280c2c440
commit 60f1882bec
14 changed files with 148 additions and 69 deletions

View file

@ -49,6 +49,7 @@ var flagsServe = append(
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-batch-timeout", Aliases: []string{"cache_batch_timeout"}, EnvVars: []string{"NTFY_CACHE_BATCH_TIMEOUT"}, Usage: "timeout for batched async writes to the message cache (if zero, writes are synchronous)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-startup-queries", Aliases: []string{"auth_startup_queries"}, EnvVars: []string{"NTFY_AUTH_STARTUP_QUERIES"}, Usage: "queries run when the auth database is initialized"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
@ -77,6 +78,8 @@ var flagsServe = append(
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "xxx"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "xxx"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "xxx"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "xxx"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-payments", Aliases: []string{"enable_payments"}, EnvVars: []string{"NTFY_ENABLE_PAYMENTS"}, Value: false, Usage: "xxx"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-reserve-topics", Aliases: []string{"enable_reserve_topics"}, EnvVars: []string{"NTFY_ENABLE_RESERVE_TOPICS"}, Value: false, Usage: "xxx"}),
) )
var cmdServe = &cli.Command{ var cmdServe = &cli.Command{
@ -118,6 +121,7 @@ func execServe(c *cli.Context) error {
cacheBatchSize := c.Int("cache-batch-size") cacheBatchSize := c.Int("cache-batch-size")
cacheBatchTimeout := c.Duration("cache-batch-timeout") cacheBatchTimeout := c.Duration("cache-batch-timeout")
authFile := c.String("auth-file") authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access") authDefaultAccess := c.String("auth-default-access")
attachmentCacheDir := c.String("attachment-cache-dir") attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
@ -146,6 +150,8 @@ func execServe(c *cli.Context) error {
behindProxy := c.Bool("behind-proxy") behindProxy := c.Bool("behind-proxy")
enableSignup := c.Bool("enable-signup") enableSignup := c.Bool("enable-signup")
enableLogin := c.Bool("enable-login") enableLogin := c.Bool("enable-login")
enablePayments := c.Bool("enable-payments")
enableReserveTopics := c.Bool("enable-reserve-topics")
// Check values // Check values
if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) { if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
@ -182,6 +188,8 @@ func execServe(c *cli.Context) error {
return errors.New("if upstream-base-url is set, base-url must also be set") return errors.New("if upstream-base-url is set, base-url must also be set")
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL { } else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications") return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
} else if authFile == "" && (enableSignup || enableLogin || enableReserveTopics || enablePayments) {
return errors.New("cannot set enable-signup, enable-login, enable-reserve-topics, or enable-payments if auth-file is not set")
} }
webRootIsApp := webRoot == "app" webRootIsApp := webRoot == "app"
@ -245,6 +253,7 @@ func execServe(c *cli.Context) error {
conf.CacheBatchSize = cacheBatchSize conf.CacheBatchSize = cacheBatchSize
conf.CacheBatchTimeout = cacheBatchTimeout conf.CacheBatchTimeout = cacheBatchTimeout
conf.AuthFile = authFile conf.AuthFile = authFile
conf.AuthStartupQueries = authStartupQueries
conf.AuthDefault = authDefault conf.AuthDefault = authDefault
conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
@ -274,6 +283,8 @@ func execServe(c *cli.Context) error {
conf.EnableWeb = enableWeb conf.EnableWeb = enableWeb
conf.EnableSignup = enableSignup conf.EnableSignup = enableSignup
conf.EnableLogin = enableLogin conf.EnableLogin = enableLogin
conf.EnablePayments = enablePayments
conf.EnableReserveTopics = enableReserveTopics
conf.Version = c.App.Version conf.Version = c.App.Version
// Set up hot-reloading of config // Set up hot-reloading of config

View file

@ -268,6 +268,7 @@ func execUserList(c *cli.Context) error {
func createUserManager(c *cli.Context) (*user.Manager, error) { func createUserManager(c *cli.Context) (*user.Manager, error) {
authFile := c.String("auth-file") authFile := c.String("auth-file")
authStartupQueries := c.String("auth-startup-queries")
authDefaultAccess := c.String("auth-default-access") authDefaultAccess := c.String("auth-default-access")
if authFile == "" { if authFile == "" {
return nil, errors.New("option auth-file not set; auth is unconfigured for this server") return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
@ -278,7 +279,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
if err != nil { if err != nil {
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'") return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
} }
return user.NewManager(authFile, authDefault) return user.NewManager(authFile, authStartupQueries, authDefault)
} }
func readPasswordAndConfirm(c *cli.Context) (string, error) { func readPasswordAndConfirm(c *cli.Context) (string, error) {

View file

@ -67,6 +67,7 @@ type Config struct {
CacheBatchSize int CacheBatchSize int
CacheBatchTimeout time.Duration CacheBatchTimeout time.Duration
AuthFile string AuthFile string
AuthStartupQueries string
AuthDefault user.Permission AuthDefault user.Permission
AttachmentCacheDir string AttachmentCacheDir string
AttachmentTotalSizeLimit int64 AttachmentTotalSizeLimit int64
@ -104,11 +105,12 @@ type Config struct {
VisitorAccountCreateLimitReplenish time.Duration VisitorAccountCreateLimitReplenish time.Duration
BehindProxy bool BehindProxy bool
EnableWeb bool EnableWeb bool
EnableSignup bool EnableSignup bool // Enable creation of accounts via API and UI
EnableLogin bool EnableLogin bool
EnableEmailConfirm bool EnableEmailConfirm bool
EnablePasswordReset bool EnablePasswordReset bool
EnablePayments bool EnablePayments bool
EnableReserveTopics bool // Allow users with role "user" to own/reserve topics
Version string // injected by App Version string // injected by App
} }

View file

@ -40,6 +40,9 @@ import (
message cache duration message cache duration
Keep 10000 messages or keep X days? Keep 10000 messages or keep X days?
Attachment expiration based on plan Attachment expiration based on plan
plan:
weirdness with admin and "default" account
"account topic" sync mechanism
purge accounts that were not logged into in X purge accounts that were not logged into in X
reset daily limits for users reset daily limits for users
max token issue limit max token issue limit
@ -165,7 +168,7 @@ func New(conf *Config) (*Server, error) {
} }
var userManager *user.Manager var userManager *user.Manager
if conf.AuthFile != "" { if conf.AuthFile != "" {
userManager, err = user.NewManager(conf.AuthFile, conf.AuthDefault) userManager, err = user.NewManager(conf.AuthFile, conf.AuthStartupQueries, conf.AuthDefault)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -453,6 +456,8 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
EnableLogin: s.config.EnableLogin, EnableLogin: s.config.EnableLogin,
EnableSignup: s.config.EnableSignup, EnableSignup: s.config.EnableSignup,
EnablePasswordReset: s.config.EnablePasswordReset, EnablePasswordReset: s.config.EnablePasswordReset,
EnablePayments: s.config.EnablePayments,
EnableReserveTopics: s.config.EnableReserveTopics,
DisallowedTopics: disallowedTopics, DisallowedTopics: disallowedTopics,
} }
b, err := json.Marshal(response) b, err := json.Marshal(response)

View file

@ -288,5 +288,6 @@ type apiConfigResponse struct {
EnableSignup bool `json:"enable_signup"` EnableSignup bool `json:"enable_signup"`
EnablePasswordReset bool `json:"enable_password_reset"` EnablePasswordReset bool `json:"enable_password_reset"`
EnablePayments bool `json:"enable_payments"` EnablePayments bool `json:"enable_payments"`
EnableReserveTopics bool `json:"enable_reserve_topics"`
DisallowedTopics []string `json:"disallowed_topics"` DisallowedTopics []string `json:"disallowed_topics"`
} }

View file

@ -59,7 +59,8 @@ const (
write INT NOT NULL, write INT NOT NULL,
owner_user_id INT, owner_user_id INT,
PRIMARY KEY (user_id, topic), PRIMARY KEY (user_id, topic),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES user (id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS user_token ( CREATE TABLE IF NOT EXISTS user_token (
user_id INT NOT NULL, user_id INT NOT NULL,
@ -75,6 +76,10 @@ const (
INSERT INTO user (id, user, pass, role) VALUES (1, '*', '', 'anonymous') ON CONFLICT (id) DO NOTHING; INSERT INTO user (id, user, pass, role) VALUES (1, '*', '', 'anonymous') ON CONFLICT (id) DO NOTHING;
` `
createTablesQueries = `BEGIN; ` + createTablesQueriesNoTx + ` COMMIT;` createTablesQueries = `BEGIN; ` + createTablesQueriesNoTx + ` COMMIT;`
builtinStartupQueries = `
PRAGMA foreign_keys = ON;
`
selectUserByNameQuery = ` selectUserByNameQuery = `
SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.topics_limit, p.attachment_file_size_limit, p.attachment_total_size_limit SELECT u.user, u.pass, u.role, u.messages, u.emails, u.settings, p.code, p.messages_limit, p.emails_limit, p.topics_limit, p.attachment_file_size_limit, p.attachment_total_size_limit
FROM user u FROM user u
@ -95,10 +100,7 @@ const (
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic
ORDER BY u.user DESC ORDER BY u.user DESC
` `
)
// Manager-related queries
const (
insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)` insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
selectUsernamesQuery = ` selectUsernamesQuery = `
SELECT user SELECT user
@ -150,7 +152,6 @@ const (
updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` updateTokenExpiryQuery = `UPDATE user_token SET expires = ? WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?`
deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?`
deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires < ?` deleteExpiredTokensQuery = `DELETE FROM user_token WHERE expires < ?`
deleteUserTokensQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?)`
) )
// Schema management queries // Schema management queries
@ -191,12 +192,12 @@ type Manager struct {
var _ Auther = (*Manager)(nil) var _ Auther = (*Manager)(nil)
// NewManager creates a new Manager instance // NewManager creates a new Manager instance
func NewManager(filename string, defaultAccess Permission) (*Manager, error) { func NewManager(filename, startupQueries string, defaultAccess Permission) (*Manager, error) {
return newManager(filename, defaultAccess, userTokenExpiryDuration, userStatsQueueWriterInterval) return newManager(filename, startupQueries, defaultAccess, userTokenExpiryDuration, userStatsQueueWriterInterval)
} }
// NewManager creates a new Manager instance // NewManager creates a new Manager instance
func newManager(filename string, defaultAccess Permission, tokenExpiryDuration, statsWriterInterval time.Duration) (*Manager, error) { func newManager(filename, startupQueries string, defaultAccess Permission, tokenExpiryDuration, statsWriterInterval time.Duration) (*Manager, error) {
db, err := sql.Open("sqlite3", filename) db, err := sql.Open("sqlite3", filename)
if err != nil { if err != nil {
return nil, err return nil, err
@ -204,6 +205,9 @@ func newManager(filename string, defaultAccess Permission, tokenExpiryDuration,
if err := setupDB(db); err != nil { if err := setupDB(db); err != nil {
return nil, err return nil, err
} }
if err := runStartupQueries(db, startupQueries); err != nil {
return nil, err
}
manager := &Manager{ manager := &Manager{
db: db, db: db,
defaultAccess: defaultAccess, defaultAccess: defaultAccess,
@ -223,11 +227,12 @@ func (a *Manager) Authenticate(username, password string) (*User, error) {
} }
user, err := a.User(username) user, err := a.User(username)
if err != nil { if err != nil {
bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash), log.Trace("authentication of user %s failed (1): %s", username, err.Error())
[]byte("intentional slow-down to avoid timing attacks")) bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
log.Trace("authentication of user %s failed (2): %s", username, err.Error())
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
return user, nil return user, nil
@ -407,21 +412,11 @@ func (a *Manager) RemoveUser(username string) error {
if !AllowedUsername(username) { if !AllowedUsername(username) {
return ErrInvalidArgument return ErrInvalidArgument
} }
tx, err := a.db.Begin() // Rows in user_access, user_token, etc. are deleted via foreign keys
if err != nil { if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
return err return err
} }
defer tx.Rollback() return nil
if _, err := tx.Exec(deleteUserAccessQuery, username); err != nil {
return err
}
if _, err := tx.Exec(deleteUserTokensQuery, username); err != nil {
return err
}
if _, err := tx.Exec(deleteUserQuery, username); err != nil {
return err
}
return tx.Commit()
} }
// Users returns a list of users. It always also returns the Everyone user ("*"). // Users returns a list of users. It always also returns the Everyone user ("*").
@ -666,6 +661,16 @@ func fromSQLWildcard(s string) string {
return strings.ReplaceAll(s, "%", "*") return strings.ReplaceAll(s, "%", "*")
} }
func runStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil {
return err
}
if _, err := db.Exec(builtinStartupQueries); err != nil {
return err
}
return nil
}
func setupDB(db *sql.DB) error { func setupDB(db *sql.DB) error {
// If 'schemaVersion' table does not exist, this must be a new database // If 'schemaVersion' table does not exist, this must be a new database
rowsSV, err := db.Query(selectSchemaVersionQuery) rowsSV, err := db.Query(selectSchemaVersionQuery)

View file

@ -12,5 +12,6 @@ var config = {
enable_signup: true, enable_signup: true,
enable_password_reset: false, enable_password_reset: false,
enable_payments: true, enable_payments: true,
enable_reserve_topics: true,
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"] disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "pricing", "signup", "login", "reset-password"]
}; };

View file

@ -177,6 +177,7 @@
"account_usage_title": "Usage", "account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}", "account_usage_of_limit": "of {{limit}}",
"account_usage_unlimited": "Unlimited", "account_usage_unlimited": "Unlimited",
"account_usage_limits_reset_daily": "Usage limits are reset daily at midnight (UTC)",
"account_usage_plan_title": "Account type", "account_usage_plan_title": "Account type",
"account_usage_plan_code_default": "Default", "account_usage_plan_code_default": "Default",
"account_usage_plan_code_unlimited": "Unlimited", "account_usage_plan_code_unlimited": "Unlimited",
@ -189,7 +190,7 @@
"account_usage_topics_title": "Reserved topics", "account_usage_topics_title": "Reserved topics",
"account_usage_attachment_storage_title": "Attachment storage", "account_usage_attachment_storage_title": "Attachment storage",
"account_usage_attachment_storage_subtitle": "{{filesize}} per file", "account_usage_attachment_storage_subtitle": "{{filesize}} per file",
"account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users.", "account_usage_basis_ip_description": "Usage stats and limits for this account are based on your IP address, so they may be shared with other users. Limits shown above are approximates based on the existing rate limits.",
"account_delete_title": "Delete account", "account_delete_title": "Delete account",
"account_delete_description": "Permanently delete your account", "account_delete_description": "Permanently delete your account",
"account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please type '{{username}}' in the text box below.", "account_delete_dialog_description": "This will permanently delete your account, including all data that is stored on the server. If you really want to proceed, please type '{{username}}' in the text box below.",
@ -243,7 +244,7 @@
"prefs_appearance_title": "Appearance", "prefs_appearance_title": "Appearance",
"prefs_appearance_language_title": "Language", "prefs_appearance_language_title": "Language",
"prefs_reservations_title": "Reserved topics", "prefs_reservations_title": "Reserved topics",
"prefs_reservations_description": "You may reserve topic names for personal use here, and define access to a topic for other users.", "prefs_reservations_description": "You can reserve topic names for personal use here. Reserving a topic gives you ownership over the topic, and allows you to define access permissions for other users over the topic.",
"prefs_reservations_add_button": "Add reserved topic", "prefs_reservations_add_button": "Add reserved topic",
"prefs_reservations_edit_button": "Edit topic access", "prefs_reservations_edit_button": "Edit topic access",
"prefs_reservations_delete_button": "Reset topic access", "prefs_reservations_delete_button": "Reset topic access",

View file

@ -1,5 +1,3 @@
import routes from "../components/routes";
class Session { class Session {
store(username, token) { store(username, token) {
localStorage.setItem("user", username); localStorage.setItem("user", username);

View file

@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import {useState} from 'react'; import {useState} from 'react';
import {LinearProgress, Stack, useMediaQuery} from "@mui/material"; import {LinearProgress, Link, Stack, useMediaQuery} from "@mui/material";
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
@ -21,7 +21,9 @@ import IconButton from "@mui/material/IconButton";
import {useOutletContext} from "react-router-dom"; import {useOutletContext} from "react-router-dom";
import {formatBytes} from "../app/utils"; import {formatBytes} from "../app/utils";
import accountApi, {UnauthorizedError} from "../app/AccountApi"; import accountApi, {UnauthorizedError} from "../app/AccountApi";
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import {Pref, PrefGroup} from "./Pref"; import {Pref, PrefGroup} from "./Pref";
import db from "../app/db";
const Account = () => { const Account = () => {
if (!session.exists()) { if (!session.exists()) {
@ -169,6 +171,15 @@ const Stats = () => {
} }
const planCode = account.plan.code ?? "none"; const planCode = account.plan.code ?? "none";
const normalize = (value, max) => Math.min(value / max * 100, 100); const normalize = (value, max) => Math.min(value / max * 100, 100);
const barColor = (remaining, limit) => {
if (account.role === "admin") {
return "primary";
} else if (limit > 0 && remaining === 0) {
return "error";
}
return "primary";
};
return ( return (
<Card sx={{p: 3}} aria-label={t("account_usage_title")}> <Card sx={{p: 3}} aria-label={t("account_usage_title")}>
<Typography variant="h5" sx={{marginBottom: 2}}> <Typography variant="h5" sx={{marginBottom: 2}}>
@ -180,20 +191,37 @@ const Stats = () => {
{account.role === "admin" {account.role === "admin"
? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></> ? <>{t("account_usage_unlimited")} <Tooltip title={t("account_basics_username_admin_tooltip")}><span style={{cursor: "default"}}>👑</span></Tooltip></>
: t(`account_usage_plan_code_${planCode}`)} : t(`account_usage_plan_code_${planCode}`)}
{config.enable_payments && account.plan.upgradeable &&
<em>{" "}
<Link onClick={() => {}}>Upgrade</Link>
</em>
}
</div> </div>
</Pref> </Pref>
<Pref title={t("account_usage_topics_title")}> <Pref title={t("account_usage_topics_title")}>
<div> {account.limits.topics > 0 &&
<Typography variant="body2" sx={{float: "left"}}>{account.stats.topics}</Typography> <>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.topics > 0 ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")}</Typography> <div>
</div> <Typography variant="body2" sx={{float: "left"}}>{account.stats.topics}</Typography>
<LinearProgress <Typography variant="body2" sx={{float: "right"}}>{account.role === "user" ? t("account_usage_of_limit", { limit: account.limits.topics }) : t("account_usage_unlimited")}</Typography>
variant="determinate" </div>
value={account.limits.topics > 0 ? normalize(account.stats.topics, account.limits.topics) : 100} <LinearProgress
color={account?.role !== "admin" && account.stats.topics_remaining === 0 ? 'error' : 'primary'} variant="determinate"
/> value={account.limits.topics > 0 ? normalize(account.stats.topics, account.limits.topics) : 100}
color={barColor(account.stats.topics_remaining, account.limits.topics)}
/>
</>
}
{account.limits.topics === 0 &&
<em>No reserved topics for this account</em>
}
</Pref> </Pref>
<Pref title={t("account_usage_messages_title")}> <Pref title={
<>
{t("account_usage_messages_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
</>
}>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.messages}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.messages > 0 ? t("account_usage_of_limit", { limit: account.limits.messages }) : t("account_usage_unlimited")}</Typography>
@ -201,10 +229,15 @@ const Stats = () => {
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={account.limits.messages > 0 ? normalize(account.stats.messages, account.limits.messages) : 100} value={account.limits.messages > 0 ? normalize(account.stats.messages, account.limits.messages) : 100}
color={account?.role !== "admin" && account.stats.messages_remaining === 0 ? 'error' : 'primary'} color={account.role === "user" && account.stats.messages_remaining === 0 ? 'error' : 'primary'}
/> />
</Pref> </Pref>
<Pref title={t("account_usage_emails_title")}> <Pref title={
<>
{t("account_usage_emails_title")}
<Tooltip title={t("account_usage_limits_reset_daily")}><span><InfoIcon/></span></Tooltip>
</>
}>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography> <Typography variant="body2" sx={{float: "left"}}>{account.stats.emails}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.emails > 0 ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.emails > 0 ? t("account_usage_of_limit", { limit: account.limits.emails }) : t("account_usage_unlimited")}</Typography>
@ -215,7 +248,14 @@ const Stats = () => {
color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'} color={account?.role !== "admin" && account.stats.emails_remaining === 0 ? 'error' : 'primary'}
/> />
</Pref> </Pref>
<Pref title={t("account_usage_attachment_storage_title")} subtitle={account.role !== "admin" ? t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) }) : null}> <Pref title={
<>
{t("account_usage_attachment_storage_title")}
{account.role === "user" &&
<Tooltip title={t("account_usage_attachment_storage_subtitle", { filesize: formatBytes(account.limits.attachment_file_size) })}><span><InfoIcon/></span></Tooltip>
}
</>
}>
<div> <div>
<Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography> <Typography variant="body2" sx={{float: "left"}}>{formatBytes(account.stats.attachment_total_size)}</Typography>
<Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography> <Typography variant="body2" sx={{float: "right"}}>{account.limits.attachment_total_size > 0 ? t("account_usage_of_limit", { limit: formatBytes(account.limits.attachment_total_size) }) : t("account_usage_unlimited")}</Typography>
@ -236,6 +276,17 @@ const Stats = () => {
); );
}; };
const InfoIcon = () => {
return (
<InfoOutlinedIcon sx={{
verticalAlign: "bottom",
width: "18px",
marginLeft: "4px",
color: "gray"
}}/>
);
}
const Delete = () => { const Delete = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (

View file

@ -89,7 +89,6 @@ const Layout = () => {
return ( return (
<Box sx={{display: 'flex'}}> <Box sx={{display: 'flex'}}>
<CssBaseline/>
<ActionBar <ActionBar
selected={selected} selected={selected}
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)} onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}

View file

@ -485,7 +485,7 @@ const Reservations = () => {
const [dialogKey, setDialogKey] = useState(0); const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
if (!session.exists() || !account || account.role === "admin") { if (!config.enable_reserve_topics || !session.exists() || !account || account.role === "admin") {
return <></>; return <></>;
} }
const reservations = account.reservations || []; const reservations = account.reservations || [];

View file

@ -76,7 +76,7 @@ const SubscribeDialog = (props) => {
const SubscribePage = (props) => { const SubscribePage = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useOutletContext(); //const { account } = useOutletContext();
const [reserveTopicVisible, setReserveTopicVisible] = useState(false); const [reserveTopicVisible, setReserveTopicVisible] = useState(false);
const [anotherServerVisible, setAnotherServerVisible] = useState(false); const [anotherServerVisible, setAnotherServerVisible] = useState(false);
const [errorText, setErrorText] = useState(""); const [errorText, setErrorText] = useState("");
@ -87,7 +87,7 @@ const SubscribePage = (props) => {
const existingBaseUrls = Array const existingBaseUrls = Array
.from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)])) .from(new Set([publicBaseUrl, ...props.subscriptions.map(s => s.baseUrl)]))
.filter(s => s !== config.base_url); .filter(s => s !== config.base_url);
const reserveTopicEnabled = session.exists() && (account?.stats.topics_remaining || 0) > 0; //const reserveTopicEnabled = session.exists() && (account?.stats.topics_remaining || 0) > 0;
const handleSubscribe = async () => { const handleSubscribe = async () => {
const user = await userManager.get(baseUrl); // May be undefined const user = await userManager.get(baseUrl); // May be undefined
@ -177,14 +177,14 @@ const SubscribePage = (props) => {
{t("subscribe_dialog_subscribe_button_generate_topic_name")} {t("subscribe_dialog_subscribe_button_generate_topic_name")}
</Button> </Button>
</div> </div>
{session.exists() && !anotherServerVisible && {config.enable_reserve_topics && session.exists() && !anotherServerVisible &&
<FormGroup> <FormGroup>
<FormControlLabel <FormControlLabel
variant="standard" variant="standard"
control={ control={
<Checkbox <Checkbox
fullWidth fullWidth
disabled={account.stats.topics_remaining} // disabled={account.stats.topics_remaining}
checked={reserveTopicVisible} checked={reserveTopicVisible}
onChange={(ev) => setReserveTopicVisible(ev.target.checked)} onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
inputProps={{ inputProps={{

View file

@ -78,26 +78,30 @@ const SubscriptionSettingsDialog = (props) => {
"aria-label": t("subscription_settings_dialog_display_name_placeholder") "aria-label": t("subscription_settings_dialog_display_name_placeholder")
}} }}
/> />
<FormControlLabel {config.enable_reserve_topics && session.exists() &&
fullWidth <>
variant="standard" <FormControlLabel
sx={{pt: 1}} fullWidth
control={ variant="standard"
<Checkbox sx={{pt: 1}}
checked={reserveTopicVisible} control={
onChange={(ev) => setReserveTopicVisible(ev.target.checked)} <Checkbox
inputProps={{ checked={reserveTopicVisible}
"aria-label": t("subscription_settings_dialog_reserve_topic_label") onChange={(ev) => setReserveTopicVisible(ev.target.checked)}
}} inputProps={{
"aria-label": t("subscription_settings_dialog_reserve_topic_label")
}}
/>
}
label={t("subscription_settings_dialog_reserve_topic_label")}
/> />
} {reserveTopicVisible &&
label={t("subscription_settings_dialog_reserve_topic_label")} <ReserveTopicSelect
/> value={everyone}
{reserveTopicVisible && onChange={setEveryone}
<ReserveTopicSelect />
value={everyone} }
onChange={setEveryone} </>
/>
} }
</DialogContent> </DialogContent>
<DialogFooter> <DialogFooter>