store, web: cite the source of what they rely on (assumptions part D)

Comments only, no behaviour changed. modernc.org/sqlite v1.60.1 (the
_pragma list on every connection, busy_timeout first; extended result
codes; a deferred BEGIN; multi-statement Exec) and SQLite 3.53.4's
documentation (transactions, WAL, foreign keys, NOCASE, upsert, julianday,
partial indexes); htmx 2.0.4 src/htmx.js; the Fetch and HTML standards for
Sec-Fetch-Site, Origin, SameSite, __Host-, CSP and defer order; Go's
html/template. Comments the source contradicts are corrected - the driver
does not serialise writes (SetMaxOpenConns(1) does), htmx:load fires per
swapped element, no-store does not keep a page out of history.

Left for their own commits: D-S3 a migration two processes can both run,
D-S20 recipient case, D-B3 Origin: null under Referrer-Policy: no-referrer,
D-H5/H6/H7/H9 (audit M11, stage 4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 19:36:26 +03:00
co-authored by Claude Opus 5.5
parent b113e72c71
commit ad3d834392
25 changed files with 243 additions and 38 deletions
+10 -3
View File
@@ -12,6 +12,9 @@ import (
// ErrLoginExists is returned when an application login is already taken. The // ErrLoginExists is returned when an application login is already taken. The
// login is globally unique because it is the SASL identity Postfix // login is globally unique because it is the SASL identity Postfix
// authenticates (one sasldb2 across all domains, architecture.md § Mail path). // authenticates (one sasldb2 across all domains, architecture.md § Mail path).
// It is unique without regard to case too (migration 0008's NOCASE index):
// Postfix 3.7.11, src/smtpd/smtpd_check.c reject_auth_sender_login_mismatch()
// compares the SASL login with the map's owners by strcasecmp_utf8.
var ErrLoginExists = errors.New("application login already exists") var ErrLoginExists = errors.New("application login already exists")
// ErrApplicationNotFound is returned when an application id does not exist. // ErrApplicationNotFound is returned when an application id does not exist.
@@ -74,6 +77,7 @@ type Binding struct {
// login maps to ErrLoginExists. // login maps to ErrLoginExists.
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) { func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
now := time.Now().UTC() now := time.Now().UTC()
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return Application{}, fmt.Errorf("begin add application: %w", err) return Application{}, fmt.Errorf("begin add application: %w", err)
@@ -90,6 +94,8 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s
} }
return Application{}, fmt.Errorf("insert application: %w", err) return Application{}, fmt.Errorf("insert application: %w", err)
} }
// sqlite3_last_insert_rowid() of this connection, the transaction's own
// (https://sqlite.org/c3ref/last_insert_rowid.html).
id, err := res.LastInsertId() id, err := res.LastInsertId()
if err != nil { if err != nil {
return Application{}, fmt.Errorf("application id: %w", err) return Application{}, fmt.Errorf("application id: %w", err)
@@ -110,6 +116,7 @@ func (s *Store) AddApplication(domainID int64, login, mode string, addresses []s
// address list atomically (product.md). The login and password are untouched. // address list atomically (product.md). The login and password are untouched.
// Returns ErrApplicationNotFound if the id does not exist. // Returns ErrApplicationNotFound if the id does not exist.
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error { func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return fmt.Errorf("begin update application: %w", err) return fmt.Errorf("begin update application: %w", err)
@@ -353,9 +360,9 @@ func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string)
return nil return nil
} }
// DeleteApplication removes an application and its addresses (via cascade), // DeleteApplication removes an application and its addresses (via cascade, see
// returning the deleted application so the caller can drop its sasldb2 entry // DeleteDomain), returning the deleted application so the caller can drop its
// (product.md). Returns ErrApplicationNotFound if no such row existed. // sasldb2 entry (product.md). Returns ErrApplicationNotFound if no such row existed.
func (s *Store) DeleteApplication(id int64) (Application, error) { func (s *Store) DeleteApplication(id int64) (Application, error) {
a, err := s.GetApplication(id) a, err := s.GetApplication(id)
if err != nil { if err != nil {
+3 -1
View File
@@ -80,8 +80,10 @@ type DMARCIngestStats struct {
} }
// InsertDMARCReport stores a parsed report and its records, replacing any prior // InsertDMARCReport stores a parsed report and its records, replacing any prior
// row with the same reporter/report_id/domain triple. // row with the same reporter/report_id/domain triple; the prior report's
// records go with it by ON DELETE CASCADE (see DeleteDomain).
func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) { func (s *Store) InsertDMARCReport(rep DMARCReport) (int64, error) {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return 0, fmt.Errorf("begin insert dmarc report: %w", err) return 0, fmt.Errorf("begin insert dmarc report: %w", err)
+12 -2
View File
@@ -128,7 +128,10 @@ func (s *Store) GetDomain(id int64) (Domain, error) {
// DeleteDomain removes a domain. Its applications and their address/binding rows // DeleteDomain removes a domain. Its applications and their address/binding rows
// go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no // go with it via ON DELETE CASCADE (product.md). Returns ErrDomainNotFound if no
// such row existed. // such row existed. SQLite 3.53.4 acts on ON DELETE only on a connection with
// foreign_keys ON, which Open's DSN sets on every one
// (https://sqlite.org/foreignkeys.html § 2, § 4.3); the rows the cascade
// removes are not in RowsAffected (https://sqlite.org/c3ref/changes.html).
func (s *Store) DeleteDomain(id int64) error { func (s *Store) DeleteDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id) res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
if err != nil { if err != nil {
@@ -162,6 +165,11 @@ func scanDomain(r scanRow) (Domain, error) {
} }
// isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY conflict. // isForeignKeyViolation reports whether err is a SQLite FOREIGN KEY conflict.
// modernc.org/sqlite v1.60.1, conn.go newConn(): extended result codes are on
// for every connection, so Error.Code() (error.go) is the extended code
// (https://sqlite.org/rescode.html#constraint_foreignkey, _unique,
// _primarykey); a unique index - the NOCASE one on logins too - fails as
// SQLITE_CONSTRAINT_UNIQUE.
func isForeignKeyViolation(err error) bool { func isForeignKeyViolation(err error) bool {
var se *sqlite.Error var se *sqlite.Error
return errors.As(err, &se) && se.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY return errors.As(err, &se) && se.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY
@@ -197,7 +205,9 @@ func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
// DomainExists reports whether a sending domain with this name is configured. // DomainExists reports whether a sending domain with this name is configured.
// Used by the DMARC ingest path to refuse reports for domains this relay does // Used by the DMARC ingest path to refuse reports for domains this relay does
// not send for (security.md: report XML is attacker-supplied input). // not send for (security.md: report XML is attacker-supplied input). NOCASE
// folds ASCII letters only (https://sqlite.org/datatype3.html § 7), which is
// all a domain name the panel accepts holds.
func (s *Store) DomainExists(name string) (bool, error) { func (s *Store) DomainExists(name string) (bool, error) {
var n int var n int
err := s.db.QueryRow( err := s.db.QueryRow(
+2
View File
@@ -48,6 +48,7 @@ type InboundDomain struct {
// caller must have validated name (security.md). // caller must have validated name (security.md).
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) { func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
now := time.Now().UTC() now := time.Now().UTC()
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err) return InboundDomain{}, fmt.Errorf("begin add inbound domain: %w", err)
@@ -189,6 +190,7 @@ func (s *Store) UpdateInboundTransport(id int64, host string, port int, tlsMode
// UpdateInboundRecipients replaces the recipient mode and, in list mode, the // UpdateInboundRecipients replaces the recipient mode and, in list mode, the
// address list. In any mode the stored list is cleared. // address list. In any mode the stored list is cleared.
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error { func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return fmt.Errorf("begin update inbound recipients: %w", err) return fmt.Errorf("begin update inbound recipients: %w", err)
+4 -1
View File
@@ -72,7 +72,8 @@ func (s *Store) InsertInboundSpam(e InboundSpamEntry) error {
// detail - and says whether it wrote. The log tailer uses it for the lines it // detail - and says whether it wrote. The log tailer uses it for the lines it
// reads when it starts: after an unclean stop those are lines it may have read // reads when it starts: after an unclean stop those are lines it may have read
// before, and a refusal read twice is otherwise two rows. e.At must be set; a // before, and a refusal read twice is otherwise two rows. e.At must be set; a
// row dated "now" is never the same as another. // row dated "now" is never the same as another. The check and the insert are
// one statement, and one of no row reports 0 (see CreateGlobalUser).
func (s *Store) InsertInboundSpamOnce(e InboundSpamEntry) (bool, error) { func (s *Store) InsertInboundSpamOnce(e InboundSpamEntry) (bool, error) {
at := e.At.UTC().Format(time.RFC3339) at := e.At.UTC().Format(time.RFC3339)
res, err := s.db.Exec( res, err := s.db.Exec(
@@ -103,6 +104,8 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) {
n, _ := res.RowsAffected() n, _ := res.RowsAffected()
// Oldest by when it happened, not by id: the tailer dates a refusal by its // Oldest by when it happened, not by id: the tailer dates a refusal by its
// log line, so after the panel was down the newest ids are old rows. // log line, so after the panel was down the newest ids are old rows.
// SQLite 3.53.4, https://sqlite.org/lang_select.html § 5: a negative LIMIT
// is no upper bound, so this is every row after the newest keep.
res, err = s.db.Exec( res, err = s.db.Exec(
`DELETE FROM inbound_spam_log WHERE id IN `DELETE FROM inbound_spam_log WHERE id IN
(SELECT id FROM inbound_spam_log ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)`, keep) (SELECT id FROM inbound_spam_log ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)`, keep)
+6 -1
View File
@@ -137,6 +137,9 @@ func (s *Store) DeleteNotifyState(key string) error {
// ListNotifyStateKeys returns the keys that begin with prefix, so that the // ListNotifyStateKeys returns the keys that begin with prefix, so that the
// states of subjects that are gone - a deleted domain - can be found. // states of subjects that are gone - a deleted domain - can be found.
// SQLite's substr counts characters of a TEXT value, len(prefix) its bytes
// (https://sqlite.org/lang_corefunc.html#substr): the two agree for the ASCII
// prefixes the callers pass ("dns:", "limit:").
func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) { func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
"SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix) "SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix)
@@ -181,7 +184,9 @@ type LimitRefusal struct {
// reason and is still judged the old way. // reason and is still judged the old way.
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) { func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
// The status is a literal so that the partial index of migration 0005 // The status is a literal so that the partial index of migration 0005
// answers the question. // answers the question: the query's term matches the index's WHERE
// exactly (https://sqlite.org/partialindex.html § 3) when the statement is
// first prepared.
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT d.name, a.login, COUNT(*), MIN(l.created_at) `SELECT d.name, a.login, COUNT(*), MIN(l.created_at)
FROM send_log l FROM send_log l
+4 -1
View File
@@ -100,6 +100,7 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
if n <= 0 { if n <= 0 {
return nil return nil
} }
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return fmt.Errorf("sender check deferred: %w", err) return fmt.Errorf("sender check deferred: %w", err)
@@ -118,7 +119,9 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
} }
// To the fraction of a second the log gives, and of a fixed width so that // To the fraction of a second the log gives, and of a fixed width so that
// the strings compare as the times do; a re-read log must not move the // the strings compare as the times do; a re-read log must not move the
// mark back. // mark back. SQLite 3.53.4, https://sqlite.org/lang_upsert.html § 2: a
// DO UPDATE whose WHERE is false is a no-op, not an error, and excluded.
// names the value the INSERT brought.
stamp := at.UTC().Format("2006-01-02T15:04:05.000000Z") stamp := at.UTC().Format("2006-01-02T15:04:05.000000Z")
setting := func(key, value, cond string) error { setting := func(key, value, cond string) error {
_, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) _, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)
+23 -4
View File
@@ -47,6 +47,11 @@ type SendLogEntry struct {
// application whose login it is, NULL for a login that is no application's // application whose login it is, NULL for a login that is no application's
// (audit 2026-10-12, L12). The domain name of the row stays what the // (audit 2026-10-12, L12). The domain name of the row stays what the
// envelope said. // envelope said.
//
// The login is matched exactly (BINARY): it reaches the milter and the log in
// the case it is stored in, because Cyrus SASL 2.1.28 looks it up as given -
// lib/canonusr.c _canonuser_internal() only trims spaces and appends a realm,
// sasldb/allockey.c _sasl_alloc_key() copies it byte for byte into the key.
const loginDomainID = `(SELECT a.domain_id FROM applications a WHERE a.login = ?)` const loginDomainID = `(SELECT a.domain_id FROM applications a WHERE a.login = ?)`
// InsertQueued records an accepted message in the send log with status // InsertQueued records an accepted message in the send log with status
@@ -108,7 +113,12 @@ func (s *Store) InsertRejected(e SendLogEntry) error {
// first and then writes fails at once with "database is locked" when another // first and then writes fails at once with "database is locked" when another
// connection - the dmarc-ingest process opens the same file - commits in // connection - the dmarc-ingest process opens the same file - commits in
// between, and the line would be lost; a write first takes the write lock, // between, and the line would be lost; a write first takes the write lock,
// and the busy timeout waits for it. // and the busy timeout waits for it. SQLite 3.53.4, btree.c btreeBeginTrans():
// the busy handler is retried only while the connection has no transaction
// open, so the upgrade of a read transaction fails with SQLITE_BUSY at once -
// also while the other connection merely holds the write lock - and with
// SQLITE_BUSY_SNAPSHOT after it committed (https://sqlite.org/rescode.html#busy_snapshot).
// Every other transaction of this package opens with a write for this reason.
func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) { func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
now := time.Now().UTC() now := time.Now().UTC()
tx, err := s.db.Begin() tx, err := s.db.Begin()
@@ -184,7 +194,9 @@ type QueuedDelivery struct {
// about them. // about them.
// //
// created_at is stored as RFC3339 UTC, so a lexical comparison against the same // created_at is stored as RFC3339 UTC, so a lexical comparison against the same
// format is chronologically correct. // format is chronologically correct. SQLite 3.53.4 compares TEXT with the
// BINARY collation, memcmp() (https://sqlite.org/datatype3.html § 7), and
// time.RFC3339 of a UTC time is fixed-width, YYYY-MM-DDTHH:MM:SSZ.
func (s *Store) ListQueuedOlderThan(cutoff time.Time) ([]QueuedDelivery, error) { func (s *Store) ListQueuedOlderThan(cutoff time.Time) ([]QueuedDelivery, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, queue_id, to_addr FROM send_log `SELECT id, queue_id, to_addr FROM send_log
@@ -247,6 +259,12 @@ type SendLogRow struct {
// order the rows were written, so two refusals of one second take one line // order the rows were written, so two refusals of one second take one line
// each in turn (review of stage 2b). A line already on a row - read again // each in turn (review of stage 2b). A line already on a row - read again
// after a restart - goes on no other. It reports whether a row took the line. // after a restart - goes on no other. It reports whether a row took the line.
//
// SQLite 3.53.4: the choice and the write are one UPDATE, a write transaction
// from its start (https://sqlite.org/lang_transaction.html § 2.1); julianday()
// reads YYYY-MM-DDTHH:MM:SSZ, the T and the Z included (lang_datefunc.html
// § 2); NOCASE folds ASCII only (datatype3.html § 7.1); ?1 is bound once and
// used twice (lang_expr.html § 4).
func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line string) (bool, error) { func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line string) (bool, error) {
res, err := s.db.Exec( res, err := s.db.Exec(
`UPDATE send_log SET refusal_line = ?1 WHERE id = ( `UPDATE send_log SET refusal_line = ?1 WHERE id = (
@@ -277,7 +295,8 @@ func (s *Store) AttachRefusalLine(clientIP, from string, at time.Time, line stri
// and nothing is. The domain is the login's, or - when the tailer did not see the login, its // and nothing is. The domain is the login's, or - when the tailer did not see the login, its
// client= line read before a restart - the envelope's, so the row is still // client= line read before a restart - the envelope's, so the row is still
// its domain's administrator's (review of stage 2b). It reports whether it // its domain's administrator's (review of stage 2b). It reports whether it
// wrote one. // wrote one: the check and the insert are one statement (see
// CreateGlobalUser), and an INSERT ... SELECT of no row reports 0.
func (s *Store) RecoverRefusal(e SendLogEntry, at time.Time) (bool, error) { func (s *Store) RecoverRefusal(e SendLogEntry, at time.Time) (bool, error) {
stamp := at.UTC().Format(time.RFC3339) stamp := at.UTC().Format(time.RFC3339)
res, err := s.db.Exec( res, err := s.db.Exec(
@@ -433,7 +452,7 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
// implementing the configurable retention window (architecture.md § // implementing the configurable retention window (architecture.md §
// Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned. // Persistence, SEND_LOG_RETENTION_DAYS). It returns the number of rows pruned.
// created_at is stored as RFC3339 UTC, so a lexical comparison against the // created_at is stored as RFC3339 UTC, so a lexical comparison against the
// same format is chronologically correct. // same format is chronologically correct (see ListQueuedOlderThan).
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) { func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
res, err := s.db.Exec( res, err := s.db.Exec(
`DELETE FROM send_log WHERE created_at < ?`, `DELETE FROM send_log WHERE created_at < ?`,
+6 -1
View File
@@ -43,7 +43,12 @@ func cutSendLogLine(line string) string {
// wrote. The tailer calls it with what it read in one pass over the log, // wrote. The tailer calls it with what it read in one pass over the log,
// whether or not send-log rows exist for the queue ids yet: a commit per line // whether or not send-log rows exist for the queue ids yet: a commit per line
// would be a disk sync per line, on the one connection the journal milter // would be a disk sync per line, on the one connection the journal milter
// writes through. // writes through. SQLite 3.53.4 as modernc builds it keeps synchronous at FULL
// (SQLITE_DEFAULT_WAL_SYNCHRONOUS 2), and in WAL mode FULL syncs the WAL at
// every commit (https://sqlite.org/wal.html § 2.3, pragma.html#pragma_synchronous).
// The first statement stepped is an INSERT, a write (see UpdateStatus):
// tx.Prepare leaves no transaction open (prepare.c sqlite3InitOne() commits
// the read it opens to load the schema).
// //
// With once set, a line is written only if the same line is not there already // With once set, a line is written only if the same line is not there already
// under its queue id. That is for the lines the tailer reads again after an // under its queue id. That is for the lines the tailer reads again after an
+6 -1
View File
@@ -30,7 +30,10 @@ func (s *Store) CreateSession(tokenHash string, userID int64, expiresAt time.Tim
// LookupSession returns the session for tokenHash, if any. It does not check // LookupSession returns the session for tokenHash, if any. It does not check
// expiry itself — callers compare ExpiresAt against time.Now() and call // expiry itself — callers compare ExpiresAt against time.Now() and call
// DeleteSession on an expired row, keeping the read side lock-free. // DeleteSession on an expired row, keeping the read side lock-free: in WAL
// mode a reader takes no write lock and is not blocked by a writer
// (https://sqlite.org/wal.html § 2.2). Within this process it still waits for
// the one pooled connection (Open).
func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) { func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
var ( var (
row SessionRow row SessionRow
@@ -99,6 +102,8 @@ func (s *Store) DeleteUserSessions(userID int64) error {
// abandoned session (cookie never presented again) does not sit in the table // abandoned session (cookie never presented again) does not sit in the table
// forever. It piggybacks on session creation rather than running as its own // forever. It piggybacks on session creation rather than running as its own
// background loop. // background loop.
// expires_at is RFC3339 UTC text, so < compares moments (see
// ListQueuedOlderThan).
func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) { func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339)) res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
if err != nil { if err != nil {
+2 -1
View File
@@ -156,7 +156,8 @@ func (s *Store) GetSetting(key string) (string, error) {
return value, nil return value, nil
} }
// SetSetting upserts a settings key. // SetSetting upserts a settings key (https://sqlite.org/lang_upsert.html: on
// a conflict of the key, excluded.value is the value the INSERT brought).
func (s *Store) SetSetting(key, value string) error { func (s *Store) SetSetting(key, value string) error {
_, err := s.db.Exec( _, err := s.db.Exec(
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+7
View File
@@ -85,6 +85,10 @@ const domainByName = "domain_id = (SELECT d.id FROM domains d WHERE d.name = ?)"
// login can be taken again in another domain, and its numbers - statistics, // login can be taken again in another domain, and its numbers - statistics,
// the level-2 count - must not start with the old one's (review of audit // the level-2 count - must not start with the old one's (review of audit
// stage 2). ?1 twice; a plain ? after it takes the next number. // stage 2). ?1 twice; a plain ? after it takes the next number.
// SQLite 3.53.4, https://sqlite.org/lang_expr.html § 4: a plain ? is numbered
// one more than the largest number already assigned; modernc.org/sqlite
// v1.60.1, conn.go bind() and argFinder.find(): ?NNN takes argument NNN, a
// plain ? the argument of its number.
const appByLogin = "app_login = ?1 AND domain_id = (SELECT a.domain_id FROM applications a WHERE a.login = ?1)" const appByLogin = "app_login = ?1 AND domain_id = (SELECT a.domain_id FROM applications a WHERE a.login = ?1)"
// sendStats counts the rows that match, a condition with one placeholder // sendStats counts the rows that match, a condition with one placeholder
@@ -102,6 +106,9 @@ func (s *Store) sendStats(match, ref string, since time.Time, hours float64) (Se
return SendStats{}, fmt.Errorf("send stats total %q: %w", ref, err) return SendStats{}, fmt.Errorf("send stats total %q: %w", ref, err)
} }
// The busiest UTC hour: substr counts characters from 1
// (https://sqlite.org/lang_corefunc.html#substr), so 1-13 of an RFC3339
// UTC stamp is YYYY-MM-DDTHH.
var peak int64 var peak int64
err = s.db.QueryRow( err = s.db.QueryRow(
`SELECT COALESCE(MAX(bucket_count), 0) FROM ( `SELECT COALESCE(MAX(bucket_count), 0) FROM (
+26 -2
View File
@@ -29,13 +29,23 @@ type Store struct {
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
// _pragma parameters are applied on every pooled connection by the driver, // _pragma parameters are applied on every pooled connection by the driver,
// so foreign-key enforcement and WAL survive connection churn. // so foreign-key enforcement and WAL survive connection churn.
// modernc.org/sqlite v1.60.1, conn.go newConn() -> sqlite.go
// applyQueryParams(): the _pragma list runs on every new connection,
// busy_timeout first. SQLite 3.53.4: foreign_keys is per connection
// (https://sqlite.org/foreignkeys.html § 2), journal_mode=WAL stays in the
// file (https://sqlite.org/wal.html § 3.3).
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path) dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
db, err := sql.Open("sqlite", dsn) db, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
return nil, fmt.Errorf("open database: %w", err) return nil, fmt.Errorf("open database: %w", err)
} }
// modernc's driver serializes writes anyway; a small pool avoids // One connection: database/sql then runs this process's statements and
// "database is locked" surprises under WAL. // transactions one after another, so none of them meets another's lock.
// The driver does not serialise across connections (modernc.org/sqlite
// v1.60.1, conn.go: the conn's mutex is its own); between processes - the
// dmarc-ingest command opens this file too - SQLite allows one write
// transaction at a time (https://sqlite.org/lang_transaction.html § 2.1)
// and the other waits out busy_timeout.
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
s := &Store{db: db} s := &Store{db: db}
@@ -83,6 +93,10 @@ func (s *Store) migrate() error {
// against today's files would skip the baseline and leave the panel running // against today's files would skip the baseline and leave the panel running
// on a schema it does not know, so such a file is refused outright; nothing // on a schema it does not know, so such a file is refused outright; nothing
// is migrated from 1.x (docs/schema-migrations.md). // is migrated from 1.x (docs/schema-migrations.md).
// SQLite 3.53.4: the application id is the header's integer at offset 68
// (https://sqlite.org/fileformat2.html § 1.3.15); btree.c newDatabase()
// zeroes bytes 24-99 of a new file's header, so it reads 0 until set - and
// 1.x never set it.
if version > 0 { if version > 0 {
var appID int64 var appID int64
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil { if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
@@ -109,12 +123,22 @@ func (s *Store) migrate() error {
if err != nil { if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err) return fmt.Errorf("begin migration %s: %w", name, err)
} }
// One Exec runs the whole file: modernc.org/sqlite v1.60.1, stmt.go
// stmt.exec(), "FALLBACK PATH: Multi-statement script" prepares and
// steps each statement of the tail in turn. It runs with foreign_keys
// ON, which cannot be switched off inside a transaction
// (https://sqlite.org/pragma.html#pragma_foreign_keys): an ADD COLUMN
// with REFERENCES needs a NULL default (lang_altertable.html § 4), and
// a changed collation means a rebuilt table (§ 8).
if _, err := tx.Exec(string(sqlBytes)); err != nil { if _, err := tx.Exec(string(sqlBytes)); err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err) return fmt.Errorf("apply migration %s: %w", name, err)
} }
// PRAGMA does not accept a bound parameter, and target is a trusted // PRAGMA does not accept a bound parameter, and target is a trusted
// loop index, so formatting it in is safe. // loop index, so formatting it in is safe.
// SQLite 3.53.4, pragma.c sqlite3Pragma(), PragTyp_HEADER_VALUE: the
// value is the literal's text (sqlite3Atoi), written by OP_SetCookie
// under a write transaction, so a rollback takes it back with the rest.
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil { if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil {
tx.Rollback() tx.Rollback()
return fmt.Errorf("bump schema version for %s: %w", name, err) return fmt.Errorf("bump schema version for %s: %w", name, err)
+18 -3
View File
@@ -83,6 +83,10 @@ func (s *Store) UserExists() (bool, error) {
// CreateGlobalUser inserts the first global user during setup. The check that // CreateGlobalUser inserts the first global user during setup. The check that
// no user exists yet and the insert are one statement, so two setup requests // no user exists yet and the insert are one statement, so two setup requests
// at once cannot both create one (audit 2026-10-12, M7). // at once cannot both create one (audit 2026-10-12, M7).
// SQLite 3.53.4, https://sqlite.org/lang_transaction.html § 2.1: an INSERT
// starts a write transaction, one at a time, so its SELECT reads what is
// committed; an insert of no row leaves sqlite3_changes() at 0
// (https://sqlite.org/c3ref/changes.html).
func (s *Store) CreateGlobalUser(username, passwordHash string) error { func (s *Store) CreateGlobalUser(username, passwordHash string) error {
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec( res, err := s.db.Exec(
@@ -249,6 +253,7 @@ func (s *Store) AddUser(c UserChange) (int64, error) {
if c.Role == RoleDomain && c.Reach.Empty() { if c.Role == RoleDomain && c.Reach.Empty() {
return 0, ErrEmptyReach return 0, ErrEmptyReach
} }
// The first statement is a write (see UpdateStatus).
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return 0, fmt.Errorf("create user: %w", err) return 0, fmt.Errorf("create user: %w", err)
@@ -302,7 +307,12 @@ func (s *Store) SaveUser(c UserChange) error {
} }
defer tx.Rollback() defer tx.Rollback()
// A write first: it takes the write lock, so what is counted below is // A write first: it takes the write lock, so what is counted below is
// what is committed. // what is committed. SQLite 3.53.4, https://sqlite.org/lang_transaction.html
// § 2.2: after a deferred BEGIN, a first statement that writes starts a
// write transaction, and § 2.1: there is one at a time. RowsAffected counts
// the row its WHERE found even when no value changes: update.c
// sqlite3Update() writes every such row through sqlite3CompleteInsertion(),
// which marks it counted (OPFLAG_NCHANGE).
// An empty hash keeps the stored one: the form read it when it was // An empty hash keeps the stored one: the form read it when it was
// opened, and the user may have changed it on Account since. // opened, and the user may have changed it on Account since.
res, err := tx.Exec( res, err := tx.Exec(
@@ -403,7 +413,9 @@ func (s *Store) SetUserReach(userID int64, reach Reach) error {
// DeleteUser removes a panel user. ErrLastGlobal when deleting the only global // DeleteUser removes a panel user. ErrLastGlobal when deleting the only global
// user: the delete and the count are one transaction, so two administrators // user: the delete and the count are one transaction, so two administrators
// deleting each other at once cannot both succeed (audit 2026-10-12, M7). // deleting each other at once cannot both succeed (audit 2026-10-12, M7). The
// DELETE comes first and so takes the write lock (see SaveUser); the rows its
// cascade removes are not in RowsAffected (https://sqlite.org/c3ref/changes.html).
func (s *Store) DeleteUser(id int64) error { func (s *Store) DeleteUser(id int64) error {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
@@ -517,7 +529,10 @@ func (s *Store) setUserReach(userID int64, reach Reach) error {
// writeUserReach writes a user's reach inside tx. With an All flag set the // writeUserReach writes a user's reach inside tx. With an All flag set the
// rows of that direction are dropped rather than stored: the flag already // rows of that direction are dropped rather than stored: the flag already
// covers them, and stale rows would come back to life the day the flag is // covers them, and stale rows would come back to life the day the flag is
// cleared. // cleared. Its first statement is a write (see UpdateStatus). A domain deleted
// meanwhile fails its INSERT at once: SQLite 3.53.4's foreign keys are
// immediate unless declared deferred (https://sqlite.org/foreignkeys.html § 4.2),
// and the code is SQLITE_CONSTRAINT_FOREIGNKEY (isForeignKeyViolation).
func writeUserReach(tx *sql.Tx, userID int64, reach Reach) error { func writeUserReach(tx *sql.Tx, userID int64, reach Reach) error {
if _, err := tx.Exec("UPDATE users SET all_domains = ?, all_inbound_domains = ? WHERE id = ?", if _, err := tx.Exec("UPDATE users SET all_domains = ?, all_inbound_domains = ? WHERE id = ?",
reach.AllDomains, reach.AllInbound, userID); err != nil { reach.AllDomains, reach.AllInbound, userID); err != nil {
+9
View File
@@ -11,6 +11,9 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// draft-ietf-httpbis-rfc6265bis § 4.1.3.2: a cookie named __Host- is accepted
// only with Secure, Path=/ and no Domain, from a secure origin - so no other
// host can set or overwrite it, and clearing it needs Secure as well.
const ( const (
sessionCookieBase = "selfpost_session" sessionCookieBase = "selfpost_session"
sessionCookiePrefixed = "__Host-" + sessionCookieBase sessionCookiePrefixed = "__Host-" + sessionCookieBase
@@ -46,6 +49,9 @@ func (m *Module) sessionToken(r *http.Request) (string, bool) {
case 1: case 1:
return token, true return token, true
default: default:
// RFC 6265 § 5.4: the Cookie header carries every cookie that
// matches, a parent domain's of the same name included. The prefixed
// name cannot be planted that way (see sessionCookiePrefixed).
logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+ logf("panel: %s %s carries %d cookies named %q — treating the request as signed out; "+
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain", "another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
r.Method, r.URL.Path, n, name) r.Method, r.URL.Path, n, name)
@@ -130,6 +136,9 @@ func (m *Module) submitLogin(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// setSessionCookie sets the session cookie. SameSite=Lax withholds it from a
// cross-site request unless that is a top-level navigation with a safe method
// (rfc6265bis § 5.8.3, § 5.6.7.1), so a cross-site POST arrives without it.
func (m *Module) setSessionCookie(w http.ResponseWriter, token string) { func (m *Module) setSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: m.sessionCookie(), Name: m.sessionCookie(),
+4
View File
@@ -34,6 +34,10 @@ func (m *Module) RequireAuth(next http.Handler) http.Handler {
}) })
} }
// isSessionActivity: a GET from htmx is a background poll - every panel hx-get
// is a load or a poll - and does not renew the session. htmx 2.0.4, src/htmx.js
// getHeaders(): every request it issues carries HX-Request: true (and
// loadHistoryFromServer() sets it too).
func isSessionActivity(r *http.Request) bool { func isSessionActivity(r *http.Request) bool {
return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "") return !(r.Method == http.MethodGet && r.Header.Get("HX-Request") != "")
} }
+4 -1
View File
@@ -31,7 +31,10 @@ func newSessionStore(st *store.Store, idle time.Duration) *sessionStore {
// MaxAge is the session cookie's Max-Age in seconds, kept equal to the // MaxAge is the session cookie's Max-Age in seconds, kept equal to the
// sliding idle window so the browser drops the cookie no later than the // sliding idle window so the browser drops the cookie no later than the
// server would have expired it anyway. // server would have expired it anyway. RFC 6265 § 5.2.2: Max-Age sets the
// expiry to now plus that many seconds, and a cookie set again under the same
// name, domain and path replaces the old one (§ 5.3 step 11), so each renewal
// slides it.
func (s *sessionStore) MaxAge() int { func (s *sessionStore) MaxAge() int {
return int(s.idle.Seconds()) return int(s.idle.Seconds())
} }
@@ -92,6 +92,8 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
f.Username = strings.TrimSpace(r.PostFormValue("username")) f.Username = strings.TrimSpace(r.PostFormValue("username"))
f.Email = strings.TrimSpace(r.PostFormValue("email")) f.Email = strings.TrimSpace(r.PostFormValue("email"))
// An unticked checkbox is not sent at all: absent is off. // 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.
f.Notify = r.PostFormValue("notify") != "" f.Notify = r.PostFormValue("notify") != ""
if f.Username == "" { if f.Username == "" {
f.Username = u.Username f.Username = u.Username
+3
View File
@@ -395,6 +395,9 @@ func (h *Handlers) recalcApp(appID int64) error {
// noStore keeps a page that shows a secret out of every cache and out of the // noStore keeps a page that shows a secret out of every cache and out of the
// browser's history copy: the password on it is shown once. // browser's history copy: the password on it is shown once.
// RFC 9111 § 5.2.2.5: no-store keeps it out of every HTTP cache, private and
// shared. The history part is not what it governs (§ 6), and Pragma has no
// defined meaning in a response (§ 5.4): assumptions-2026-10-14, D-B13.
func noStore(w http.ResponseWriter) { func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache") w.Header().Set("Pragma", "no-cache")
@@ -102,6 +102,8 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
} }
} }
// An unticked checkbox is not sent at all: absent is off. // 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") != "" notifications := r.PostFormValue("notify") != ""
if err := h.store.SetSendLogRetentionDays(days); err != nil { if err := h.store.SetSendLogRetentionDays(days); err != nil {
logf("panel: settings: set send-log retention failed: %v", err) logf("panel: settings: set send-log retention failed: %v", err)
+43 -8
View File
@@ -19,8 +19,17 @@ import (
// relative URL on the page, including the form actions; // relative URL on the page, including the form actions;
// - form-action 'self' keeps a form from being retargeted at another host. // - form-action 'self' keeps a form from being retargeted at another host.
// //
// CSP Level 3, https://www.w3.org/TR/CSP3/ § 6.8.3 "Get fetch directive
// fallback list": script, style, img, font, connect and object fall back to
// default-src; base-uri (§ 6.3.1), form-action (§ 6.4.1) and frame-ancestors
// (§ 6.4.2) are not in any list, so each is set here.
//
// This is a second line of defence: XSS is already prevented by // This is a second line of defence: XSS is already prevented by
// html/template's contextual auto-escaping (security.md). // html/template's contextual auto-escaping (security.md). Go 1.26,
// html/template doc.go "Security Model": template authors are trusted, the
// data is not. An attribute it does not know (hx-get, hx-swap, popovertarget)
// is escaped as plain text (attr.go attrType()), so the hx-get paths are built
// by the server, never taken from input.
const contentSecurityPolicy = "default-src 'self'; " + const contentSecurityPolicy = "default-src 'self'; " +
"object-src 'none'; " + "object-src 'none'; " +
"base-uri 'none'; " + "base-uri 'none'; " +
@@ -31,8 +40,10 @@ const contentSecurityPolicy = "default-src 'self'; " +
// (CookieSecure, the same condition that gates the cookie's Secure attribute): // (CookieSecure, the same condition that gates the cookie's Secure attribute):
// on the plain-HTTP development setup it would be meaningless at best and // on the plain-HTTP development setup it would be meaningless at best and
// would pin the browser to a scheme the dev instance does not speak at worst. // would pin the browser to a scheme the dev instance does not speak at worst.
// RFC 6797 § 8.1: a header received over insecure transport is ignored.
// //
// Deliberately without includeSubDomains. The panel commonly lives on a // Deliberately without includeSubDomains (RFC 6797 § 6.1.2: only with it does
// the policy reach the host's subdomains). The panel commonly lives on a
// subdomain of a domain used for other things, but it may also sit at the // subdomain of a domain used for other things, but it may also sit at the
// apex — and there the directive would force HTTPS on every unrelated // apex — and there the directive would force HTTPS on every unrelated
// subdomain of that domain for a year, which is not SelfPost's call to make. // subdomain of that domain for a year, which is not SelfPost's call to make.
@@ -51,6 +62,8 @@ func (s *Server) secure(next http.Handler) http.Handler {
h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Content-Type-Options", "nosniff")
// Equivalent to the CSP's frame-ancestors directive, kept for browsers // Equivalent to the CSP's frame-ancestors directive, kept for browsers
// that predate it. The two never disagree: both mean "never framed". // that predate it. The two never disagree: both mean "never framed".
// CSP3 § 6.4.2.2: 'none' is DENY, and where frame-ancestors is
// enforced X-Frame-Options is ignored.
h.Set("X-Frame-Options", "DENY") h.Set("X-Frame-Options", "DENY")
// The panel's behaviour never depends on the referrer, so the strictest // The panel's behaviour never depends on the referrer, so the strictest
// value is free: panel URLs carry domain names and record identifiers // value is free: panel URLs carry domain names and record identifiers
@@ -65,7 +78,12 @@ func (s *Server) secure(next http.Handler) http.Handler {
// being the name the browser actually used, which every reverse // being the name the browser actually used, which every reverse
// proxy fragment this project ships preserves (Apache // proxy fragment this project ships preserves (Apache
// ProxyPreserveHost On, nginx proxy_set_header Host $host, Caddy // ProxyPreserveHost On, nginx proxy_set_header Host $host, Caddy
// and Traefik by default) — but a proxy that rewrites Host turns // and Traefik by default: httpd.apache.org/docs/2.4/mod/mod_proxy.html
// #proxypreservehost; nginx.org ngx_http_proxy_module
// #proxy_set_header, whose default is $proxy_host; Caddy 2
// reverse_proxy § Headers > Defaults, "passes through incoming
// headers - including Host"; Traefik service load balancer,
// passHostHeader true by default) — but a proxy that rewrites Host turns
// every form submission into a 403, and without these two values // every form submission into a 403, and without these two values
// side by side the symptom just looks like "the panel stopped // side by side the symptom just looks like "the panel stopped
// saving anything". // saving anything".
@@ -87,6 +105,9 @@ func (s *Server) secure(next http.Handler) http.Handler {
// domain, a forgotten staging host — counts as same-site and its forged POST // domain, a forgotten staging host — counts as same-site and its forged POST
// would arrive with the session cookie attached. Sec-Fetch-Site and Origin are // would arrive with the session cookie attached. Sec-Fetch-Site and Origin are
// judged per *origin*, so they tell that neighbour apart from the panel. // judged per *origin*, so they tell that neighbour apart from the panel.
// draft-ietf-httpbis-rfc6265bis § 5.2 (same-site is the [SAMESITE] "same
// site" of the two origins); W3C Fetch Metadata § 2.3 (Sec-Fetch-Site:
// same-origin only if every URL of the request is same origin).
// //
// Read-only methods are exempt: every GET route in the panel is a read (the // Read-only methods are exempt: every GET route in the panel is a read (the
// delete confirmation at GET /outbound/domains/{id}/delete only renders a form), so // delete confirmation at GET /outbound/domains/{id}/delete only renders a form), so
@@ -100,26 +121,40 @@ func originAllowed(r *http.Request) bool {
// The browser's own account of where the request came from. It cannot be // The browser's own account of where the request came from. It cannot be
// set by page script (a forbidden header name), so when it is present it // set by page script (a forbidden header name), so when it is present it
// is the answer: anything but same-origin — including "none", a // is the answer: anything but same-origin — including "none", a
// navigation with no initiator — is refused. // navigation with no initiator — is refused. Fetch Standard § 2.2.2: a
// header name starting with `Sec-` is a forbidden request-header; Fetch
// Metadata § 2.3: "none" is a navigation the user started directly.
if site := r.Header.Get("Sec-Fetch-Site"); site != "" { if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
return site == "same-origin" return site == "same-origin"
} }
// Older browsers send only Origin. Compare hosts, not full origins: the // Older browsers send only Origin, and so does a current one over plain
// panel speaks plain HTTP behind the proxy and does not know its own // HTTP at a name other than localhost: Fetch Metadata § 3 appends its
// external scheme, while Origin will say https. // headers only for a potentially trustworthy URL. Fetch Standard § 3.2: a
// request that is neither GET nor HEAD carries Origin. (Which value it
// carries under this panel's Referrer-Policy: assumptions-2026-10-14, D-B3.)
// Compare hosts, not full origins: the panel speaks plain HTTP behind the
// proxy and does not know its own external scheme, while Origin will say
// https.
origin := r.Header.Get("Origin") origin := r.Header.Get("Origin")
if origin == "" { if origin == "" {
// Neither header. A client this old cannot be checked at all; it is // Neither header. A client this old cannot be checked at all; it is
// let through as the risk consciously accepted (single-admin panel, // let through as the risk consciously accepted (single-admin panel,
// the administrator picks the browser). Turning this return into // the administrator picks the browser). Turning this return into
// false is the whole of the stricter policy. // false is the whole of the stricter policy. By Fetch § 3.2 a browser
// that follows it never sends a POST without Origin; Firefox did for a
// same-origin form before 70 (MDN browser-compat-data 6.0.0,
// http.headers.Origin).
return true return true
} }
u, err := url.Parse(origin) u, err := url.Parse(origin)
if err != nil || u.Host == "" { if err != nil || u.Host == "" {
// Unparseable, or the opaque "null" origin (sandboxed iframe, // Unparseable, or the opaque "null" origin (sandboxed iframe,
// redirected cross-origin POST) — not this panel either way. // redirected cross-origin POST) — not this panel either way.
// Fetch Standard § 2.2.5 "serializing a request origin": "null" for a
// redirect-tainted request; HTML § 7.1.1: an opaque origin serialises
// to "null"; url.Parse("null") has no Host. (Fetch § 3.2 also sends
// "null" under Referrer-Policy no-referrer: D-B3 of the same register.)
return false return false
} }
return u.Host == r.Host return u.Host == r.Host
+13 -1
View File
@@ -110,6 +110,12 @@ type Tag struct {
// why a message was decided the way it was). It is the browser's own popover: // why a message was decided the way it was). It is the browser's own popover:
// the tag is a button that names the sheet by ID, so ID is unique on the page. // the tag is a button that names the sheet by ID, so ID is unique on the page.
// Its content is a table of name and value. // Its content is a table of name and value.
//
// HTML Living Standard, popover.html: popovertarget on a button shows or hides
// (popovertargetaction="hide") the element with that id, with no script; a
// browser without the popover attribute (before Chrome 114, Firefox 125,
// Safari 17 - MDN browser-compat-data 6.0.0, html.global_attributes.popover)
// does not open it.
type Sheet struct { type Sheet struct {
ID string ID string
Tag Tag Tag Tag
@@ -163,7 +169,9 @@ func StatusTag(status string) string {
// wbrAt escapes s and puts a <wbr> after every @ (template function wbr_at): // wbrAt escapes s and puts a <wbr> after every @ (template function wbr_at):
// an address in a table cell breaks where it reads naturally, so the column can // an address in a table cell breaks where it reads naturally, so the column can
// shrink without splitting a name mid-word. // shrink without splitting a name mid-word. Go 1.26, text/template funcs.go
// HTMLEscape(): HTMLEscapeString escapes < > & ' " and NUL, so the only markup
// in the result is the <wbr> added here.
func wbrAt(s string) template.HTML { func wbrAt(s string) template.HTML {
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>")) return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>"))
} }
@@ -283,6 +291,10 @@ type Box struct {
Poll string Poll string
// OOB marks the box to be swapped into the page by its ID when it comes // OOB marks the box to be swapped into the page by its ID when it comes
// in a polled fragment beside the element that asked for the fragment. // in a polled fragment beside the element that asked for the fragment.
// htmx 2.0.4, src/htmx.js swap(): the response replaces only the target;
// findAndSwapOobElements() takes every hx-swap-oob element out of it,
// nested ones too (allowNestedOobSwaps defaults true), and oobSwap()
// with "true" swaps it outerHTML over the element of its id.
OOB bool OOB bool
// DomainPick: see Box. // DomainPick: see Box.
DomainPick bool DomainPick bool
+6 -1
View File
@@ -16,7 +16,10 @@ import (
// a response carries no validator at all and the browser is free to guess how // a response carries no validator at all and the browser is free to guess how
// long to keep it — which is how a replaced favicon keeps showing the old mark // long to keep it — which is how a replaced favicon keeps showing the old mark
// long after a deploy. Hashing the content gives every asset a validator that // long after a deploy. Hashing the content gives every asset a validator that
// changes exactly when the asset does. // changes exactly when the asset does. RFC 9111 § 4.2.2: without explicit
// freshness a cache may assign a heuristic lifetime; Go 1.26, net/http fs.go
// ServeContent(): no Last-Modified for a zero modtime, and an ETag the caller
// set answers If-None-Match.
var staticETags = buildStaticETags() var staticETags = buildStaticETags()
func buildStaticETags() map[string]string { func buildStaticETags() map[string]string {
@@ -50,6 +53,8 @@ func buildStaticETags() map[string]string {
// handful of small files on a single-operator panel that trade is the right way // handful of small files on a single-operator panel that trade is the right way
// round — correctness after a deploy matters more than saving the round trip. // round — correctness after a deploy matters more than saving the round trip.
// http.ServeContent answers the conditional request from the ETag we set here. // http.ServeContent answers the conditional request from the ETag we set here.
// RFC 9111 § 5.2.2.4: no-cache lets a cache store the response but not reuse
// it without validating it first.
func StaticHandler() http.Handler { func StaticHandler() http.Handler {
files := http.FileServer(http.FS(assetsFS)) files := http.FileServer(http.FS(assetsFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+23 -5
View File
@@ -10,7 +10,8 @@
// read from the field itself, so it can never drift from what is shown. Where // read from the field itself, so it can never drift from what is shown. Where
// the clipboard API is absent (plain HTTP outside localhost) the value is // the clipboard API is absent (plain HTTP outside localhost) the value is
// selected instead, ready for the operator's own Ctrl+C. The button's icon // selected instead, ready for the operator's own Ctrl+C. The button's icon
// stays; only its word changes for a moment. // stays; only its word changes for a moment. W3C Clipboard API, § Navigator
// interface: navigator.clipboard is [SecureContext].
document.addEventListener("click", function (ev) { document.addEventListener("click", function (ev) {
var button = ev.target.closest("button[data-copy]"); var button = ev.target.closest("button[data-copy]");
if (!button) { if (!button) {
@@ -69,6 +70,9 @@
// inline script. The listener is delegated from the document, // inline script. The listener is delegated from the document,
// so it also covers markup swapped in by HTMX. With JavaScript disabled the // so it also covers markup swapped in by HTMX. With JavaScript disabled the
// form submits without asking — exactly as the inline handler behaved. // form submits without asking — exactly as the inline handler behaved.
// HTML Living Standard, form-control-infrastructure.html#form-submission-algorithm:
// submit is fired with bubbles and cancelable true, and a cancelled one
// ends the submission.
document.addEventListener("submit", function (ev) { document.addEventListener("submit", function (ev) {
var form = ev.target.closest("form[data-confirm]"); var form = ev.target.closest("form[data-confirm]");
if (form && !window.confirm(form.dataset.confirm)) { if (form && !window.confirm(form.dataset.confirm)) {
@@ -140,6 +144,10 @@
form.querySelectorAll(rule.target).forEach(function (target) { form.querySelectorAll(rule.target).forEach(function (target) {
// hidden alone does not hide an element whose own stylesheet gives it a // hidden alone does not hide an element whose own stylesheet gives it a
// display (Bulma's .box), so Bulma's is-hidden helper goes with it. // display (Bulma's .box), so Bulma's is-hidden helper goes with it.
// HTML Living Standard, rendering.html#hidden-elements: [hidden] is
// display: none in the user agent's sheet, which an author rule such
// as Bulma 1.0.4's .box { display: block } overrides; .is-hidden is
// display: none !important.
target.hidden = !show; target.hidden = !show;
target.classList.toggle("is-hidden", !show); target.classList.toggle("is-hidden", !show);
if (!show && rule.clearWhenHidden) { if (!show && rule.clearWhenHidden) {
@@ -169,19 +177,26 @@
initShowWhen(document); initShowWhen(document);
}); });
// htmx:load fires for every element htmx puts into the page, so swapped-in // htmx:load fires for each top-level element htmx puts into the page, so
// markup starts with its blocks shown or hidden the way a full page does. // swapped-in markup starts with its blocks shown or hidden the way a full
// page does. htmx 2.0.4, src/htmx.js insertNodesBefore() queues
// makeAjaxLoadTask(child) for each element of the swapped content, which
// triggers htmx:load on it (detail.elt); initShowWhen looks inside it.
document.addEventListener("htmx:load", function (ev) { document.addEventListener("htmx:load", function (ev) {
initShowWhen(ev.detail.elt); initShowWhen(ev.detail.elt);
}); });
// --- Adaptive monitoring polling --------------------------------------- // --- Adaptive monitoring polling ---------------------------------------
// The four monitoring fragments carry data-poll and hx-trigger="load" for // The four monitoring fragments carry data-poll and hx-trigger="load" for
// the first fetch only. panel.js schedules the rest: 5 s while the operator // the first fetch only (htmx 2.0.4, src/htmx.js addTriggerHandler(): "load"
// fires once, when the element is first processed). panel.js schedules the rest: 5 s while the operator
// is active on the page, 30 s when the tab is visible but idle, and nothing // is active on the page, 30 s when the tab is visible but idle, and nothing
// while the tab is hidden. hx-trigger="every Ns [expr]" could express some // while the tab is hidden. hx-trigger="every Ns [expr]" could express some
// of that, but the filter is evaluated with `new Function`, which the // of that, but the filter is evaluated with `new Function`, which the
// panel's CSP (default-src 'self', no 'unsafe-eval') would silently break. // panel's CSP (default-src 'self', no 'unsafe-eval') would break: htmx
// 2.0.4, src/htmx.js maybeGenerateConditional() compiles the filter with
// Function(); the EvalError is caught, logged as htmx:syntax:error, and the
// trigger is kept without its filter, so it would fire on every tick.
var pollActiveMs = 5000; var pollActiveMs = 5000;
var pollIdleMs = 30000; var pollIdleMs = 30000;
// No pointer/keyboard/scroll input for this long → treat the tab as idle. // No pointer/keyboard/scroll input for this long → treat the tab as idle.
@@ -238,6 +253,9 @@
} }
// Swapped-in markup still carries hx-trigger="load"; strip it so htmx does // Swapped-in markup still carries hx-trigger="load"; strip it so htmx does
// not issue a duplicate GET on top of the response we just received. // not issue a duplicate GET on top of the response we just received.
// htmx 2.0.4, src/htmx.js swap(): htmx:afterSwap fires on the new
// elements before doSettle() runs the settle tasks, and only those
// (makeAjaxLoadTask -> processNode) act on hx-trigger.
el.removeAttribute("hx-trigger"); el.removeAttribute("hx-trigger");
schedulePoll(el); schedulePoll(el);
} }
+5 -1
View File
@@ -32,7 +32,11 @@
the head for the request-indicator classes. The panel uses no the head for the request-indicator classes. The panel uses no
hx-indicator, and that injected element would be the one thing the hx-indicator, and that injected element would be the one thing the
Content-Security-Policy has to make an exception for, so it Content-Security-Policy has to make an exception for, so it
is switched off here. */}} is switched off here. htmx 2.0.4, src/htmx.js: the ready handler runs
mergeMetaConfig() before insertIndicatorStyles(), which adds the element
only while includeIndicatorStyles is not false. Both scripts are defer:
they run in this order once parsing ends and before DOMContentLoaded
(HTML Living Standard, parsing.html#the-end). */}}
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'> <meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
<script src="/static/panel.js" defer></script> <script src="/static/panel.js" defer></script>