test / test (push) Canceled after 0s
The last stage of the 2026-10-12 audit; no cut. Doc drift: architecture.md (the sender check in § OpenDKIM, the limit: row's 48 hours, the inbound log and filter-list routes and who may open them, migrations to 0011 and every table, KitPage as production data, Reload configuration as a restore writes, the panel's roles counted), schema-migrations.md (tables and columns since v2), guide.md (a delivery's lines go at the next prune; INBOUND_JOURNAL_MILTER_SOCKET moved to architecture.md's internal list), development.md (the env guard, the deferral the e2e proves, its fixed sleeps), roadmap.md. Comments say what the code does now; TestBaselineIsTheOnlyMigration is TestChainStartsAtTheBaseline; the milter package doc names its three milters. Dead code gone: view.Facts, dmarc HostedSuggestion and ValidateHostedAddress, app UpdateMode (each with the test that alone kept it), the method checks the method-bound routes made dead; Settings.WithResult calls resultFlash. staticcheck 2026.1 in test.yml on both modules, clean: the e2e package builds without its tests now (a test and two constants moved out of non-test files), three findings fixed. The test moved, TestImageBuildPreservesLogrotateMode, had sat in a non-test file and never run; run, it failed on the entrypoint (docker run without a hostname) and now asks with --entrypoint stat. The test gaps no step named, each closed: follow() with mail.log gone past blindMin and made again; CheckRestore with a manifest that is not JSON; the origin check with a port; every /server/ pattern in web.go through server() and in the routes table (it found GET /server/health/fragment missing from the table - guarded, not tested); L9 as the owner decided it on 2026-10-12, the comment on senderSession.Headers and the test passing an h that holds the earlier messages' From:. Plan stage 6 ticked; roadmap audit row done, 81/81. Tested on the test server: go vet + go test of 27 packages, make e2e (all pass, 513 s) on this tree; staticcheck locally on both modules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
7.4 KiB
Go
188 lines
7.4 KiB
Go
package handlers
|
||
|
||
import (
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||
"github.com/mixeme/selfpost/internal/health"
|
||
"github.com/mixeme/selfpost/internal/notify"
|
||
"github.com/mixeme/selfpost/internal/store"
|
||
"github.com/mixeme/selfpost/internal/web/view"
|
||
)
|
||
|
||
// Server › Settings holds what is true of the whole instance, whoever is
|
||
// signed in: log retention (the outbound log's, and the inbound log's while the
|
||
// inbound relay is on), the read-only view of the rate limits, the switch for
|
||
// notification e-mails and the DNS records of the server's own name those are
|
||
// sent from. A
|
||
// user's own name, e-mail, password and DMARC default are on Account
|
||
// (handlers_account.go). Before placing a setting here, ask whose it is.
|
||
|
||
// HandleServerSettings shows and saves the instance settings (global role).
|
||
func (h *Handlers) HandleServerSettings(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := h.requireGlobal(w, r); !ok {
|
||
return
|
||
}
|
||
// The routes are method-bound (GET and POST), so it is one or the other.
|
||
if r.Method == http.MethodPost {
|
||
h.submitServerSettings(w, r)
|
||
return
|
||
}
|
||
h.renderServerSettings(w, r, http.StatusOK, "", h.currentRetentions())
|
||
}
|
||
|
||
// retentions are the two journal retentions the Settings form shows: the
|
||
// outbound log's, and the inbound log's while the inbound relay is on.
|
||
type retentions struct {
|
||
outbound, inbound int
|
||
}
|
||
|
||
func (h *Handlers) currentRetentions() retentions {
|
||
cur := retentions{outbound: h.sendLogRetentionDays()}
|
||
if h.cfg.InboundEnabled {
|
||
cur.inbound = h.inboundSpamLogRetentionDays()
|
||
}
|
||
return cur
|
||
}
|
||
|
||
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, form retentions) {
|
||
page := view.NewSettings(h.shellMeta(r), strconv.Itoa(form.outbound), view.FormatRate(h.l1Messages(), h.l1Window()))
|
||
on, err := h.store.NotificationsEnabled()
|
||
if err != nil {
|
||
// Shown as on: the form saves what it shows, and a failed read must not
|
||
// become "off" at the next Save.
|
||
logf("panel: settings: read the notifications switch: %v", err)
|
||
on = true
|
||
}
|
||
page.WithNotifications(on, notify.From(h.cfg.Hostname))
|
||
page.WithMail(h.serverMail(false).records)
|
||
// The inbound field exists while there is an inbound journal to keep.
|
||
if h.cfg.InboundEnabled {
|
||
page.WithInbound(strconv.Itoa(form.inbound))
|
||
}
|
||
flash := ""
|
||
if r.URL.Query().Has("saved") {
|
||
flash = "Settings saved."
|
||
}
|
||
page.WithResult(flash, formErr)
|
||
h.view.Render(w, status, "settings", page)
|
||
}
|
||
|
||
func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
|
||
current := h.currentRetentions()
|
||
if err := r.ParseForm(); err != nil {
|
||
h.renderServerSettings(w, r, http.StatusBadRequest, "Invalid form submission.", current)
|
||
return
|
||
}
|
||
days, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("send_log_retention_days")))
|
||
if err != nil {
|
||
h.renderServerSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", current)
|
||
return
|
||
}
|
||
form := retentions{outbound: days, inbound: current.inbound}
|
||
if err := store.ValidateSendLogRetentionDays(days); err != nil {
|
||
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), form)
|
||
return
|
||
}
|
||
if h.cfg.InboundEnabled {
|
||
inboundDays, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("inbound_spam_log_retention_days")))
|
||
if err != nil {
|
||
h.renderServerSettings(w, r, http.StatusBadRequest, "Inbound log retention must be a whole number of days.", form)
|
||
return
|
||
}
|
||
form.inbound = inboundDays
|
||
if err := store.ValidateInboundSpamLogRetentionDays(inboundDays); err != nil {
|
||
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), form)
|
||
return
|
||
}
|
||
}
|
||
// An unticked checkbox is not sent at all: absent is off.
|
||
// HTML Living Standard, form-control-infrastructure.html#constructing-entry-list:
|
||
// a checkbox whose checkedness is false is skipped.
|
||
notifications := r.PostFormValue("notify") != ""
|
||
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||
logf("panel: settings: set send-log retention failed: %v", err)
|
||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||
"Could not save send log retention. Please check the logs and try again.", form)
|
||
return
|
||
}
|
||
if err := h.store.SetNotificationsEnabled(notifications); err != nil {
|
||
logf("panel: settings: set the notifications switch failed: %v", err)
|
||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||
"Could not save the notifications switch. Please check the logs and try again.", form)
|
||
return
|
||
}
|
||
if h.cfg.InboundEnabled {
|
||
if err := h.store.SetInboundSpamLogRetentionDays(form.inbound); err != nil {
|
||
logf("panel: settings: set inbound-log retention failed: %v", err)
|
||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||
"Could not save inbound log retention. Please check the logs and try again.", form)
|
||
return
|
||
}
|
||
logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days, notifications: %t)", days, form.inbound, notifications)
|
||
} else {
|
||
logf("panel: instance settings updated (send-log retention: %d days, notifications: %t)", days, notifications)
|
||
}
|
||
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
|
||
}
|
||
|
||
// sendLogRetentionDays returns the effective delivery-journal retention window.
|
||
func (h *Handlers) sendLogRetentionDays() int {
|
||
days, err := h.store.GetSendLogRetentionDays(h.cfg.SendLogRetentionEnvDefault)
|
||
if err != nil {
|
||
logf("panel: send-log retention: %v", err)
|
||
if h.cfg.SendLogRetentionEnvDefault > 0 {
|
||
return h.cfg.SendLogRetentionEnvDefault
|
||
}
|
||
return store.SendLogRetentionDaysDefault
|
||
}
|
||
return days
|
||
}
|
||
|
||
// serverMailState is the server's own name as a sender of notifications: the
|
||
// records to publish for it, as Settings draws them, and their state, as
|
||
// Health lists it.
|
||
type serverMailState struct {
|
||
records []view.Record
|
||
rows []view.MailRow
|
||
// status is the verdict of Health's card: ok, warn or unknown. A record
|
||
// that is not in place is a warning, never an error: without them the
|
||
// notifications are still sent.
|
||
status string
|
||
}
|
||
|
||
// serverMail reads the records of the server's own name and checks them in DNS
|
||
// (cached like a domain's; force skips the cache, for Re-check DNS). When the
|
||
// DKIM key cannot be read the page is still shown: the DKIM record is left out,
|
||
// the SPF record is shown unchecked, and the card does not claim it is fine.
|
||
func (h *Handlers) serverMail(force bool) serverMailState {
|
||
host := h.cfg.Hostname
|
||
spfOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
|
||
return view.ServerMailSPF(host, dnscheck.ServerMailSPF, c), view.NewMailRow("SPF", host, c)
|
||
}
|
||
mail, err := h.domains.ServerMail()
|
||
if err != nil {
|
||
logf("panel: server mail: %v", err)
|
||
rec, row := spfOf(view.DNSCheck{Status: string(health.StatusUnknown)})
|
||
return serverMailState{records: []view.Record{rec}, rows: []view.MailRow{row}, status: string(health.StatusUnknown)}
|
||
}
|
||
srv := h.dns.Server(host, false)
|
||
check := h.dns.ServerMail(dnscheck.Query{
|
||
Name: host, Selector: mail.Selector, ExpectedDKIM: mail.Record.Value,
|
||
Hostname: srv.Hostname, ServerIPs: srv.IPs,
|
||
}, force)
|
||
dkim := dnsCheck(check.DKIM)
|
||
spfRec, spfRow := spfOf(dnsCheck(check.SPF))
|
||
status := check.Overall
|
||
if status == health.StatusError {
|
||
status = health.StatusWarn
|
||
}
|
||
return serverMailState{
|
||
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec},
|
||
rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow},
|
||
status: string(status),
|
||
}
|
||
}
|