panel-redesign: the 2.0 schema from zero - one baseline, no 1.x chain
Stage 1, step 1. BREAKING: a 1.x database is not migrated.
Schema
- internal/store/migrations/ holds one file, 0001_init.sql, equal to the
2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
admin table created and dropped, no ALTERs.
- users.email replaces users.dmarc_report_email: the address is the
user's own. The default DMARC report address is a user's setting
(dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
the instance setting that mirrored the global user's field is gone.
- domains.dmarc_rua_user_id: a domain follows a named user's default, or
has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
leaves it at "no reports", never at someone else's default.
- user_inbound_domains, users.all_domains, users.all_inbound_domains: in
the schema for the inbound delegation of the next step.
- users.role is 'global' / 'domain' (was 'domain_admin');
rate_limits.allowed_ips, unused since 1.9.0, is dropped.
Opening a database
- The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
file that has a schema version without it (ErrForeignSchema) and one
newer than the build. A 1.x file at user_version 10 would otherwise read
as already migrated. The refused file is left untouched.
Code
- Store.DomainDMARCRua is the one place a domain's report address is
resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
DNS re-check go through it.
- The old templates keep working on the new model until their pages are
restyled: the Settings form's address field is the account e-mail and
switches the user's default between it and none; a domain's "Same as
Settings" makes it follow the user who chose it; a new or imported
domain follows its creator.
- Domain export/import: a domain that follows a user exports no address.
Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.
docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.
go test ./... (Windows)
ok for every package except two that fail the same way before this commit:
internal/domain TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
internal/logtail TestFollowTailsAndRotates (rename of an open file)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,8 +18,8 @@ const (
|
||||
type Role = store.Role
|
||||
|
||||
const (
|
||||
RoleGlobal = store.RoleGlobal
|
||||
RoleDomainAdmin = store.RoleDomainAdmin
|
||||
RoleGlobal = store.RoleGlobal
|
||||
RoleDomain = store.RoleDomain
|
||||
)
|
||||
|
||||
// Principal is the authenticated panel user attached to a request.
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
@@ -35,6 +36,22 @@ func (h *Handlers) assignedDomains(p auth.Principal) ([]store.Domain, error) {
|
||||
return h.store.ListDomainsForUser(p.ID)
|
||||
}
|
||||
|
||||
// hostedDMARCAddress is the SelfPost-hosted report mailbox for a sending
|
||||
// domain, "" when report ingest is off on this server.
|
||||
func (h *Handlers) hostedDMARCAddress(domainName string) string {
|
||||
if h.dmarc == nil || !h.cfg.DMARCEnabled {
|
||||
return ""
|
||||
}
|
||||
return dmarc.HostedReportAddress(h.cfg.Hostname, domainName)
|
||||
}
|
||||
|
||||
// domainReportAddress resolves where a domain's DMARC aggregate reports go:
|
||||
// its own address, or the default of the user it follows. "" is a policy-only
|
||||
// record.
|
||||
func (h *Handlers) domainReportAddress(d store.Domain) (string, error) {
|
||||
return h.store.DomainDMARCRua(d, h.hostedDMARCAddress(d.Name))
|
||||
}
|
||||
|
||||
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||
m := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
|
||||
@@ -153,29 +153,37 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
// What DNS actually publishes for the domain today, checked against the key
|
||||
// this server signs with. Cached by the checker, so re-rendering the page
|
||||
// after a form post costs nothing.
|
||||
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
||||
dns, srv := h.domainDNS(d, record, profileEmail, false)
|
||||
dns, srv := h.domainDNS(d, record, reportEmail, false)
|
||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||
hostedAddr := h.hostedDMARCAddress(d.Name)
|
||||
// The form's "inherit" is a domain that follows a user's default. What it
|
||||
// offers next to that choice is the address following would give: the
|
||||
// followed user's when the domain already follows one, otherwise the
|
||||
// signed-in user's own — choosing it makes the domain follow them.
|
||||
dmarcMode := "inherit"
|
||||
dmarcCustom := ""
|
||||
hostedAddr := ""
|
||||
if h.dmarc != nil && h.cfg.DMARCEnabled {
|
||||
hostedAddr = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
}
|
||||
if d.DMARCRua.Valid {
|
||||
if d.DMARCRua.String == "" {
|
||||
profileEmail := reportEmail
|
||||
if !d.DMARCRuaUserID.Valid {
|
||||
profileEmail = ""
|
||||
if p, ok := h.principal(r); ok {
|
||||
if def, err := h.store.GetDMARCDefault(p.ID); err == nil {
|
||||
profileEmail = def.Resolve(hostedAddr)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case d.DMARCRua == "":
|
||||
dmarcMode = "none"
|
||||
} else if hostedAddr != "" && strings.EqualFold(d.DMARCRua.String, hostedAddr) {
|
||||
case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr):
|
||||
dmarcMode = "hosted"
|
||||
} else {
|
||||
default:
|
||||
dmarcMode = "custom"
|
||||
dmarcCustom = d.DMARCRua.String
|
||||
dmarcCustom = d.DMARCRua
|
||||
}
|
||||
}
|
||||
dmarcSource := "policy"
|
||||
@@ -186,7 +194,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
dmarcSource = "custom"
|
||||
case dmarcMode == "none":
|
||||
dmarcSource = "none"
|
||||
case profileEmail != "":
|
||||
case reportEmail != "":
|
||||
dmarcSource = "settings"
|
||||
}
|
||||
|
||||
@@ -250,7 +258,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
|
||||
// and no extra environment variable is needed. That server result is returned
|
||||
// alongside, because the page's suggested SPF record is built from the same
|
||||
// addresses. force bypasses the cache, for the Re-check button.
|
||||
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||
func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, reportEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||
srv := h.dns.Server(h.cfg.Hostname, false)
|
||||
return h.dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
@@ -258,7 +266,7 @@ func (h *Handlers) domainDNS(d store.Domain, record domain.DKIMRecord, profileEm
|
||||
ExpectedDKIM: record.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
|
||||
DMARCReportEmail: reportEmail,
|
||||
}, force), srv
|
||||
}
|
||||
|
||||
@@ -275,13 +283,13 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
profileEmail, err := h.store.GlobalDMARCReportEmail()
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: global dmarc email: %v", d.ID, err)
|
||||
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.domainDNS(d, record, profileEmail, true)
|
||||
h.domainDNS(d, record, reportEmail, true)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -277,6 +277,13 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderBackupPage(w, r, status, msg)
|
||||
return
|
||||
}
|
||||
// An export without an address of its own followed someone's default on the
|
||||
// server it came from; here it follows whoever imports it.
|
||||
if exp.DMARCRua == nil {
|
||||
if p, ok := h.principal(r); ok {
|
||||
h.followCreator(d.ID, p.ID)
|
||||
}
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -26,12 +25,14 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var rua sql.NullString
|
||||
// "inherit" makes the domain follow the default of the user who chose it;
|
||||
// every other mode gives the domain an address of its own ("" = none).
|
||||
follow := false
|
||||
rua := ""
|
||||
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
|
||||
case "inherit":
|
||||
rua = sql.NullString{}
|
||||
follow = true
|
||||
case "none":
|
||||
rua = sql.NullString{Valid: true, String: ""}
|
||||
case "custom":
|
||||
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
||||
if err := validate.Email(email); err != nil {
|
||||
@@ -42,20 +43,25 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
|
||||
return
|
||||
}
|
||||
rua = sql.NullString{Valid: true, String: email}
|
||||
rua = email
|
||||
case "hosted":
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
|
||||
return
|
||||
}
|
||||
addr := dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
rua = sql.NullString{Valid: true, String: addr}
|
||||
rua = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
|
||||
default:
|
||||
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
|
||||
var err error
|
||||
if p, ok := h.principal(r); follow && ok {
|
||||
err = h.store.SetDomainDMARCUser(d.ID, p.ID)
|
||||
} else {
|
||||
err = h.store.SetDomainDMARCAddress(d.ID, rua)
|
||||
}
|
||||
if err != nil {
|
||||
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
|
||||
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// postFormAs sends a form to a handler as the given principal.
|
||||
func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues map[string]string, values url.Values) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(values.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for k, v := range pathValues {
|
||||
req.SetPathValue(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, auth.RequestWithPrincipal(req, p))
|
||||
return rec
|
||||
}
|
||||
|
||||
// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that
|
||||
// follows the default of the user who chose it; every other choice gives the
|
||||
// domain an address of its own and stops it following anyone.
|
||||
func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
h.domains = domain.NewService(h.store, nil, nil, "selfpost")
|
||||
d, err := h.store.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
admin, err := h.store.GetUserByUsername("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserByUsername: %v", err)
|
||||
}
|
||||
p := auth.Principal{ID: admin.ID, Username: "admin", Role: auth.RoleGlobal}
|
||||
if err := h.store.UpdateUser(admin.ID, "admin", admin.PasswordHash, "mix@example.org"); err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
if err := h.store.SetDMARCDefault(admin.ID, store.DMARCDefaultAccount, ""); err != nil {
|
||||
t.Fatalf("SetDMARCDefault: %v", err)
|
||||
}
|
||||
save := func(values url.Values) store.Domain {
|
||||
t.Helper()
|
||||
rec := postFormAs(h.HandleDomainDMARC, p, "/domains/1/dmarc", map[string]string{"id": "1"}, values)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
|
||||
}
|
||||
got, err := h.store.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
got := save(url.Values{"dmarc_rua_mode": {"inherit"}})
|
||||
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != admin.ID {
|
||||
t.Fatalf("inherit: the domain follows %v, want user %d", got.DMARCRuaUserID, admin.ID)
|
||||
}
|
||||
if rua, err := h.domainReportAddress(got); err != nil || rua != "mix@example.org" {
|
||||
t.Fatalf("inherit resolves to %q, %v; want the user's account e-mail", rua, err)
|
||||
}
|
||||
|
||||
got = save(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"dmarc@hub.example"}})
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "dmarc@hub.example" {
|
||||
t.Fatalf("custom: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
got = save(url.Values{"dmarc_rua_mode": {"none"}})
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
|
||||
t.Fatalf("none: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Until the Account page replaces the old Settings form, its single address
|
||||
// field is the account e-mail and switches the user's DMARC default between
|
||||
// "my account e-mail" and "none". Nothing is written to the instance settings.
|
||||
func TestSettingsFormMapsToAccountEmailAndDefault(t *testing.T) {
|
||||
h, password := settingsServer(t)
|
||||
submit := func(email string) store.User {
|
||||
t.Helper()
|
||||
rec := postFormAs(h.HandleSettings, globalPrincipal, "/settings", nil, url.Values{
|
||||
"username": {"admin"}, "current_password": {password}, "dmarc_report_email": {email},
|
||||
})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST /settings (%q) = %d, want 303:\n%s", email, rec.Code, rec.Body.String())
|
||||
}
|
||||
u, err := h.store.GetUser(globalPrincipal.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
u := submit("mix@example.org")
|
||||
if u.Email != "mix@example.org" || u.DMARCDefaultMode != store.DMARCDefaultAccount {
|
||||
t.Fatalf("after setting the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
|
||||
}
|
||||
if v, err := h.store.GetSetting("dmarc_report_email"); err == nil && v != "" {
|
||||
t.Fatalf("the address was mirrored into the instance settings: %q", v)
|
||||
}
|
||||
|
||||
u = submit("")
|
||||
if u.Email != "" || u.DMARCDefaultMode != store.DMARCDefaultNone {
|
||||
t.Fatalf("after clearing the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
|
||||
}
|
||||
}
|
||||
|
||||
// A new domain takes its report address from whoever created it.
|
||||
func TestFollowCreator(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
d, err := h.store.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
h.followCreator(d.ID, globalPrincipal.ID)
|
||||
got, err := h.store.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != globalPrincipal.ID {
|
||||
t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID)
|
||||
}
|
||||
// The creator's default is "none" until they choose one, so the new
|
||||
// domain publishes a policy-only record — as a fresh 1.x install did.
|
||||
if rua, err := h.domainReportAddress(got); err != nil || rua != "" {
|
||||
t.Fatalf("resolved %q, %v; want no report address yet", rua, err)
|
||||
}
|
||||
}
|
||||
@@ -51,10 +51,6 @@ func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, statu
|
||||
}
|
||||
|
||||
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||
profileEmail := ""
|
||||
if email, err := h.store.GlobalDMARCReportEmail(); err == nil {
|
||||
profileEmail = email
|
||||
}
|
||||
rows := make([]domainRow, len(domains))
|
||||
var wg sync.WaitGroup
|
||||
for i, d := range domains {
|
||||
@@ -67,7 +63,12 @@ func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
|
||||
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := h.domainDNS(d, record, profileEmail, false)
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: dashboard: domain %d: dmarc report address: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := h.domainDNS(d, record, reportEmail, false)
|
||||
rows[i].DNS = dns.Overall
|
||||
}()
|
||||
}
|
||||
@@ -86,7 +87,8 @@ func dashboardFlash(r *http.Request) string {
|
||||
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||
// publish is shown (product.md).
|
||||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
p, ok := h.requireGlobal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -111,9 +113,26 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
"Could not add the domain. Please check the logs and try again.", raw)
|
||||
return
|
||||
}
|
||||
h.followCreator(d.ID, p.ID)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// followCreator makes a new domain take its DMARC report address from the
|
||||
// default of whoever created it (plan § Account e-mail). The domain exists
|
||||
// either way: a failure here leaves it with no report address, which its page
|
||||
// shows and its DMARC form can change.
|
||||
func (h *Handlers) followCreator(domainID, userID int64) {
|
||||
if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil {
|
||||
logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err)
|
||||
return
|
||||
}
|
||||
if h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: domain %d: dmarc resync: %v", domainID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
|
||||
@@ -373,7 +373,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
|
||||
if err != nil {
|
||||
t.Fatalf("add placeholder domain: %v", err)
|
||||
}
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomain, []int64{placeholder.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("create domain admin %s: %v", username, err)
|
||||
}
|
||||
@@ -387,7 +387,7 @@ func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...in
|
||||
}
|
||||
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
|
||||
}
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
|
||||
id, err := st.CreateUser(username, hash, store.RoleDomain, domainIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("create domain admin %s: %v", username, err)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, h.sendLogRetentionDays(), p.IsGlobal())
|
||||
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal())
|
||||
case http.MethodPost:
|
||||
h.submitSettings(w, r)
|
||||
default:
|
||||
@@ -128,7 +128,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||
formRetention := currentRetention
|
||||
if !p.IsGlobal() {
|
||||
dmarcEmail = user.DMARCReportEmail
|
||||
dmarcEmail = user.Email
|
||||
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
@@ -161,7 +161,7 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
|
||||
emailChanging := p.IsGlobal() && dmarcEmail != user.Email
|
||||
|
||||
retentionChanging := false
|
||||
if p.IsGlobal() && formRetention != currentRetention {
|
||||
@@ -218,6 +218,23 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Until the Account page replaces this form (stage 2), its one "default
|
||||
// report address" field stands for both halves of the 2.0 model: it is the
|
||||
// account e-mail, and the user's DMARC default is "my account e-mail" while
|
||||
// it is filled and "none" once it is cleared.
|
||||
if emailChanging {
|
||||
mode := store.DMARCDefaultNone
|
||||
if dmarcEmail != "" {
|
||||
mode = store.DMARCDefaultAccount
|
||||
}
|
||||
if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil {
|
||||
logf("panel: settings: set dmarc default failed: %v", err)
|
||||
h.renderSettings(w, r, http.StatusInternalServerError,
|
||||
"Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if retentionChanging {
|
||||
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
|
||||
logf("panel: settings: set send-log retention failed: %v", err)
|
||||
|
||||
@@ -58,7 +58,7 @@ func (h *Handlers) HandleUserNew(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomainAdmin)})
|
||||
h.renderUserForm(w, r, http.StatusOK, 0, userFormView{FormRole: string(store.RoleDomain)})
|
||||
case http.MethodPost:
|
||||
h.submitUserCreate(w, r)
|
||||
default:
|
||||
@@ -162,11 +162,11 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||
if role != store.RoleGlobal && role != store.RoleDomain {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
if role == store.RoleDomain && len(domainIDs) == 0 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, 0, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: domainIDSetFromForm(r)})
|
||||
return
|
||||
}
|
||||
@@ -213,16 +213,16 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: err.Error(), FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role != store.RoleGlobal && role != store.RoleDomainAdmin {
|
||||
if role != store.RoleGlobal && role != store.RoleDomain {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Choose a valid role.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
if role == store.RoleDomainAdmin && len(domainIDs) == 0 {
|
||||
if role == store.RoleDomain && len(domainIDs) == 0 {
|
||||
h.renderUserForm(w, r, http.StatusBadRequest, u.ID, userFormView{FormErr: "Select at least one domain for a domain administrator.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
}
|
||||
|
||||
if u.Role == store.RoleGlobal && role == store.RoleDomainAdmin {
|
||||
if u.Role == store.RoleGlobal && role == store.RoleDomain {
|
||||
n, err := h.store.CountGlobalUsers()
|
||||
if err != nil || n <= 1 {
|
||||
msg := "Cannot demote the last global administrator."
|
||||
@@ -249,7 +249,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := h.store.UpdateUser(u.ID, username, hash, u.DMARCReportEmail); err != nil {
|
||||
if err := h.store.UpdateUser(u.ID, username, hash, u.Email); err != nil {
|
||||
if errors.Is(err, store.ErrUserExists) {
|
||||
h.renderUserForm(w, r, http.StatusConflict, u.ID, userFormView{FormErr: "That username is already in use.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
return
|
||||
@@ -272,7 +272,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
}
|
||||
}
|
||||
|
||||
if role == store.RoleDomainAdmin {
|
||||
if role == store.RoleDomain {
|
||||
if err := h.store.SetUserDomains(u.ID, domainIDs); err != nil {
|
||||
logf("panel: set user domains: %v", err)
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save domain assignments.", FormUsername: username, FormRole: string(role), FormDomains: selected})
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<label for="role">Role</label>
|
||||
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
||||
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
|
||||
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="domain" {{if eq .FormRole "domain"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
|
||||
</select>
|
||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user