fc53ae1314
Phase 12 (UI/UX). The navigation bar now renders once from layout.html instead of being copied into each content template, so it is present on every authenticated page — including the domain page and its delete confirmation, which had no links at all — and the current page is highlighted via .Active rather than quietly dropping out of the list. New /account page changes the administrator's username and/or password: the current password is required and the attempt is throttled on the same limiter as the login form, so this route cannot be used to brute-force past that limit. A password change invalidates every other session while keeping the one performing it; a rename carries that session over. Backup and domain import move from a card in the middle of the domain list to their own /backup page, one card each; the handlers themselves are unchanged, only the page the import form renders its errors on. The domain page gains a "Sending server settings" card (server, port, encryption) so a client can be configured without reading the docs; 587 is listed only when SUBMISSION_ENABLE is true for this deployment, which is a deploy-time flag the panel cannot verify at runtime. Client-side (static/panel.js, no libraries): Copy buttons on the values that get carried elsewhere (DKIM record, new application credentials, server name), and the Addresses field is hidden while the address mode is wildcard, where the server ignores it. Verified in a container on the dev server: setup, login, every page's nav and active item, domain and application creation, all account-form paths including cross-session invalidation, import errors, full backup download. gofmt/vet/test/docker build green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
5.1 KiB
Go
161 lines
5.1 KiB
Go
package web
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"codeberg.org/mix/selfpost/internal/store"
|
|
)
|
|
|
|
// handleDashboard is the authenticated landing page: the list of sending
|
|
// domains with their DKIM/selector and application counts, plus the add-domain
|
|
// form (spec 7.2.2). Applications and the send log arrive in later phases.
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
s.renderDashboard(w, r, http.StatusOK, "", "")
|
|
}
|
|
|
|
// renderDashboard renders the domain list. formErr and formName repopulate the
|
|
// add-domain form after a rejected submission; flash surfaces a one-shot status
|
|
// message keyed by a redirect query flag (never reflected user input).
|
|
func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
|
|
domains, err := s.domains.List()
|
|
if err != nil {
|
|
logf("panel: dashboard: list domains: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.render(w, status, "dashboard", map[string]any{
|
|
"Title": "SelfPost",
|
|
"User": currentUser(r),
|
|
"Active": "domains",
|
|
"Domains": domains,
|
|
"Error": formErr,
|
|
"FormName": formName,
|
|
"Flash": dashboardFlash(r),
|
|
})
|
|
}
|
|
|
|
// dashboardFlash maps a fixed redirect flag to a fixed message, so status text
|
|
// after a redirect is never attacker-influenced.
|
|
func dashboardFlash(r *http.Request) string {
|
|
switch {
|
|
case r.URL.Query().Get("reloaded") != "":
|
|
return "Configuration reloaded."
|
|
case r.URL.Query().Get("deleted") != "":
|
|
return "Domain deleted."
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// handleAddDomain validates the submitted name, creates the domain (DKIM key +
|
|
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
|
// publish is shown (spec 7.2.3).
|
|
func (s *Server) handleAddDomain(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
s.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
|
|
return
|
|
}
|
|
raw := r.PostFormValue("name")
|
|
name := normalizeDomain(raw)
|
|
if err := validateDomain(name); err != nil {
|
|
s.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
|
|
return
|
|
}
|
|
|
|
d, err := s.domains.Add(name)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrDomainExists) {
|
|
s.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
|
|
return
|
|
}
|
|
logf("panel: add domain %q: %v", name, err)
|
|
s.renderDashboard(w, r, http.StatusInternalServerError,
|
|
"Could not add the domain. Please check the logs and try again.", raw)
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
|
|
}
|
|
|
|
// handleDeleteConfirm shows the cascade warning before a domain is removed: the
|
|
// panel must explicitly state that all bound applications go with it (spec 7.2.4).
|
|
func (s *Server) handleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
|
d, ok := s.lookupDomain(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
s.render(w, http.StatusOK, "domain_delete", map[string]any{
|
|
"Title": "SelfPost — delete " + d.Name,
|
|
"User": currentUser(r),
|
|
"Active": "domains",
|
|
"Domain": d,
|
|
})
|
|
}
|
|
|
|
// handleDeleteDomain performs the deletion (cascade + DKIM key + OpenDKIM reload)
|
|
// and returns to the domain list.
|
|
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
|
id, ok := parseDomainID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.domains.Delete(id); err != nil {
|
|
if errors.Is(err, store.ErrDomainNotFound) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
logf("panel: delete domain %d: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/?deleted=1", http.StatusSeeOther)
|
|
}
|
|
|
|
// handleReload re-applies both the OpenDKIM configuration and the Postfix
|
|
// sender map on demand (spec 7.2.12). Each Resync regenerates its files from the
|
|
// database and reloads its daemon, so the button doubles as a drift-recovery.
|
|
func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.domains.Resync(); err != nil {
|
|
logf("panel: manual reload (opendkim): %v", err)
|
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := s.apps.Resync(); err != nil {
|
|
logf("panel: manual reload (postfix): %v", err)
|
|
http.Error(w, "reload failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/?reloaded=1", http.StatusSeeOther)
|
|
}
|
|
|
|
// lookupDomain resolves the {id} path value to a domain, writing a 404 for a
|
|
// bad id or a missing domain and reporting ok=false in that case.
|
|
func (s *Server) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
|
id, ok := parseDomainID(w, r)
|
|
if !ok {
|
|
return store.Domain{}, false
|
|
}
|
|
d, err := s.domains.Get(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrDomainNotFound) {
|
|
http.NotFound(w, r)
|
|
return store.Domain{}, false
|
|
}
|
|
logf("panel: get domain %d: %v", id, err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return store.Domain{}, false
|
|
}
|
|
return d, true
|
|
}
|
|
|
|
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
if err != nil || id <= 0 {
|
|
http.NotFound(w, r)
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|