go-bananas/internal/store/repositories.go

370 lines
12 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:11:35 +02:00
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 != "" {
2026-10-10 02:30:01 +02:00
where = append(where, `actor_email LIKE ?`)
args = append(args, "%"+filter.Actor+"%")
2026-10-07 15:11:35 +02:00
}
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
}