147072dbb9
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>
172 lines
5.0 KiB
Go
172 lines
5.0 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"codeberg.org/mix/selfpost/internal/logtail"
|
|
"codeberg.org/mix/selfpost/internal/postfix"
|
|
"codeberg.org/mix/selfpost/internal/store"
|
|
)
|
|
|
|
// sendLogPageSize bounds each send-log page (spec 7.2's monitoring screens
|
|
// call for pagination); logTailLines bounds how much of mail.log the log view
|
|
// shows per refresh.
|
|
const (
|
|
sendLogPageSize = 50
|
|
logTailLines = 200
|
|
)
|
|
|
|
// handleSendLog renders the send-log monitoring page: server-side filters by
|
|
// domain/application and pagination (spec 7.3.3). The row table itself is the
|
|
// "sendlog_rows" fragment, shared verbatim with handleSendLogRows so the
|
|
// initial page and its HTMX-polled refreshes never diverge.
|
|
func (s *Server) handleSendLog(w http.ResponseWriter, r *http.Request) {
|
|
data, err := s.sendLogData(r)
|
|
if err != nil {
|
|
logf("panel: send log: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
data["Title"] = "SelfPost — send log"
|
|
data["User"] = currentUser(r)
|
|
data["Active"] = "sendlog"
|
|
s.render(w, http.StatusOK, "sendlog", data)
|
|
}
|
|
|
|
// handleSendLogRows serves the HTMX polling fragment for the send-log table
|
|
// (spec 7.1: fragment endpoints return HTML, not JSON).
|
|
func (s *Server) handleSendLogRows(w http.ResponseWriter, r *http.Request) {
|
|
data, err := s.sendLogData(r)
|
|
if err != nil {
|
|
logf("panel: send log rows: %v", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.renderFragment(w, http.StatusOK, "sendlog_rows", data)
|
|
}
|
|
|
|
// sendLogData reads the domain/app filters and page number off the query
|
|
// string, queries the store, and assembles everything the template needs
|
|
// (filter dropdown options plus the current selection, rows, and pagination).
|
|
func (s *Server) sendLogData(r *http.Request) (map[string]any, error) {
|
|
q := r.URL.Query()
|
|
filter := store.SendLogFilter{
|
|
Domain: q.Get("domain"),
|
|
AppLogin: q.Get("app"),
|
|
}
|
|
page := parsePage(q.Get("p"))
|
|
|
|
total, err := s.store.CountSendLog(filter)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.store.QuerySendLog(filter, sendLogPageSize, (page-1)*sendLogPageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
domains, err := s.store.ListDomains()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
domainNames := make([]string, len(domains))
|
|
for i, d := range domains {
|
|
domainNames[i] = d.Name
|
|
}
|
|
logins, err := s.store.ListApplicationLogins()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
lastPage := 1
|
|
if total > 0 {
|
|
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
|
|
}
|
|
return map[string]any{
|
|
"Rows": rows,
|
|
"FilterDomains": domainNames,
|
|
"FilterApps": logins,
|
|
"FilterDomain": filter.Domain,
|
|
"FilterApp": filter.AppLogin,
|
|
"Page": page,
|
|
"PrevPage": page - 1,
|
|
"NextPage": page + 1,
|
|
"LastPage": lastPage,
|
|
"HasPrev": page > 1,
|
|
"HasNext": page < lastPage,
|
|
}, nil
|
|
}
|
|
|
|
// parsePage clamps the "p" query parameter to a valid page number, defaulting
|
|
// to 1 for anything missing or malformed rather than rejecting the request.
|
|
func parsePage(v string) int {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 1 {
|
|
return 1
|
|
}
|
|
return n
|
|
}
|
|
|
|
// handleQueue renders the mail-queue monitoring page (spec 7.2.11).
|
|
func (s *Server) handleQueue(w http.ResponseWriter, r *http.Request) {
|
|
out, errText := readQueue()
|
|
s.render(w, http.StatusOK, "queue", map[string]any{
|
|
"Title": "SelfPost — mail queue",
|
|
"User": currentUser(r),
|
|
"Active": "queue",
|
|
"Output": out,
|
|
"Error": errText,
|
|
})
|
|
}
|
|
|
|
// handleQueueBody serves the HTMX polling fragment for the queue view.
|
|
func (s *Server) handleQueueBody(w http.ResponseWriter, r *http.Request) {
|
|
out, errText := readQueue()
|
|
s.renderFragment(w, http.StatusOK, "queue_body", map[string]any{
|
|
"Output": out,
|
|
"Error": errText,
|
|
})
|
|
}
|
|
|
|
// readQueue runs postqueue -p, returning a friendly message instead of the
|
|
// error itself: a transient postqueue failure should degrade the monitoring
|
|
// view, not surface internals to the panel.
|
|
func readQueue() (string, string) {
|
|
out, err := postfix.Queue()
|
|
if err != nil {
|
|
logf("panel: postqueue -p: %v", err)
|
|
return "", "Could not read the mail queue."
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// handleLogTail renders the mail.log monitoring page (spec 7.2.13).
|
|
func (s *Server) handleLogTail(w http.ResponseWriter, r *http.Request) {
|
|
lines, errText := s.readLogTail()
|
|
s.render(w, http.StatusOK, "logtail", map[string]any{
|
|
"Title": "SelfPost — mail log",
|
|
"User": currentUser(r),
|
|
"Active": "logtail",
|
|
"Lines": lines,
|
|
"Error": errText,
|
|
})
|
|
}
|
|
|
|
// handleLogTailBody serves the HTMX polling fragment for the log-tail view.
|
|
func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) {
|
|
lines, errText := s.readLogTail()
|
|
s.renderFragment(w, http.StatusOK, "logtail_body", map[string]any{
|
|
"Lines": lines,
|
|
"Error": errText,
|
|
})
|
|
}
|
|
|
|
func (s *Server) readLogTail() ([]string, string) {
|
|
lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines)
|
|
if err != nil {
|
|
logf("panel: tail %s: %v", s.cfg.MailLogPath, err)
|
|
return nil, "Could not read the mail log."
|
|
}
|
|
return lines, ""
|
|
}
|