Files
2026-08-16 21:18:45 -05:00

208 lines
6.3 KiB
Go

package database
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/owen/vibedns/internal/models"
)
// Admin returns the administrator account, or ErrNotFound before first setup.
func (db *DB) Admin(ctx context.Context) (models.Admin, error) {
var a models.Admin
var created, updated int64
var lastLogin sql.NullInt64
var mustChange int
err := db.QueryRowContext(ctx, `
SELECT username, password_hash, must_change_password, created_at, updated_at, last_login_at
FROM admin_user WHERE id = 1`).
Scan(&a.Username, &a.PasswordHash, &mustChange, &created, &updated, &lastLogin)
switch {
case errors.Is(err, sql.ErrNoRows):
return a, ErrNotFound
case err != nil:
return a, fmt.Errorf("load administrator: %w", err)
}
a.MustChangePassword = mustChange != 0
a.CreatedAt = time.Unix(created, 0)
a.UpdatedAt = time.Unix(updated, 0)
a.LastLoginAt = unixPtr(lastLogin)
return a, nil
}
// CreateAdmin inserts the single administrator row. It fails if one exists.
func (db *DB) CreateAdmin(ctx context.Context, username, passwordHash string, mustChange bool) error {
_, err := db.ExecContext(ctx, `
INSERT INTO admin_user (id, username, password_hash, must_change_password)
VALUES (1, ?, ?, ?)`, username, passwordHash, boolInt(mustChange))
if err != nil {
if isUniqueViolation(err) {
return ErrConflict
}
return fmt.Errorf("create administrator: %w", err)
}
return nil
}
// UpdateAdminCredentials replaces the username and/or password hash.
func (db *DB) UpdateAdminCredentials(ctx context.Context, username, passwordHash string, mustChange bool) error {
res, err := db.ExecContext(ctx, `
UPDATE admin_user
SET username = ?, password_hash = ?, must_change_password = ?, updated_at = unixepoch()
WHERE id = 1`, username, passwordHash, boolInt(mustChange))
if err != nil {
return fmt.Errorf("update administrator: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// TouchAdminLogin records a successful authentication.
func (db *DB) TouchAdminLogin(ctx context.Context) error {
_, err := db.ExecContext(ctx, `UPDATE admin_user SET last_login_at = unixepoch() WHERE id = 1`)
return err
}
// --- API tokens ---------------------------------------------------------
// CreateAPIToken stores a new token. Only the prefix and hash are persisted.
func (db *DB) CreateAPIToken(ctx context.Context, name, description, prefix, hash string) (models.APIToken, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO api_tokens (name, description, token_prefix, token_hash)
VALUES (?, ?, ?, ?)`, name, description, prefix, hash)
if err != nil {
if isUniqueViolation(err) {
return models.APIToken{}, ErrConflict
}
return models.APIToken{}, fmt.Errorf("create API token: %w", err)
}
id, _ := res.LastInsertId()
return db.APIToken(ctx, id)
}
// APIToken loads one token by ID.
func (db *DB) APIToken(ctx context.Context, id int64) (models.APIToken, error) {
rows, err := db.queryTokens(ctx, `WHERE id = ?`, id)
if err != nil {
return models.APIToken{}, err
}
if len(rows) == 0 {
return models.APIToken{}, ErrNotFound
}
return rows[0], nil
}
// APITokens lists all tokens, newest first.
func (db *DB) APITokens(ctx context.Context) ([]models.APIToken, error) {
return db.queryTokens(ctx, `ORDER BY created_at DESC, id DESC`)
}
func (db *DB) queryTokens(ctx context.Context, where string, args ...any) ([]models.APIToken, error) {
q := `SELECT id, name, description, token_prefix, enabled, created_at, last_used_at FROM api_tokens ` + where
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list API tokens: %w", err)
}
defer rows.Close()
var out []models.APIToken
for rows.Next() {
var t models.APIToken
var enabled int
var created int64
var lastUsed sql.NullInt64
if err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.Prefix, &enabled, &created, &lastUsed); err != nil {
return nil, err
}
t.Enabled = enabled != 0
t.CreatedAt = time.Unix(created, 0)
t.LastUsedAt = unixPtr(lastUsed)
out = append(out, t)
}
return out, rows.Err()
}
// APITokenCandidate is a stored token hash keyed by its lookup prefix.
type APITokenCandidate struct {
ID int64
Name string
Hash string
}
// APITokensByPrefix returns enabled tokens whose prefix matches. The prefix
// narrows the search; the caller still verifies the hash in constant time.
func (db *DB) APITokensByPrefix(ctx context.Context, prefix string) ([]APITokenCandidate, error) {
rows, err := db.QueryContext(ctx,
`SELECT id, name, token_hash FROM api_tokens WHERE token_prefix = ? AND enabled = 1`, prefix)
if err != nil {
return nil, fmt.Errorf("lookup API token: %w", err)
}
defer rows.Close()
var out []APITokenCandidate
for rows.Next() {
var c APITokenCandidate
if err := rows.Scan(&c.ID, &c.Name, &c.Hash); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// TouchAPIToken records that a token was just used. Errors are non-fatal to the
// request path, so callers may ignore them.
func (db *DB) TouchAPIToken(ctx context.Context, id int64) error {
_, err := db.ExecContext(ctx, `UPDATE api_tokens SET last_used_at = unixepoch() WHERE id = ?`, id)
return err
}
// SetAPITokenEnabled enables or disables a token without deleting it.
func (db *DB) SetAPITokenEnabled(ctx context.Context, id int64, enabled bool) error {
res, err := db.ExecContext(ctx, `UPDATE api_tokens SET enabled = ? WHERE id = ?`, boolInt(enabled), id)
if err != nil {
return fmt.Errorf("update API token: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteAPIToken permanently revokes a token.
func (db *DB) DeleteAPIToken(ctx context.Context, id int64) error {
res, err := db.ExecContext(ctx, `DELETE FROM api_tokens WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete API token: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
// isUniqueViolation detects SQLite UNIQUE/PRIMARY KEY constraint failures
// without depending on driver-specific error types.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unique constraint failed") ||
strings.Contains(msg, "constraint failed: unique")
}