initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
369
internal/store/repositories.go
Normal file
369
internal/store/repositories.go
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue