Phase 2: SQLite persistence, admin setup-link, login/sessions
Implements the secure single-admin panel entry (spec 7.6). - internal/store: modernc.org/sqlite (pure Go, static build), WAL + foreign keys, embedded PRAGMA user_version migrations; schema 0001 covers admin/settings/domains/applications/send_log/rate_limits (spec 9). - Setup secret-link (spec 7.6.1): 128-bit crypto/rand token, printed to log + /data/setup-token (0600), 10-min TTL with regeneration, per-IP rate limit, subtle.ConstantTimeCompare, failures don't invalidate, one-time admin form, permanent invalidation once admin exists (/setup 404). - bcrypt admin password; server-side username/password validation. - Login + in-memory sessions, crypto-random token, cookie HttpOnly/Secure/SameSite (Secure toggleable for dev HTTP), login rate limit, auth middleware. - html/template base layout + setup/login/dashboard, vendored htmx 2.0.4. - build/entrypoint.sh: fix bind-mounted /data ownership as root before supervisord drops to the unprivileged panel user (found via container test). Verified on selfpost.mixfed.ru: go vet/build/test/gofmt clean; e2e curl of setup+login flows; docker build + run with -v ./data:/data creates the DB and 0600 token owned by panel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
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 (spec 7.6.1).
|
||||
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 (spec 7.6.1).
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user