Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.
Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).
internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).
Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.
Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.
Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
8.4 KiB
Go
233 lines
8.4 KiB
Go
package view
|
||
|
||
// The data of Account (templates/account.html, mockup account.html) and of
|
||
// Server › Settings (templates/settings.html, mockup settings.html): what used
|
||
// to be one Settings page, split by whose it is. Account is the signed-in
|
||
// user's own — who they are to the panel and their password — and every role
|
||
// has it. Settings is the instance's.
|
||
|
||
import (
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Account is the data of the Account page. Two forms, each its own POST: the
|
||
// profile (Username, Email) and the password. The fields hold what was stored,
|
||
// or what was just submitted when a form is shown again with an error.
|
||
type Account struct {
|
||
Meta
|
||
Head Head
|
||
Flash *Flash
|
||
|
||
Profile Box
|
||
Username string
|
||
Email string
|
||
// Notify is whether the panel writes to this user: the checkbox under the
|
||
// e-mail.
|
||
Notify bool
|
||
// EmailTag says whether the stored address is confirmed (nil without
|
||
// one); Resend is the form that sends its link again, nil once it is
|
||
// confirmed (password-reset).
|
||
EmailTag *Tag
|
||
Resend *ResendLink
|
||
|
||
Password Box
|
||
}
|
||
|
||
// ResendLink is Account's "Send the link again": the address and when its
|
||
// live link went ("" for none).
|
||
type ResendLink struct {
|
||
Email string
|
||
Sent string
|
||
}
|
||
|
||
// WithEmailState sets the stored address's tag and, while it is not
|
||
// confirmed, the resend form: sentAt is when its live link went, zero for
|
||
// none.
|
||
func (a *Account) WithEmailState(email string, confirmed bool, sentAt time.Time) *Account {
|
||
if email == "" {
|
||
return a
|
||
}
|
||
if confirmed {
|
||
a.EmailTag = &Tag{Status: "ok", Label: "confirmed"}
|
||
return a
|
||
}
|
||
a.EmailTag = &Tag{Status: "warn", Label: "not confirmed"}
|
||
a.Resend = &ResendLink{Email: email}
|
||
if !sentAt.IsZero() {
|
||
a.Resend.Sent = sentAt.Local().Format("15:04")
|
||
}
|
||
return a
|
||
}
|
||
|
||
// NewAccount builds the page for a user. role is "global" or "domain" and
|
||
// appears in the kicker.
|
||
func NewAccount(m Meta, role string) *Account {
|
||
m.Title, m.Section, m.Page = "Account", "user", "account"
|
||
return &Account{
|
||
Meta: m,
|
||
Head: Head{
|
||
Kicker: "Signed in as " + m.User + " · " + role,
|
||
Title: "Account",
|
||
Lead: Plain("Who you are to this panel. Applications keep their own logins and passwords and are not affected."),
|
||
},
|
||
Profile: Box{No: "01", Title: "Profile"},
|
||
Password: Box{No: "02", Title: "Password"},
|
||
}
|
||
}
|
||
|
||
// WithResult shows the result of the last action, or its refusal, between the
|
||
// head and the first box.
|
||
func (a *Account) WithResult(flash, formErr string) *Account {
|
||
a.Flash = resultFlash(flash, formErr)
|
||
return a
|
||
}
|
||
|
||
// Settings is the data of Server › Settings: one form with the instance's
|
||
// settings and one Save settings button, and the read-only level-1 rate limit
|
||
// the operator sets in .env.
|
||
type Settings struct {
|
||
Meta
|
||
Head Head
|
||
Flash *Flash
|
||
|
||
Retention Box
|
||
RetentionDays string
|
||
// Inbound is whether the inbound relay is on, which gives the retention box
|
||
// its second field; InboundRetentionDays is what that field shows.
|
||
Inbound bool
|
||
InboundRetentionDays string
|
||
|
||
RateLimits Box
|
||
RateFacts []Fact
|
||
|
||
// Notifications is the instance switch for notification e-mails; Notify is
|
||
// its state and NotifyNote the line under it, which says from where and to
|
||
// whom.
|
||
Notifications Box
|
||
Notify bool
|
||
NotifyNote Text
|
||
|
||
// Mail holds the DNS records of the server's own name, which notifications
|
||
// are signed and authorised with, drawn like a domain's.
|
||
Mail Box
|
||
MailRecords []Record
|
||
|
||
// TimeZone names the container's zone, which every time and day the
|
||
// panel shows is in; it is set in .env, so the box only shows it.
|
||
TimeZone Box
|
||
ZoneFacts []Fact
|
||
}
|
||
|
||
// NewSettings builds the page. retention is what the field shows (the stored
|
||
// value, or what was just submitted); level1 is the per-client-IP limit as
|
||
// FormatRate writes it.
|
||
func NewSettings(m Meta, retention, level1 string) *Settings {
|
||
m.Title, m.Section, m.Page = "Settings", "server", "settings"
|
||
return &Settings{
|
||
Meta: m,
|
||
Head: Head{
|
||
Kicker: "Server",
|
||
Title: "Settings",
|
||
Lead: Rich("What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under ",
|
||
Link("/account", "Account"), "."),
|
||
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: settingsDNSRecheck}},
|
||
},
|
||
Retention: Box{No: "01", Title: "Log retention", Help: topicLink(HelpSettings, "How long rows are kept")},
|
||
RetentionDays: retention,
|
||
RateLimits: Box{No: "04", Title: "Sending rate limits", ID: "rate-limits", End: Plain("Read-only"), Help: topicLink(HelpLimits, "Levels 1 and 2")},
|
||
RateFacts: []Fact{
|
||
{Label: "Level 1 · per client IP", Value: Plain(level1), Big: true,
|
||
Note: Rich("Set in ", Code(".env"), "; restart to change. Nothing below may exceed it.")},
|
||
{Label: "Level 2 · domain, application",
|
||
Value: Rich("Set on each ", Link("/outbound/domains", "domain"), " and on each application of a domain.")},
|
||
},
|
||
Notifications: Box{No: "02", Title: "Notifications", ID: "notifications", Help: topicLink(HelpNotifications, "What is sent and when")},
|
||
Mail: Box{No: "03", Title: "Notification mail", ID: "notification-mail", End: Plain("DNS records of the server's own name"),
|
||
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
|
||
TimeZone: Box{No: "05", Title: "Time zone", ID: "time-zone", End: Plain("Read-only"), Help: topicLink(HelpSettings, "Times and days of the panel")},
|
||
}
|
||
}
|
||
|
||
// WithNotifications sets the instance switch: on is whether notification
|
||
// e-mails are sent at all, from is the address they come from.
|
||
func (s *Settings) WithNotifications(on bool, from string) *Settings {
|
||
s.Notify = on
|
||
s.NotifyNote = Rich("From ", Mono(from), ", to every user who has an e-mail and has not turned them off under ",
|
||
Link("/account", "Account"), ".")
|
||
return s
|
||
}
|
||
|
||
// settingsDNSRecheck is where Re-check DNS on Settings posts.
|
||
const settingsDNSRecheck = "/server/settings/dns-recheck"
|
||
|
||
// WithMail sets the records of the server's own name, in the order they are
|
||
// shown (ServerMailDKIM, ServerMailSPF), and how long ago they were checked
|
||
// (FormatAge; "" when they were not).
|
||
func (s *Settings) WithMail(records []Record, checked string) *Settings {
|
||
s.MailRecords = records
|
||
s.Mail.End = Plain("DNS records of the server's own name")
|
||
if checked != "" {
|
||
s.Mail.End = Plain("The server's own name · checked " + checked)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// WithTimeZone names the zone: tz is TZ as the container has it ("" is UTC,
|
||
// Go's and the C library's default), loc the zone the process took from it
|
||
// (time.Local), at now for its offset. A TZ Go cannot load leaves time.Local
|
||
// UTC (time/zoneinfo_unix.go, initLocal), and the box says so rather than name
|
||
// a zone the times are not in.
|
||
func (s *Settings) WithTimeZone(tz string, loc *time.Location, now time.Time) *Settings {
|
||
name := strings.TrimPrefix(tz, ":")
|
||
if name == "" {
|
||
name = "UTC"
|
||
}
|
||
_, offset := now.In(loc).Zone()
|
||
sign := "+"
|
||
if offset < 0 {
|
||
sign, offset = "-", -offset
|
||
}
|
||
note := Rich(fmt.Sprintf("UTC%s%02d:%02d. Set in ", sign, offset/3600, offset%3600/60), Code(".env"), " (", Code("TZ"), "); restart to change.")
|
||
if name != "UTC" && loc.String() != name {
|
||
note = Rich("Not a zone this image knows, so the times are UTC. Set in ", Code(".env"), " (", Code("TZ"), "); restart to change.")
|
||
}
|
||
s.ZoneFacts = []Fact{{Label: "Zone", Value: Plain(name), Mono: true, Note: note}}
|
||
return s
|
||
}
|
||
|
||
// WithInbound adds the retention of the Inbound log to the Log retention box.
|
||
// days is what the field shows, like retention in NewSettings. The field exists
|
||
// only while the inbound relay is on: with it off there is no journal to keep.
|
||
func (s *Settings) WithInbound(days string) *Settings {
|
||
s.Inbound, s.InboundRetentionDays = true, days
|
||
return s
|
||
}
|
||
|
||
// WithResult shows the result of the last action, or its refusal, between the
|
||
// head and the form.
|
||
func (s *Settings) WithResult(flash, formErr string) *Settings {
|
||
s.Flash = resultFlash(flash, formErr)
|
||
return s
|
||
}
|
||
|
||
// FormatRate writes a rate limit of messages per window the short way:
|
||
// "600 / h" for an hourly limit, "30 / min", "5 000 / day", and the window in
|
||
// seconds for any other.
|
||
func FormatRate(messages, windowSeconds int) string {
|
||
n := FormatCount(int64(messages))
|
||
switch {
|
||
case windowSeconds == 60:
|
||
return n + " / min"
|
||
case windowSeconds == 3600:
|
||
return n + " / h"
|
||
case windowSeconds == 86400:
|
||
return n + " / day"
|
||
case windowSeconds > 0 && windowSeconds%3600 == 0:
|
||
return n + " / " + strconv.Itoa(windowSeconds/3600) + " h"
|
||
}
|
||
return n + " / " + strconv.Itoa(windowSeconds) + " s"
|
||
}
|