diff --git a/cmd/access.go b/cmd/access.go index f34c02c..88f666c 100644 --- a/cmd/access.go +++ b/cmd/access.go @@ -183,11 +183,15 @@ func showUserAccess(c *cli.Context, manager *user.Manager, username string) erro func showUsers(c *cli.Context, manager *user.Manager, users []*user.User) error { for _, u := range users { + grants, err := manager.Grants(u.Name) + if err != nil { + return err + } fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", u.Name, u.Role) if u.Role == user.RoleAdmin { fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n") - } else if len(u.Grants) > 0 { - for _, grant := range u.Grants { + } else if len(grants) > 0 { + for _, grant := range grants { if grant.AllowRead && grant.AllowWrite { fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern) } else if grant.AllowRead { diff --git a/server/server.go b/server/server.go index 5e18bb7..032879b 100644 --- a/server/server.go +++ b/server/server.go @@ -1308,7 +1308,7 @@ func (s *Server) runFirebaseKeepaliver() { if s.firebaseClient == nil { return } - v := newVisitor(s.config, s.messageCache, netip.IPv4Unspecified(), nil) // Background process, not a real visitor, uses IP 0.0.0.0 + v := newVisitor(s.config, s.messageCache, s.userManager, netip.IPv4Unspecified(), nil) // Background process, not a real visitor, uses IP 0.0.0.0 for { select { case <-time.After(s.config.FirebaseKeepaliveInterval): @@ -1579,7 +1579,7 @@ func (s *Server) visitorFromID(visitorID string, ip netip.Addr, user *user.User) defer s.mu.Unlock() v, exists := s.visitors[visitorID] if !exists { - s.visitors[visitorID] = newVisitor(s.config, s.messageCache, ip, user) + s.visitors[visitorID] = newVisitor(s.config, s.messageCache, s.userManager, ip, user) return s.visitors[visitorID] } v.Keepalive() diff --git a/server/server_account.go b/server/server_account.go index 9e8cba0..c6d226b 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -94,16 +94,27 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis Upgradable: true, } } - if len(v.user.Grants) > 0 { - response.Access = make([]*apiAccountGrant, 0) - for _, grant := range v.user.Grants { - if grant.Owner { - response.Access = append(response.Access, &apiAccountGrant{ - Topic: grant.TopicPattern, - Read: grant.AllowRead, - Write: grant.AllowWrite, - }) + reservations, err := s.userManager.Reservations(v.user.Name) + if err != nil { + return err + } + if len(reservations) > 0 { + response.Reservations = make([]*apiAccountReservation, 0) + for _, r := range reservations { + var everyone string + if r.AllowEveryoneRead && r.AllowEveryoneWrite { + everyone = "read-write" + } else if r.AllowEveryoneRead && !r.AllowEveryoneWrite { + everyone = "read-only" + } else if !r.AllowEveryoneRead && r.AllowEveryoneWrite { + everyone = "write-only" + } else { + everyone = "deny-all" } + response.Reservations = append(response.Reservations, &apiAccountReservation{ + Topic: r.TopicPattern, + Everyone: everyone, + }) } } } else { @@ -356,9 +367,13 @@ func (s *Server) handleAccountAccessDelete(w http.ResponseWriter, r *http.Reques if !topicRegex.MatchString(topic) { return errHTTPBadRequestTopicInvalid } + reservations, err := s.userManager.Reservations(v.user.Name) // FIXME replace with HasReservation + if err != nil { + return err + } authorized := false - for _, grant := range v.user.Grants { - if grant.TopicPattern == topic && grant.Owner { + for _, r := range reservations { + if r.TopicPattern == topic { authorized = true break } diff --git a/server/server_firebase_test.go b/server/server_firebase_test.go index 9a21834..f18abe1 100644 --- a/server/server_firebase_test.go +++ b/server/server_firebase_test.go @@ -326,7 +326,7 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) { func TestToFirebaseSender_Abuse(t *testing.T) { sender := &testFirebaseSender{allowed: 2} client := newFirebaseClient(sender, &testAuther{}) - visitor := newVisitor(newTestConfig(t), newMemTestCache(t), netip.MustParseAddr("1.2.3.4"), nil) + visitor := newVisitor(newTestConfig(t), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil) require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"})) require.Equal(t, 1, len(sender.Messages())) diff --git a/server/server_matrix_test.go b/server/server_matrix_test.go index 77d6d4a..883a8c1 100644 --- a/server/server_matrix_test.go +++ b/server/server_matrix_test.go @@ -72,7 +72,7 @@ func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) { func TestMatrix_WriteMatrixError(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil) - v := newVisitor(newTestConfig(t), nil, netip.MustParseAddr("1.2.3.4"), nil) + v := newVisitor(newTestConfig(t), nil, nil, netip.MustParseAddr("1.2.3.4"), nil) require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch})) require.Equal(t, 200, w.Result().StatusCode) require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String()) diff --git a/server/types.go b/server/types.go index fef1269..de5c452 100644 --- a/server/types.go +++ b/server/types.go @@ -259,22 +259,21 @@ type apiAccountStats struct { AttachmentTotalSizeRemaining int64 `json:"attachment_total_size_remaining"` } -type apiAccountGrant struct { - Topic string `json:"topic"` - Read bool `json:"read"` - Write bool `json:"write"` +type apiAccountReservation struct { + Topic string `json:"topic"` + Everyone string `json:"everyone"` } type apiAccountResponse struct { - Username string `json:"username"` - Role string `json:"role,omitempty"` - Language string `json:"language,omitempty"` - Notification *user.NotificationPrefs `json:"notification,omitempty"` - Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` - Access []*apiAccountGrant `json:"access,omitempty"` - Plan *apiAccountPlan `json:"plan,omitempty"` - Limits *apiAccountLimits `json:"limits,omitempty"` - Stats *apiAccountStats `json:"stats,omitempty"` + Username string `json:"username"` + Role string `json:"role,omitempty"` + Language string `json:"language,omitempty"` + Notification *user.NotificationPrefs `json:"notification,omitempty"` + Subscriptions []*user.Subscription `json:"subscriptions,omitempty"` + Reservations []*apiAccountReservation `json:"reservations,omitempty"` + Plan *apiAccountPlan `json:"plan,omitempty"` + Limits *apiAccountLimits `json:"limits,omitempty"` + Stats *apiAccountStats `json:"stats,omitempty"` } type apiAccountAccessRequest struct { diff --git a/server/visitor.go b/server/visitor.go index 2fbce8c..0646a70 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -26,6 +26,7 @@ var ( type visitor struct { config *Config messageCache *messageCache + userManager *user.Manager // May be nil! ip netip.Addr user *user.User messages int64 // Number of messages sent @@ -57,7 +58,7 @@ type visitorInfo struct { AttachmentFileSizeLimit int64 } -func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr, user *user.User) *visitor { +func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor { var requestLimiter, emailsLimiter, accountLimiter *rate.Limiter var messages, emails int64 if user != nil { @@ -76,6 +77,7 @@ func newVisitor(conf *Config, messageCache *messageCache, ip netip.Addr, user *u return &visitor{ config: conf, messageCache: messageCache, + userManager: userManager, // May be nil! ip: ip, user: user, messages: messages, @@ -192,7 +194,7 @@ func (v *visitor) Info() (*visitorInfo, error) { info.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit info.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit } - var attachmentsBytesUsed int64 + var attachmentsBytesUsed int64 // FIXME Maybe move this to endpoint? var err error if v.user != nil { attachmentsBytesUsed, err = v.messageCache.AttachmentBytesUsedByUser(v.user.Name) @@ -203,12 +205,12 @@ func (v *visitor) Info() (*visitorInfo, error) { return nil, err } var topics int64 - if v.user != nil { - for _, grant := range v.user.Grants { - if grant.Owner { - topics++ - } + if v.user != nil && v.userManager != nil { + reservations, err := v.userManager.Reservations(v.user.Name) // FIXME dup call, move this to endpoint? + if err != nil { + return nil, err } + topics = int64(len(reservations)) } info.Messages = messages info.MessagesRemaining = zeroIfNegative(info.MessagesLimit - info.Messages) diff --git a/user/manager.go b/user/manager.go index 9916f95..41d42ec 100644 --- a/user/manager.go +++ b/user/manager.go @@ -92,7 +92,7 @@ const ( SELECT read, write FROM user_access a JOIN user u ON u.id = a.user_id - 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 ` ) @@ -123,11 +123,19 @@ const ( DO UPDATE SET read=excluded.read, write=excluded.write, owner_user_id=excluded.owner_user_id ` selectUserAccessQuery = ` - SELECT topic, read, write, IIF(owner_user_id IS NOT NULL AND user_id = owner_user_id,1,0) AS owner + SELECT topic, read, write FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) ORDER BY write DESC, read DESC, topic ` + selectUserReservationsQuery = ` + SELECT a_user.topic, a_user.read, a_user.write, a_everyone.read AS everyone_read, a_everyone.write AS everyone_write + FROM user_access a_user + LEFT JOIN user_access a_everyone ON a_user.topic = a_everyone.topic AND a_everyone.user_id = (SELECT id FROM user WHERE user = ?) + WHERE a_user.user_id = a_user.owner_user_id + AND a_user.owner_user_id = (SELECT id FROM user WHERE user = ?) + ORDER BY a_user.topic + ` selectOtherAccessCountQuery = ` SELECT count(*) FROM user_access @@ -354,7 +362,7 @@ func (a *Manager) Authorize(user *User, topic string, perm Permission) error { } // Select the read/write permissions for this user/topic combo. The query may return two // rows (one for everyone, and one for the user), but prioritizes the user. - rows, err := a.db.Query(selectTopicPermsQuery, username, topic) + rows, err := a.db.Query(selectTopicPermsQuery, Everyone, username, topic) if err != nil { return err } @@ -479,15 +487,10 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { } else if err := rows.Err(); err != nil { return nil, err } - grants, err := a.readGrants(username) - if err != nil { - return nil, err - } user := &User{ - Name: username, - Hash: hash, - Role: Role(role), - Grants: grants, + Name: username, + Hash: hash, + Role: Role(role), Stats: &Stats{ Messages: messages, Emails: emails, @@ -513,7 +516,8 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { return user, nil } -func (a *Manager) readGrants(username string) ([]Grant, error) { +// Grants returns all user-specific access control entries +func (a *Manager) Grants(username string) ([]Grant, error) { rows, err := a.db.Query(selectUserAccessQuery, username) if err != nil { return nil, err @@ -522,8 +526,8 @@ func (a *Manager) readGrants(username string) ([]Grant, error) { grants := make([]Grant, 0) for rows.Next() { var topic string - var read, write, owner bool - if err := rows.Scan(&topic, &read, &write, &owner); err != nil { + var read, write bool + if err := rows.Scan(&topic, &read, &write); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -532,12 +536,39 @@ func (a *Manager) readGrants(username string) ([]Grant, error) { TopicPattern: fromSQLWildcard(topic), AllowRead: read, AllowWrite: write, - Owner: owner, }) } return grants, nil } +// Reservations returns all user-owned topics, and the associated everyone-access +func (a *Manager) Reservations(username string) ([]Reservation, error) { + rows, err := a.db.Query(selectUserReservationsQuery, Everyone, username) + if err != nil { + return nil, err + } + defer rows.Close() + reservations := make([]Reservation, 0) + for rows.Next() { + var topic string + var read, write bool + var everyoneRead, everyoneWrite sql.NullBool + if err := rows.Scan(&topic, &read, &write, &everyoneRead, &everyoneWrite); err != nil { + return nil, err + } else if err := rows.Err(); err != nil { + return nil, err + } + reservations = append(reservations, Reservation{ + TopicPattern: topic, + AllowRead: read, + AllowWrite: write, + AllowEveryoneRead: everyoneRead.Bool, // false if null + AllowEveryoneWrite: everyoneWrite.Bool, // false if null + }) + } + return reservations, nil +} + // ChangePassword changes a user's password func (a *Manager) ChangePassword(username, password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) diff --git a/user/types.go b/user/types.go index 8c8ecac..03e67ac 100644 --- a/user/types.go +++ b/user/types.go @@ -9,14 +9,13 @@ import ( // User is a struct that represents a user type User struct { - Name string - Hash string // password hash (bcrypt) - Token string // Only set if token was used to log in - Role Role - Grants []Grant - Prefs *Prefs - Plan *Plan - Stats *Stats + Name string + Hash string // password hash (bcrypt) + Token string // Only set if token was used to log in + Role Role + Prefs *Prefs + Plan *Plan + Stats *Stats } // Auther is an interface for authentication and authorization @@ -91,7 +90,15 @@ type Grant struct { TopicPattern string // May include wildcard (*) AllowRead bool AllowWrite bool - Owner bool // This user owns this ACL entry +} + +// Reservation is a struct that represents the ownership over a topic by a user +type Reservation struct { + TopicPattern string + AllowRead bool + AllowWrite bool + AllowEveryoneRead bool + AllowEveryoneWrite bool } // Permission represents a read or write permission to a topic diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index e4982fe..5b3e723 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -239,21 +239,22 @@ "prefs_users_dialog_button_save": "Save", "prefs_appearance_title": "Appearance", "prefs_appearance_language_title": "Language", - "prefs_access_title": "Reserved topics", - "prefs_access_description": "You may reserve topic names for personal use here, and define access to a topic for other users.", - "prefs_access_add_button": "Add reserved topic", - "prefs_access_edit_button": "Edit topic access", - "prefs_access_delete_button": "Reset topic access", - "prefs_access_table": "Reserved topics table", - "prefs_access_table_topic_header": "Topic", - "prefs_access_table_access_header": "Access", - "prefs_access_table_perms_private": "Only I can publish and subscribe", - "prefs_access_table_perms_public_read": "I can publish, everyone can subscribe", - "prefs_access_table_perms_public": "Everyone can publish and subscribe", - "prefs_access_dialog_title_add": "Reserve topic", - "prefs_access_dialog_title_edit": "Edit reserved topic", - "prefs_access_dialog_topic_label": "Topic", - "prefs_access_dialog_access_label": "Access", + "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_add_button": "Add reserved topic", + "prefs_reservations_edit_button": "Edit topic access", + "prefs_reservations_delete_button": "Reset topic access", + "prefs_reservations_table": "Reserved topics table", + "prefs_reservations_table_topic_header": "Topic", + "prefs_reservations_table_access_header": "Access", + "prefs_reservations_table_everyone_deny_all": "Only I can publish and subscribe", + "prefs_reservations_table_everyone_read_only": "I can publish and subscribe, everyone can subscribe", + "prefs_reservations_table_everyone_write_only": "I can publish and subscribe, everyone can publish", + "prefs_reservations_table_everyone_read_write": "Everyone can publish and subscribe", + "prefs_reservations_dialog_title_add": "Reserve topic", + "prefs_reservations_dialog_title_edit": "Edit reserved topic", + "prefs_reservations_dialog_topic_label": "Topic", + "prefs_reservations_dialog_access_label": "Access", "priority_min": "min", "priority_low": "low", "priority_default": "default", diff --git a/web/src/components/Preferences.js b/web/src/components/Preferences.js index 49d5488..b46e461 100644 --- a/web/src/components/Preferences.js +++ b/web/src/components/Preferences.js @@ -50,7 +50,7 @@ const Preferences = () => { - + @@ -476,7 +476,7 @@ const Language = () => { ) }; -const Access = () => { +const Reservations = () => { const { t } = useTranslation(); const { account } = useOutletContext(); const [dialogKey, setDialogKey] = useState(0); @@ -506,19 +506,19 @@ const Access = () => { } return ( - + - {t("prefs_access_title")} + {t("prefs_reservations_title")} - {t("prefs_access_description")} + {t("prefs_reservations_description")} - {account.access.length > 0 && } + {account.reservations.length > 0 && } - - {t("prefs_reservations_add_button")} + { ); }; -const AccessTable = (props) => { +const ReservationsTable = (props) => { const { t } = useTranslation(); const [dialogKey, setDialogKey] = useState(0); const [dialogOpen, setDialogOpen] = useState(false); @@ -557,37 +557,59 @@ const AccessTable = (props) => { }; return ( - +
- {t("prefs_access_table_topic_header")} - {t("prefs_access_table_access_header")} + {t("prefs_reservations_table_topic_header")} + {t("prefs_reservations_table_access_header")} - {props.entries.map(entry => ( + {props.reservations.map(reservation => ( - {entry.topic} - - - {t("prefs_access_table_perms_private")} + {reservation.topic} + + {reservation.everyone === "read-write" && + <> + + {t("prefs_reservations_table_everyone_read_write")} + + } + {reservation.everyone === "read-only" && + <> + + {t("prefs_reservations_table_everyone_read_only")} + + } + {reservation.everyone === "write-only" && + <> + + {t("prefs_reservations_table_everyone_write_only")} + + } + {reservation.everyone === "deny-all" && + <> + + {t("prefs_reservations_table_everyone_deny_all")} + + } - handleEditClick(entry)} aria-label={t("prefs_access_edit_button")}> + handleEditClick(reservation)} aria-label={t("prefs_reservations_edit_button")}> - handleDeleteClick(entry)} aria-label={t("prefs_access_delete_button")}> + handleDeleteClick(reservation)} aria-label={t("prefs_reservations_delete_button")}> ))} - { ); }; -const AccessDialog = (props) => { +const ReservationsDialog = (props) => { const { t } = useTranslation(); const [topic, setTopic] = useState(""); const [access, setAccess] = useState("private"); @@ -621,15 +643,15 @@ const AccessDialog = (props) => { } }, [editMode, props]); return ( - - {editMode ? t("prefs_access_dialog_title_edit") : t("prefs_access_dialog_title_add")} + + {editMode ? t("prefs_reservations_dialog_title_edit") : t("prefs_reservations_dialog_title_add")} {!editMode && setTopic(ev.target.value)} type="url" @@ -640,7 +662,7 @@ const AccessDialog = (props) => {