A bounce could be recorded as a successful delivery. The log-tailer's Fixed: - A bounce could be recorded as a successful delivery. The log-tailer's - Log-tailer resumes where it stopped instead of jumping to end-of-file on - Level-2 rate limit no longer overshoots under concurrency: messages that Changed: - The project has a single public home: `github.com/mixeme/selfpost`. Codeberg - Code comments no longer cite the archived specification. References like - `docs/code-review.md` is gone. Its plan is finished — phases 0 (bar the - `docs/architecture.md` gained a *Code layers* section: a diagram of - Phase 1 doc/code hygiene (`docs/code-review.md`): removed ~30 stale … (1 more in CHANGELOG) Security: - Pre-release security review (plan § D, model Fable, 2026-08-06): full pass - `saslpasswd2` argv: the application login is now passed after a `--` Added: - Optional password encryption for the two secret-bearing downloads (plan - Domain import accepts an encrypted export: the envelope is detected by its - `selfpost-backup` writes encrypted archives and reads them back: - `TestParseDelivery` covers the exotic mail.log shapes the review asked for - docs: README *Encrypting a backup or export*; `docs/security.md` § … (14 more in CHANGELOG) Removed: - docs: `security.md` — accepted risk «restore old backup revives session rows» Changed: - docs: `implementation-plan.md` trimmed to the sole open v1.x gate — pre-release - docs: `architecture.md` — sessions (SQLite, idle renew, password change), - docs: `development.md` — expanded e2e stack and `release.yml` matrix workflow. - docs: `security.md` — accepted risk for send-log rows stuck at `queued` after - docs: `roadmap.md` — optional send-log / `mail.log` follow-ups under v1.x tail. … (10 more in CHANGELOG) Full detail: CHANGELOG.md (0.5.0).
85 lines
2.5 KiB
Go
85 lines
2.5 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
|
|
var ErrNoAdmin = errors.New("no administrator account")
|
|
|
|
// Admin is the single panel administrator (security.md).
|
|
type Admin struct {
|
|
Username string
|
|
PasswordHash string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// AdminExists reports whether the administrator account has been created. This
|
|
// doubles as the "primary setup complete" flag: once true, the /setup route is
|
|
// permanently gone (security.md).
|
|
func (s *Store) AdminExists() (bool, error) {
|
|
var n int
|
|
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
|
|
return false, fmt.Errorf("count admin: %w", err)
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// CreateAdmin inserts the administrator row. It fails if one already exists,
|
|
// which — combined with the id=1 constraint — makes admin creation one-shot
|
|
// even under a race between two setup submissions.
|
|
func (s *Store) CreateAdmin(username, passwordHash string) error {
|
|
_, err := s.db.Exec(
|
|
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
|
|
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("create admin: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdateAdmin replaces the administrator's username and password hash. It
|
|
// touches only the admin row (id = 1): panel credentials are unrelated to the
|
|
// SASL logins applications authenticate with, which live in their own table.
|
|
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
|
|
// silently create an account.
|
|
func (s *Store) UpdateAdmin(username, passwordHash string) error {
|
|
res, err := s.db.Exec(
|
|
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
|
|
username, passwordHash,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update admin: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("update admin: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return ErrNoAdmin
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
|
|
func (s *Store) GetAdmin() (Admin, error) {
|
|
var (
|
|
a Admin
|
|
createdAt string
|
|
)
|
|
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1").
|
|
Scan(&a.Username, &a.PasswordHash, &createdAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Admin{}, ErrNoAdmin
|
|
}
|
|
if err != nil {
|
|
return Admin{}, fmt.Errorf("get admin: %w", err)
|
|
}
|
|
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
|
return a, nil
|
|
}
|