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
|
||||
// login is globally unique because it is the SASL identity Postfix
|
||||
// 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")
|
||||
|
||||
// ErrApplicationNotFound is returned when an application id does not exist.
|
||||
@@ -74,6 +77,7 @@ type Binding struct {
|
||||
// login maps to ErrLoginExists.
|
||||
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
|
||||
now := time.Now().UTC()
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
// sqlite3_last_insert_rowid() of this connection, the transaction's own
|
||||
// (https://sqlite.org/c3ref/last_insert_rowid.html).
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
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.
|
||||
// Returns ErrApplicationNotFound if the id does not exist.
|
||||
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin update application: %w", err)
|
||||
@@ -353,9 +360,9 @@ func (s *Store) UpdateApplicationAuthIPs(id int64, restrict bool, ips []string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteApplication removes an application and its addresses (via cascade),
|
||||
// returning the deleted application so the caller can drop its sasldb2 entry
|
||||
// (product.md). Returns ErrApplicationNotFound if no such row existed.
|
||||
// DeleteApplication removes an application and its addresses (via cascade, see
|
||||
// DeleteDomain), returning the deleted application so the caller can drop its
|
||||
// sasldb2 entry (product.md). Returns ErrApplicationNotFound if no such row existed.
|
||||
func (s *Store) DeleteApplication(id int64) (Application, error) {
|
||||
a, err := s.GetApplication(id)
|
||||
if err != nil {
|
||||
|
||||
@@ -80,8 +80,10 @@ type DMARCIngestStats struct {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
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
|
||||
// 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 {
|
||||
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
|
||||
if err != nil {
|
||||
@@ -162,6 +165,11 @@ func scanDomain(r scanRow) (Domain, error) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
var se *sqlite.Error
|
||||
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.
|
||||
// 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) {
|
||||
var n int
|
||||
err := s.db.QueryRow(
|
||||
|
||||
@@ -48,6 +48,7 @@ type InboundDomain struct {
|
||||
// caller must have validated name (security.md).
|
||||
func (s *Store) AddInboundDomain(name string) (InboundDomain, error) {
|
||||
now := time.Now().UTC()
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
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
|
||||
// address list. In any mode the stored list is cleared.
|
||||
func (s *Store) UpdateInboundRecipients(id int64, mode string, addresses []string) error {
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
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
|
||||
// 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
|
||||
// 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) {
|
||||
at := e.At.UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
@@ -103,6 +104,8 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) {
|
||||
n, _ := res.RowsAffected()
|
||||
// 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.
|
||||
// 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(
|
||||
`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)
|
||||
|
||||
@@ -137,6 +137,9 @@ func (s *Store) DeleteNotifyState(key string) error {
|
||||
|
||||
// ListNotifyStateKeys returns the keys that begin with prefix, so that the
|
||||
// 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) {
|
||||
rows, err := s.db.Query(
|
||||
"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.
|
||||
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
|
||||
// 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(
|
||||
`SELECT d.name, a.login, COUNT(*), MIN(l.created_at)
|
||||
FROM send_log l
|
||||
|
||||
@@ -100,6 +100,7 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
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
|
||||
// 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")
|
||||
setting := func(key, value, cond string) error {
|
||||
_, 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
|
||||
// (audit 2026-10-12, L12). The domain name of the row stays what the
|
||||
// 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 = ?)`
|
||||
|
||||
// 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
|
||||
// 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,
|
||||
// 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) {
|
||||
now := time.Now().UTC()
|
||||
tx, err := s.db.Begin()
|
||||
@@ -184,7 +194,9 @@ type QueuedDelivery struct {
|
||||
// about them.
|
||||
//
|
||||
// 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) {
|
||||
rows, err := s.db.Query(
|
||||
`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
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
res, err := s.db.Exec(
|
||||
`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
|
||||
// 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
|
||||
// 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) {
|
||||
stamp := at.UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
@@ -433,7 +452,7 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
|
||||
// implementing the configurable retention window (architecture.md §
|
||||
// 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
|
||||
// same format is chronologically correct.
|
||||
// same format is chronologically correct (see ListQueuedOlderThan).
|
||||
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
|
||||
res, err := s.db.Exec(
|
||||
`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,
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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) {
|
||||
var (
|
||||
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
|
||||
// forever. It piggybacks on session creation rather than running as its own
|
||||
// background loop.
|
||||
// expires_at is RFC3339 UTC text, so < compares moments (see
|
||||
// ListQueuedOlderThan).
|
||||
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))
|
||||
if err != nil {
|
||||
|
||||
@@ -156,7 +156,8 @@ func (s *Store) GetSetting(key string) (string, error) {
|
||||
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 {
|
||||
_, err := s.db.Exec(
|
||||
"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,
|
||||
// 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.
|
||||
// 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)"
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
err = s.db.QueryRow(
|
||||
`SELECT COALESCE(MAX(bucket_count), 0) FROM (
|
||||
|
||||
+26
-2
@@ -29,13 +29,23 @@ type Store struct {
|
||||
func Open(path string) (*Store, error) {
|
||||
// _pragma parameters are applied on every pooled connection by the driver,
|
||||
// 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)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
// modernc's driver serializes writes anyway; a small pool avoids
|
||||
// "database is locked" surprises under WAL.
|
||||
// One connection: database/sql then runs this process's statements and
|
||||
// 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)
|
||||
|
||||
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
|
||||
// on a schema it does not know, so such a file is refused outright; nothing
|
||||
// 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 {
|
||||
var appID int64
|
||||
if err := s.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil {
|
||||
@@ -109,12 +123,22 @@ func (s *Store) migrate() error {
|
||||
if err != nil {
|
||||
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 {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
// PRAGMA does not accept a bound parameter, and target is a trusted
|
||||
// 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 {
|
||||
tx.Rollback()
|
||||
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
|
||||
// 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).
|
||||
// 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 {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
@@ -249,6 +253,7 @@ func (s *Store) AddUser(c UserChange) (int64, error) {
|
||||
if c.Role == RoleDomain && c.Reach.Empty() {
|
||||
return 0, ErrEmptyReach
|
||||
}
|
||||
// The first statement is a write (see UpdateStatus).
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create user: %w", err)
|
||||
@@ -302,7 +307,12 @@ func (s *Store) SaveUser(c UserChange) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// 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
|
||||
// opened, and the user may have changed it on Account since.
|
||||
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
|
||||
// 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 {
|
||||
tx, err := s.db.Begin()
|
||||
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
|
||||
// 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
|
||||
// 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 {
|
||||
if _, err := tx.Exec("UPDATE users SET all_domains = ?, all_inbound_domains = ? WHERE id = ?",
|
||||
reach.AllDomains, reach.AllInbound, userID); err != nil {
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"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 (
|
||||
sessionCookieBase = "selfpost_session"
|
||||
sessionCookiePrefixed = "__Host-" + sessionCookieBase
|
||||
@@ -46,6 +49,9 @@ func (m *Module) sessionToken(r *http.Request) (string, bool) {
|
||||
case 1:
|
||||
return token, true
|
||||
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; "+
|
||||
"another host on this domain is overwriting the session cookie, clear the cookies for the parent domain",
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
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 {
|
||||
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
|
||||
// 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 {
|
||||
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.Email = strings.TrimSpace(r.PostFormValue("email"))
|
||||
// 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") != ""
|
||||
if f.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
|
||||
// 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) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
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.
|
||||
// HTML Living Standard, form-control-infrastructure.html#constructing-entry-list:
|
||||
// a checkbox whose checkedness is false is skipped.
|
||||
notifications := r.PostFormValue("notify") != ""
|
||||
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||||
logf("panel: settings: set send-log retention failed: %v", err)
|
||||
|
||||
@@ -19,8 +19,17 @@ import (
|
||||
// relative URL on the page, including the form actions;
|
||||
// - 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
|
||||
// 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'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'none'; " +
|
||||
@@ -31,8 +40,10 @@ const contentSecurityPolicy = "default-src 'self'; " +
|
||||
// (CookieSecure, the same condition that gates the cookie's Secure attribute):
|
||||
// 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.
|
||||
// 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
|
||||
// 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.
|
||||
@@ -51,6 +62,8 @@ func (s *Server) secure(next http.Handler) http.Handler {
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
// Equivalent to the CSP's frame-ancestors directive, kept for browsers
|
||||
// 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")
|
||||
// The panel's behaviour never depends on the referrer, so the strictest
|
||||
// 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
|
||||
// proxy fragment this project ships preserves (Apache
|
||||
// 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
|
||||
// side by side the symptom just looks like "the panel stopped
|
||||
// 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
|
||||
// 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.
|
||||
// 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
|
||||
// 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
|
||||
// set by page script (a forbidden header name), so when it is present it
|
||||
// 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 != "" {
|
||||
return site == "same-origin"
|
||||
}
|
||||
|
||||
// Older browsers send only Origin. 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.
|
||||
// Older browsers send only Origin, and so does a current one over plain
|
||||
// HTTP at a name other than localhost: Fetch Metadata § 3 appends its
|
||||
// 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")
|
||||
if origin == "" {
|
||||
// Neither header. A client this old cannot be checked at all; it is
|
||||
// let through as the risk consciously accepted (single-admin panel,
|
||||
// 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
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Host == "" {
|
||||
// Unparseable, or the opaque "null" origin (sandboxed iframe,
|
||||
// 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 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:
|
||||
// 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.
|
||||
//
|
||||
// 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 {
|
||||
ID string
|
||||
Tag Tag
|
||||
@@ -163,7 +169,9 @@ func StatusTag(status string) string {
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(s), "@", "@<wbr>"))
|
||||
}
|
||||
@@ -283,6 +291,10 @@ type Box struct {
|
||||
Poll string
|
||||
// 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.
|
||||
// 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
|
||||
// DomainPick: see Box.
|
||||
DomainPick bool
|
||||
|
||||
@@ -16,7 +16,10 @@ import (
|
||||
// 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 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()
|
||||
|
||||
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
|
||||
// round — correctness after a deploy matters more than saving the round trip.
|
||||
// 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 {
|
||||
files := http.FileServer(http.FS(assetsFS))
|
||||
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
|
||||
// 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
|
||||
// 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) {
|
||||
var button = ev.target.closest("button[data-copy]");
|
||||
if (!button) {
|
||||
@@ -69,6 +70,9 @@
|
||||
// inline script. The listener is delegated from the document,
|
||||
// so it also covers markup swapped in by HTMX. With JavaScript disabled the
|
||||
// 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) {
|
||||
var form = ev.target.closest("form[data-confirm]");
|
||||
if (form && !window.confirm(form.dataset.confirm)) {
|
||||
@@ -140,6 +144,10 @@
|
||||
form.querySelectorAll(rule.target).forEach(function (target) {
|
||||
// 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.
|
||||
// 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.classList.toggle("is-hidden", !show);
|
||||
if (!show && rule.clearWhenHidden) {
|
||||
@@ -169,19 +177,26 @@
|
||||
initShowWhen(document);
|
||||
});
|
||||
|
||||
// htmx:load fires for every element htmx puts into the page, so swapped-in
|
||||
// markup starts with its blocks shown or hidden the way a full page does.
|
||||
// htmx:load fires for each top-level element htmx puts into the page, so
|
||||
// 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) {
|
||||
initShowWhen(ev.detail.elt);
|
||||
});
|
||||
|
||||
// --- Adaptive monitoring polling ---------------------------------------
|
||||
// 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
|
||||
// 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
|
||||
// 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 pollIdleMs = 30000;
|
||||
// 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
|
||||
// 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");
|
||||
schedulePoll(el);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@
|
||||
the head for the request-indicator classes. The panel uses no
|
||||
hx-indicator, and that injected element would be the one thing the
|
||||
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}'>
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script src="/static/panel.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user