M7: DeleteUser counted the global users and then deleted; the handler's demotion and CreateGlobalUser checked and then wrote. MaxOpenConns(1) serialises statements, not pairs: two globals deleting or demoting each other at once could leave none. Now: - DeleteUser: a transaction that deletes first (taking the write lock) and then requires a global left (requireAGlobal) - ErrLastGlobal rolls back. - SaveUser: likewise for a demotion, the count inside the transaction after the write. The handler's early count stays only to answer before hashing a password; SaveUser decides, and ErrLastGlobal maps to the same message. - CreateGlobalUser: INSERT ... SELECT ... WHERE NOT EXISTS (SELECT 1 FROM users), one statement. M9: submitUserUpdate made five writes in a row (UpdateUser, sessions, SetUserRole, ClearUserReach with its error only logged, SetUserReach). store.SaveUser(UserChange) writes name, hash, e-mail, role and reach in one transaction (a global user's reach cleared); sessions end after it commits. The create path had the same flaw - user, reach and then e-mail in three writes - and now goes through AddUser, one transaction; CreateUser stays as a wrapper for its many test callers. SetUserRole is gone; writeUserReach is the reach write inside a transaction. Tests: store - two globals demoting, and deleting, each other from two goroutines: exactly one refused, one global left; a refused SaveUser (taken name, a reach to no domain, nothing assigned) changes nothing; promoted to global loses the reach; AddUser with a bad reach leaves no user; four setups at once create one user. Handlers (the users pages, direct): the whole change saved, back to domain with a reach; the last global not demoted and the typed rename not written; a domain user with nothing assigned refused, nothing changed; create with e-mail and reach. CHANGELOG [Unreleased], audit plan lines ticked (two), roadmap 17/62. Evidence: go vet ./... clean; go test ./internal/store ok; the users tests of ./internal/web/handlers ok; the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
498 lines
18 KiB
Go
498 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
"github.com/mixeme/selfpost/internal/web/auth"
|
|
"github.com/mixeme/selfpost/internal/web/validate"
|
|
"github.com/mixeme/selfpost/internal/web/view"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// userFormView is what the user form is drawn with: a refusal, and the values
|
|
// the form holds — the stored user's, or what was typed into a form that was
|
|
// refused.
|
|
type userFormView struct {
|
|
FormErr string
|
|
FormUsername string
|
|
FormEmail string
|
|
FormRole string
|
|
Reach store.Reach
|
|
}
|
|
|
|
// HandleUsers lists panel users (global only).
|
|
func (h *Handlers) HandleUsers(w http.ResponseWriter, r *http.Request) {
|
|
p, ok := h.requireGlobal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
rows, err := h.store.ListUserRows()
|
|
if err != nil {
|
|
logf("panel: list users: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
list := make([]view.UserRow, len(rows))
|
|
for i, row := range rows {
|
|
u := row.User
|
|
list[i] = view.NewUserRow(view.UserRowInput{
|
|
ID: u.ID, Username: u.Username, Email: u.Email, Global: u.Role == store.RoleGlobal, You: u.ID == p.ID,
|
|
AllOutbound: u.AllDomains, AllInbound: u.AllInboundDomains,
|
|
Outbound: row.DomainNames, Inbound: row.InboundDomainNames,
|
|
})
|
|
}
|
|
page := view.NewUsers(h.shellMeta(r), h.cfg.InboundEnabled, usersFlash(r)).WithRows(list)
|
|
h.view.Render(w, http.StatusOK, "users", page)
|
|
}
|
|
|
|
func usersFlash(r *http.Request) string {
|
|
switch r.URL.Query().Get("done") {
|
|
case "created":
|
|
return "User created."
|
|
case "updated":
|
|
return "User updated."
|
|
case "deleted":
|
|
return "User deleted."
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// HandleUserNew creates a panel user (global only).
|
|
func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)})
|
|
case http.MethodPost:
|
|
h.submitUserCreate(w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// HandleUserEdit edits or deletes a panel user (global only).
|
|
func (h *Handlers) HandleUserEdit(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
uid, ok := parseUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
u, err := h.store.GetUser(uid)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUserNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
logf("panel: get user %d: %v", uid, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
h.renderUserForm(w, r, http.StatusOK, u.ID, userFormView{
|
|
FormUsername: u.Username,
|
|
FormEmail: u.Email,
|
|
FormRole: string(u.Role),
|
|
Reach: u.Reach(),
|
|
})
|
|
case http.MethodPost:
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
|
return
|
|
}
|
|
h.submitUserUpdate(w, r, u)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) renderUserForm(w http.ResponseWriter, r *http.Request, status int, userID int64, form userFormView) {
|
|
domains, err := h.store.ListDomains()
|
|
if err != nil {
|
|
logf("panel: user form: list domains: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
in := view.UserFormInput{
|
|
ID: userID, Username: form.FormUsername, Email: form.FormEmail, Role: form.FormRole,
|
|
RoleLocked: lastGlobalLocked(h, userID, form.FormRole),
|
|
PasswordMin: validate.MinAdminPasswordLen,
|
|
ShowInbound: h.cfg.InboundEnabled,
|
|
AllOut: form.Reach.AllDomains, AllIn: form.Reach.AllInbound,
|
|
Error: form.FormErr,
|
|
}
|
|
// The head names the user as stored, not as typed into a form that was
|
|
// refused; Delete user is not offered for the only global user.
|
|
if userID != 0 {
|
|
in.Name = form.FormUsername
|
|
if u, err := h.store.GetUser(userID); err == nil {
|
|
in.Name = u.Username
|
|
}
|
|
in.CanDelete = !in.RoleLocked
|
|
}
|
|
out := idSet(form.Reach.DomainIDs)
|
|
for _, d := range domains {
|
|
in.OutDomains = append(in.OutDomains, view.DomainChoice{ID: d.ID, Name: d.Name, Checked: out[d.ID]})
|
|
}
|
|
// The two lists of the form, each with its All tick. Inbound is offered
|
|
// only where the feature is on.
|
|
if h.cfg.InboundEnabled {
|
|
inboundDomains, err := h.store.ListInboundDomains()
|
|
if err != nil {
|
|
logf("panel: user form: list inbound domains: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
inb := idSet(form.Reach.InboundDomainIDs)
|
|
for _, d := range inboundDomains {
|
|
in.InDomains = append(in.InDomains, view.DomainChoice{ID: d.ID, Name: d.Name, Checked: inb[d.ID]})
|
|
}
|
|
}
|
|
h.view.Render(w, status, "user", view.NewUserForm(h.shellMeta(r), in))
|
|
}
|
|
|
|
// lastGlobalLocked is whether the form's user is the only global one, whose role
|
|
// cannot be changed (the form shows it read-only).
|
|
func lastGlobalLocked(h *Handlers, userID int64, formRole string) bool {
|
|
if userID == 0 || formRole != string(store.RoleGlobal) {
|
|
return false
|
|
}
|
|
n, err := h.store.CountGlobalUsers()
|
|
return err == nil && n <= 1
|
|
}
|
|
|
|
func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Invalid form submission."})
|
|
return
|
|
}
|
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
email := strings.TrimSpace(r.PostFormValue("email"))
|
|
password := r.PostFormValue("password")
|
|
role := store.Role(r.PostFormValue("role"))
|
|
reach := h.reachFromForm(r, store.Reach{})
|
|
|
|
if err := validate.Username(username); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
if err := validate.AccountEmail(email); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
if err := validate.AdminPassword(password); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
if role != store.RoleGlobal && role != store.RoleDomain {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
if role == store.RoleDomain && reach.Empty() {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
if err != nil {
|
|
logf("panel: create user hash: %v", err)
|
|
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Internal error. Please try again."})
|
|
return
|
|
}
|
|
// The user, the e-mail and the reach in one transaction: a failure leaves
|
|
// no user rather than half of one (audit 2026-10-12, M9).
|
|
if _, err := h.store.AddUser(store.UserChange{Username: username, PasswordHash: string(hash), Email: email, Role: role, Reach: reach}); err != nil {
|
|
if errors.Is(err, store.ErrUserExists) {
|
|
h.renderUserForm(w, r, http.StatusConflict, 0, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
logf("panel: create user: %v", err)
|
|
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/server/users?done=created", http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Invalid form submission.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
|
return
|
|
}
|
|
p, ok := h.principal(r)
|
|
if !ok {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
username := strings.TrimSpace(r.PostFormValue("username"))
|
|
// The form always sends the e-mail, empty when cleared. A post without the
|
|
// field at all is not a request to clear it.
|
|
email := u.Email
|
|
if _, sent := r.PostForm["email"]; sent {
|
|
email = strings.TrimSpace(r.PostFormValue("email"))
|
|
}
|
|
password := r.PostFormValue("password")
|
|
role := store.Role(r.PostFormValue("role"))
|
|
reach := h.reachFromForm(r, u.Reach())
|
|
|
|
if username == "" {
|
|
username = u.Username
|
|
}
|
|
if err := validate.Username(username); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
if err := validate.AccountEmail(email); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
if role != store.RoleGlobal && role != store.RoleDomain {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
if role == store.RoleDomain && reach.Empty() {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Assign at least one outbound or inbound domain to a domain administrator.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
|
|
lastGlobal := func() {
|
|
msg := "Cannot demote the last global administrator."
|
|
if u.ID == p.ID {
|
|
msg = "You cannot demote yourself without another global administrator."
|
|
}
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: msg, FormUsername: username, FormEmail: email, FormRole: string(u.Role), Reach: reach})
|
|
}
|
|
// Asked here to answer before a password is hashed; SaveUser decides,
|
|
// inside its transaction.
|
|
if u.Role == store.RoleGlobal && role == store.RoleDomain {
|
|
n, err := h.store.CountGlobalUsers()
|
|
if err != nil || n <= 1 {
|
|
lastGlobal()
|
|
return
|
|
}
|
|
}
|
|
|
|
hash := u.PasswordHash
|
|
if password != "" {
|
|
if err := validate.AdminPassword(password); err != nil {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
if err != nil {
|
|
logf("panel: update user hash: %v", err)
|
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Internal error. Please try again.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
hash = string(newHash)
|
|
}
|
|
|
|
// Name, password, e-mail, role and reach in one transaction (audit
|
|
// 2026-10-12, M9). Sessions end only once it has committed.
|
|
err := h.store.SaveUser(store.UserChange{ID: u.ID, Username: username, PasswordHash: hash, Email: email, Role: role, Reach: reach})
|
|
switch {
|
|
case errors.Is(err, store.ErrUserExists):
|
|
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
case errors.Is(err, store.ErrLastGlobal):
|
|
lastGlobal()
|
|
return
|
|
case err != nil:
|
|
logf("panel: update user: %v", err)
|
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
|
return
|
|
}
|
|
if password != "" {
|
|
h.endSessionsAfterPasswordSet(r, p, u)
|
|
}
|
|
http.Redirect(w, r, "/server/users?done=updated", http.StatusSeeOther)
|
|
}
|
|
|
|
// endSessionsAfterPasswordSet signs out the logins a user had under the old
|
|
// password when an administrator sets a new one on the user form. An
|
|
// administrator who sets their own password keeps the browser they are using.
|
|
func (h *Handlers) endSessionsAfterPasswordSet(r *http.Request, by auth.Principal, u store.User) {
|
|
if u.ID == by.ID {
|
|
if token, ok := h.auth.SessionToken(r); ok {
|
|
h.auth.DestroyOtherSessions(u.ID, token)
|
|
return
|
|
}
|
|
}
|
|
h.auth.DestroyUserSessions(u.ID)
|
|
}
|
|
|
|
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
|
|
// removed — the same pattern as HandleDeleteConfirm for domains, so a single
|
|
// mis-click on Delete cannot remove a user (P3, code-review.md).
|
|
func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
uid, ok := parseUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
u, err := h.store.GetUser(uid)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUserNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
logf("panel: get user %d: %v", uid, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
in := view.UserDeleteInput{
|
|
ID: u.ID, Username: u.Username, Global: u.Role == store.RoleGlobal,
|
|
AllOutbound: u.AllDomains, AllInbound: u.AllInboundDomains,
|
|
}
|
|
if !in.Global {
|
|
if in.Outbound, in.Inbound, err = h.assignedNames(u); err != nil {
|
|
logf("panel: delete user %d: assigned domains: %v", u.ID, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
h.view.Render(w, http.StatusOK, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
|
|
}
|
|
|
|
// assignedNames names the domains a domain user is assigned one by one, outbound
|
|
// and inbound: what deleting the user takes away. A direction the user holds as
|
|
// All has no names.
|
|
func (h *Handlers) assignedNames(u store.User) (outbound, inbound []string, err error) {
|
|
if len(u.DomainIDs) > 0 && !u.AllDomains {
|
|
domains, err := h.store.ListDomains()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
assigned := idSet(u.DomainIDs)
|
|
for _, d := range domains {
|
|
if assigned[d.ID] {
|
|
outbound = append(outbound, d.Name)
|
|
}
|
|
}
|
|
}
|
|
if len(u.InboundDomainIDs) > 0 && !u.AllInboundDomains {
|
|
domains, err := h.store.ListInboundDomains()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
assigned := idSet(u.InboundDomainIDs)
|
|
for _, d := range domains {
|
|
if assigned[d.ID] {
|
|
inbound = append(inbound, d.Name)
|
|
}
|
|
}
|
|
}
|
|
return outbound, inbound, nil
|
|
}
|
|
|
|
// HandleUserDelete performs the deletion confirmed on HandleUserDeleteConfirm
|
|
// and returns to the user list.
|
|
func (h *Handlers) HandleUserDelete(w http.ResponseWriter, r *http.Request) {
|
|
if _, ok := h.requireGlobal(w, r); !ok {
|
|
return
|
|
}
|
|
uid, ok := parseUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
u, err := h.store.GetUser(uid)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrUserNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
logf("panel: get user %d: %v", uid, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h.submitUserDelete(w, r, u)
|
|
}
|
|
|
|
func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u store.User) {
|
|
p, ok := h.principal(r)
|
|
if !ok {
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if u.ID == p.ID {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "You cannot delete your own account while signed in.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
|
return
|
|
}
|
|
if err := h.store.DeleteUser(u.ID); err != nil {
|
|
if errors.Is(err, store.ErrLastGlobal) {
|
|
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Cannot delete the last global administrator.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
|
return
|
|
}
|
|
logf("panel: delete user %d: %v", u.ID, err)
|
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormEmail: u.Email, FormRole: string(u.Role)})
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/server/users?done=deleted", http.StatusSeeOther)
|
|
}
|
|
|
|
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("uid"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
http.NotFound(w, r)
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// reachFromForm reads the two assignment lists of the user form. With an All
|
|
// box ticked the rows under it are ignored — the form works without scripts,
|
|
// so both may arrive — and All then also covers domains added later. Where
|
|
// inbound is switched off the form has no inbound list, and what the user
|
|
// already had there (current) is kept rather than wiped by an unrelated save.
|
|
func (h *Handlers) reachFromForm(r *http.Request, current store.Reach) store.Reach {
|
|
reach := store.Reach{
|
|
AllDomains: r.PostFormValue("all_domains") != "",
|
|
AllInbound: current.AllInbound, InboundDomainIDs: current.InboundDomainIDs,
|
|
}
|
|
if !reach.AllDomains {
|
|
reach.DomainIDs = parseIDs(r, "domain_ids")
|
|
}
|
|
if h.cfg.InboundEnabled {
|
|
reach.AllInbound = r.PostFormValue("all_inbound_domains") != ""
|
|
reach.InboundDomainIDs = nil
|
|
if !reach.AllInbound {
|
|
reach.InboundDomainIDs = parseIDs(r, "inbound_domain_ids")
|
|
}
|
|
}
|
|
return reach
|
|
}
|
|
|
|
func parseIDs(r *http.Request, field string) []int64 {
|
|
var ids []int64
|
|
seen := make(map[int64]bool)
|
|
for _, v := range r.PostForm[field] {
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err == nil && id > 0 && !seen[id] {
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func idSet(ids []int64) map[int64]bool {
|
|
m := make(map[int64]bool, len(ids))
|
|
for _, id := range ids {
|
|
m[id] = true
|
|
}
|
|
return m
|
|
}
|