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:
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 (?, ?)
|
||||||
|
|||||||
@@ -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 < ?`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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") != "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user