370 lines
12 KiB
Go
370 lines
12 KiB
Go
|
|
package store
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"database/sql"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/urpi/go-bananas/internal/domain"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------- users --
|
||
|
|
|
||
|
|
// UpsertUser records a successful login. Roles are overwritten from the
|
||
|
|
// identity provider on every login rather than merged, so that a user who is
|
||
|
|
// removed from a group actually loses access.
|
||
|
|
func (s *Store) UpsertUser(ctx context.Context, identity *domain.Identity) error {
|
||
|
|
if identity == nil || identity.Subject == "" {
|
||
|
|
return fmt.Errorf("%w: identity subject must not be empty", domain.ErrInvalid)
|
||
|
|
}
|
||
|
|
roles, err := json.Marshal(identity.Roles)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("encode roles: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
now := s.timestamp()
|
||
|
|
_, err = s.db.ExecContext(ctx, `
|
||
|
|
INSERT INTO users (subject, email, display_name, roles, method, first_seen, last_login)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
|
|
ON CONFLICT(subject) DO UPDATE SET
|
||
|
|
email = excluded.email,
|
||
|
|
display_name = excluded.display_name,
|
||
|
|
roles = excluded.roles,
|
||
|
|
method = excluded.method,
|
||
|
|
last_login = excluded.last_login`,
|
||
|
|
identity.Subject, identity.Email, identity.Name, string(roles), identity.Method, now, now,
|
||
|
|
)
|
||
|
|
return translateError(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// User is a stored account projection.
|
||
|
|
type User struct {
|
||
|
|
Subject string `json:"subject"`
|
||
|
|
Email string `json:"email,omitempty"`
|
||
|
|
Name string `json:"name,omitempty"`
|
||
|
|
Roles []string `json:"roles"`
|
||
|
|
Method string `json:"method"`
|
||
|
|
FirstSeen time.Time `json:"firstSeen"`
|
||
|
|
LastLogin time.Time `json:"lastLogin"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListUsers returns every known account, most recently seen first.
|
||
|
|
func (s *Store) ListUsers(ctx context.Context, limit int) ([]User, error) {
|
||
|
|
if limit <= 0 || limit > 500 {
|
||
|
|
limit = 100
|
||
|
|
}
|
||
|
|
rows, err := s.db.QueryContext(ctx, `
|
||
|
|
SELECT subject, email, display_name, roles, method, first_seen, last_login
|
||
|
|
FROM users ORDER BY last_login DESC LIMIT ?`, limit)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("list users: %w", translateError(err))
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
out := make([]User, 0, limit)
|
||
|
|
for rows.Next() {
|
||
|
|
var (
|
||
|
|
u User
|
||
|
|
rolesJSON string
|
||
|
|
firstSeen string
|
||
|
|
lastLogin string
|
||
|
|
)
|
||
|
|
if err := rows.Scan(&u.Subject, &u.Email, &u.Name, &rolesJSON, &u.Method, &firstSeen, &lastLogin); err != nil {
|
||
|
|
return nil, fmt.Errorf("scan user: %w", err)
|
||
|
|
}
|
||
|
|
// Deliberately degraded, not accidentally ignored: a malformed roles blob
|
||
|
|
// yields a user with no roles, which the authorisation layer treats as
|
||
|
|
// read-only. Failing the whole listing because one row has a corrupt column
|
||
|
|
// would be worse than degrading that single user, and it would mean one bad
|
||
|
|
// row hides every other user from the admin.
|
||
|
|
//
|
||
|
|
//nolint:errcheck // see above
|
||
|
|
_ = json.Unmarshal([]byte(rolesJSON), &u.Roles)
|
||
|
|
if u.Roles == nil {
|
||
|
|
u.Roles = []string{}
|
||
|
|
}
|
||
|
|
if u.FirstSeen, err = parseTime(firstSeen); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if u.LastLogin, err = parseTime(lastLogin); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out = append(out, u)
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// UserCount returns the number of known accounts.
|
||
|
|
func (s *Store) UserCount(ctx context.Context) (int, error) {
|
||
|
|
var count int
|
||
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||
|
|
return 0, fmt.Errorf("count users: %w", err)
|
||
|
|
}
|
||
|
|
return count, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------- settings --
|
||
|
|
|
||
|
|
// Setting returns a setting value, or fallback when it is absent.
|
||
|
|
func (s *Store) Setting(ctx context.Context, key, fallback string) (string, error) {
|
||
|
|
var value string
|
||
|
|
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
|
||
|
|
switch {
|
||
|
|
case errors.Is(err, sql.ErrNoRows):
|
||
|
|
return fallback, nil
|
||
|
|
case err != nil:
|
||
|
|
return "", fmt.Errorf("read setting %s: %w", key, err)
|
||
|
|
default:
|
||
|
|
return value, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetSetting writes a setting.
|
||
|
|
func (s *Store) SetSetting(ctx context.Context, key, value, actor string) error {
|
||
|
|
_, err := s.db.ExecContext(ctx, `
|
||
|
|
INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?)
|
||
|
|
ON CONFLICT(key) DO UPDATE SET
|
||
|
|
value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
|
||
|
|
key, value, s.timestamp(), actor)
|
||
|
|
return translateError(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// AllSettings returns every setting.
|
||
|
|
func (s *Store) AllSettings(ctx context.Context) (map[string]domain.Setting, error) {
|
||
|
|
rows, err := s.db.QueryContext(ctx, `SELECT key, value, updated_at, updated_by FROM settings`)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("list settings: %w", translateError(err))
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
out := make(map[string]domain.Setting)
|
||
|
|
for rows.Next() {
|
||
|
|
var (
|
||
|
|
setting domain.Setting
|
||
|
|
updatedAt string
|
||
|
|
)
|
||
|
|
if err := rows.Scan(&setting.Key, &setting.Value, &updatedAt, &setting.UpdatedBy); err != nil {
|
||
|
|
return nil, fmt.Errorf("scan setting: %w", err)
|
||
|
|
}
|
||
|
|
if setting.UpdatedAt, err = parseTime(updatedAt); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out[setting.Key] = setting
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------- audit --
|
||
|
|
|
||
|
|
// AuditFilter narrows the activity log.
|
||
|
|
type AuditFilter struct {
|
||
|
|
Actor string
|
||
|
|
Action string
|
||
|
|
EntityType string
|
||
|
|
EntityID string
|
||
|
|
Since *time.Time
|
||
|
|
Limit int
|
||
|
|
Offset int
|
||
|
|
}
|
||
|
|
|
||
|
|
// AppendAudit writes one audit entry. Failures are returned but callers on the
|
||
|
|
// request path generally log and continue: losing an audit row is bad, failing
|
||
|
|
// a user's publish because of it is worse.
|
||
|
|
func (s *Store) AppendAudit(ctx context.Context, entry *domain.AuditEntry) error {
|
||
|
|
if entry == nil {
|
||
|
|
return fmt.Errorf("%w: audit entry must not be nil", domain.ErrInvalid)
|
||
|
|
}
|
||
|
|
if entry.Action == "" {
|
||
|
|
return fmt.Errorf("%w: audit entry requires an action", domain.ErrInvalid)
|
||
|
|
}
|
||
|
|
if entry.At.IsZero() {
|
||
|
|
entry.At = s.now().UTC()
|
||
|
|
}
|
||
|
|
|
||
|
|
detail, err := json.Marshal(entry.Detail)
|
||
|
|
if err != nil {
|
||
|
|
detail = []byte("{}")
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = s.db.ExecContext(ctx, `
|
||
|
|
INSERT INTO audit_log (at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
|
|
entry.At.UTC().Format(domain.TimeFormat), entry.Actor, entry.ActorEmail,
|
||
|
|
string(entry.Action), entry.EntityType, entry.EntityID, entry.Summary,
|
||
|
|
string(detail), entry.RemoteIP,
|
||
|
|
)
|
||
|
|
return translateError(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListAudit returns audit entries newest first.
|
||
|
|
func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) ([]domain.AuditEntry, int, error) {
|
||
|
|
var (
|
||
|
|
where []string
|
||
|
|
args []any
|
||
|
|
)
|
||
|
|
if filter.Actor != "" {
|
||
|
|
where = append(where, `actor = ?`)
|
||
|
|
args = append(args, filter.Actor)
|
||
|
|
}
|
||
|
|
if filter.Action != "" {
|
||
|
|
where = append(where, `action = ?`)
|
||
|
|
args = append(args, filter.Action)
|
||
|
|
}
|
||
|
|
if filter.EntityType != "" {
|
||
|
|
where = append(where, `entity_type = ?`)
|
||
|
|
args = append(args, filter.EntityType)
|
||
|
|
}
|
||
|
|
if filter.EntityID != "" {
|
||
|
|
where = append(where, `entity_id = ?`)
|
||
|
|
args = append(args, filter.EntityID)
|
||
|
|
}
|
||
|
|
if filter.Since != nil {
|
||
|
|
where = append(where, `at >= ?`)
|
||
|
|
args = append(args, filter.Since.UTC().Format(domain.TimeFormat))
|
||
|
|
}
|
||
|
|
|
||
|
|
clause := ""
|
||
|
|
if len(where) > 0 {
|
||
|
|
clause = " WHERE " + strings.Join(where, " AND ")
|
||
|
|
}
|
||
|
|
|
||
|
|
limit := filter.Limit
|
||
|
|
if limit <= 0 || limit > 500 {
|
||
|
|
limit = 50
|
||
|
|
}
|
||
|
|
offset := filter.Offset
|
||
|
|
if offset < 0 {
|
||
|
|
offset = 0
|
||
|
|
}
|
||
|
|
|
||
|
|
var total int
|
||
|
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`+clause, args...).Scan(&total); err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("count audit entries: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
rows, err := s.db.QueryContext(ctx,
|
||
|
|
`SELECT id, at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip
|
||
|
|
FROM audit_log`+clause+` ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||
|
|
append(append([]any{}, args...), limit, offset)...)
|
||
|
|
if err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("list audit entries: %w", translateError(err))
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
out := make([]domain.AuditEntry, 0, limit)
|
||
|
|
for rows.Next() {
|
||
|
|
var (
|
||
|
|
entry domain.AuditEntry
|
||
|
|
action string
|
||
|
|
at string
|
||
|
|
detailJSON string
|
||
|
|
)
|
||
|
|
if err := rows.Scan(&entry.ID, &at, &entry.Actor, &entry.ActorEmail, &action,
|
||
|
|
&entry.EntityType, &entry.EntityID, &entry.Summary, &detailJSON, &entry.RemoteIP); err != nil {
|
||
|
|
return nil, 0, fmt.Errorf("scan audit entry: %w", err)
|
||
|
|
}
|
||
|
|
entry.Action = domain.AuditAction(action)
|
||
|
|
if entry.At, err = parseTime(at); err != nil {
|
||
|
|
return nil, 0, err
|
||
|
|
}
|
||
|
|
if detailJSON != "" {
|
||
|
|
// Same reasoning as the roles column: an audit entry whose detail cannot
|
||
|
|
// be decoded is still worth returning, because the point of the audit log
|
||
|
|
// is that the record of the action exists.
|
||
|
|
//
|
||
|
|
//nolint:errcheck // see above
|
||
|
|
_ = json.Unmarshal([]byte(detailJSON), &entry.Detail)
|
||
|
|
}
|
||
|
|
out = append(out, entry)
|
||
|
|
}
|
||
|
|
return out, total, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// AuditRetention returns the age of the oldest audit entry, used by the
|
||
|
|
// retention job.
|
||
|
|
func (s *Store) AuditRetention(ctx context.Context) (time.Time, error) {
|
||
|
|
var oldest sql.NullString
|
||
|
|
if err := s.db.QueryRowContext(ctx, `SELECT MIN(at) FROM audit_log`).Scan(&oldest); err != nil {
|
||
|
|
return time.Time{}, fmt.Errorf("audit retention: %w", err)
|
||
|
|
}
|
||
|
|
if !oldest.Valid {
|
||
|
|
return s.now().UTC(), nil
|
||
|
|
}
|
||
|
|
return parseTime(oldest.String)
|
||
|
|
}
|
||
|
|
|
||
|
|
// PurgeAudit removes entries older than the cutoff and returns the count.
|
||
|
|
func (s *Store) PurgeAudit(ctx context.Context, before time.Time) (int64, error) {
|
||
|
|
result, err := s.db.ExecContext(ctx,
|
||
|
|
`DELETE FROM audit_log WHERE at < ?`, before.UTC().Format(domain.TimeFormat))
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("purge audit: %w", translateError(err))
|
||
|
|
}
|
||
|
|
return result.RowsAffected()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stats summarises the instance for the admin dashboard.
|
||
|
|
type Stats struct {
|
||
|
|
Themes int `json:"themes"`
|
||
|
|
ThemesByStatus map[string]int `json:"themesByStatus"`
|
||
|
|
Users int `json:"users"`
|
||
|
|
AuditEntries int `json:"auditEntries"`
|
||
|
|
DatabaseSize int64 `json:"databaseSizeBytes"`
|
||
|
|
VersionsRetaind int `json:"versionsRetained"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stats gathers dashboard counters in a single pass.
|
||
|
|
func (s *Store) Stats(ctx context.Context) (Stats, error) {
|
||
|
|
stats := Stats{ThemesByStatus: map[string]int{}}
|
||
|
|
|
||
|
|
rows, err := s.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM themes GROUP BY status`)
|
||
|
|
if err != nil {
|
||
|
|
return stats, fmt.Errorf("stats: %w", translateError(err))
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
for rows.Next() {
|
||
|
|
var (
|
||
|
|
status string
|
||
|
|
count int
|
||
|
|
)
|
||
|
|
if err := rows.Scan(&status, &count); err != nil {
|
||
|
|
return stats, fmt.Errorf("stats: %w", err)
|
||
|
|
}
|
||
|
|
stats.ThemesByStatus[status] = count
|
||
|
|
stats.Themes += count
|
||
|
|
}
|
||
|
|
// rows.Next() returning false means either no rows or an error. Checking Err
|
||
|
|
// distinguishes them, and without it a failure part way through the result set
|
||
|
|
// reports success with silently incomplete counters.
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return stats, fmt.Errorf("stats: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, query := range []struct {
|
||
|
|
sql string
|
||
|
|
dest *int
|
||
|
|
}{
|
||
|
|
{`SELECT COUNT(*) FROM users`, &stats.Users},
|
||
|
|
{`SELECT COUNT(*) FROM audit_log`, &stats.AuditEntries},
|
||
|
|
{`SELECT COUNT(*) FROM theme_versions`, &stats.VersionsRetaind},
|
||
|
|
} {
|
||
|
|
if err := s.db.QueryRowContext(ctx, query.sql).Scan(query.dest); err != nil {
|
||
|
|
return stats, fmt.Errorf("stats: %w", translateError(err))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// page_count * page_size is the on-disk size of the main database file. It
|
||
|
|
// is a cheap approximation, which is all a dashboard needs.
|
||
|
|
var pageCount, pageSize int64
|
||
|
|
if err := s.db.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err == nil {
|
||
|
|
if err := s.db.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err == nil {
|
||
|
|
stats.DatabaseSize = pageCount * pageSize
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return stats, nil
|
||
|
|
}
|