2b08a947b2
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>
62 lines
1.8 KiB
Go
62 lines
1.8 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 (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
|
|
}
|