Files
mcias/data/store.go
2025-11-16 21:54:28 -08:00

157 lines
4.5 KiB
Go

package data
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"time"
_ "modernc.org/sqlite"
)
// Store provides persistence for users and roles in a SQLite database.
type Store struct {
db *sql.DB
}
// Open opens or creates a SQLite database at the given path and ensures the schema exists.
func Open(ctx context.Context, path string) (*Store, error) {
dsn := path
if dsn == "" {
dsn = ":memory:"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
// Enforce foreign keys
if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
_ = db.Close()
return nil, err
}
if err = applySchema(ctx, db); err != nil {
_ = db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// Close closes the underlying database.
func (s *Store) Close() error { return s.db.Close() }
// CreateUser inserts a new user. If u.ID is empty, a random ID is generated.
func (s *Store) CreateUser(ctx context.Context, u *User) error {
if u == nil {
return errors.New("nil user")
}
if u.Username == "" {
return errors.New("username required")
}
if u.Type == "" {
u.Type = AccountHuman
}
if u.ID == "" {
id, err := newID()
if err != nil {
return err
}
u.ID = id
}
now := time.Now().Unix()
_, err := s.db.ExecContext(ctx,
`INSERT INTO users(id, username, type, pwd_hash, totp_secret, created_at, updated_at)
VALUES(?,?,?,?,?,?,?)`,
u.ID, u.Username, string(u.Type), u.PasswordHash(), u.TOTPSecret, now, now,
)
return err
}
// UpdateUser updates mutable fields of a user identified by ID.
func (s *Store) UpdateUser(ctx context.Context, u *User) error {
if u == nil || u.ID == "" {
return errors.New("user ID required")
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET username=?, type=?, pwd_hash=?, totp_secret=?, updated_at=? WHERE id=?`,
u.Username, string(u.Type), u.PasswordHash(), u.TOTPSecret, time.Now().Unix(), u.ID,
)
return err
}
// GetUserByUsername fetches a user and its roles.
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, username, type, pwd_hash, totp_secret FROM users WHERE username=?`, username)
var id, uname, typ, ph, totp string
if err := row.Scan(&id, &uname, &typ, &ph, &totp); err != nil {
return nil, err
}
u := &User{ID: id, Username: uname, Type: AccountType(typ), TOTPSecret: totp}
if err := u.LoadPasswordHash(ph); err != nil {
return nil, err
}
roles, err := s.userRoles(ctx, id)
if err != nil {
return nil, err
}
u.Roles = roles
return u, nil
}
// AssignRole ensures a role exists and links it to the user.
func (s *Store) AssignRole(ctx context.Context, userID, role string) error {
if role == "" || userID == "" {
return errors.New("userID and role required")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO roles(name) VALUES (?)`, role); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO user_roles(user_id, role) VALUES (?,?)`, userID, role); err != nil {
return err
}
return tx.Commit()
}
// RemoveRole removes a role association from a user.
func (s *Store) RemoveRole(ctx context.Context, userID, role string) error {
if role == "" || userID == "" {
return errors.New("userID and role required")
}
_, err := s.db.ExecContext(ctx, `DELETE FROM user_roles WHERE user_id=? AND role=?`, userID, role)
return err
}
func (s *Store) userRoles(ctx context.Context, userID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT role FROM user_roles WHERE user_id=? ORDER BY role`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func newID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
dst := make([]byte, hex.EncodedLen(len(b)))
hex.Encode(dst, b[:])
return string(dst), nil
}