diff --git a/absences.go b/absences.go index d5e4164..63a01cc 100644 --- a/absences.go +++ b/absences.go @@ -11,6 +11,11 @@ func (s *Store) AddAbsence(ctx context.Context, session time.Time, userId string return fmt.Errorf("not a monday") } + defer s.Audit(&AuditableEvent{ + userId: userId, + eventType: EventAbsence, + }) + tx, err := s.db.Begin() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) diff --git a/audit_log.go b/audit_log.go new file mode 100644 index 0000000..72c367f --- /dev/null +++ b/audit_log.go @@ -0,0 +1,103 @@ +package themis + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/rs/zerolog/log" +) + +type EventType int + +const ( + EventFlush EventType = iota + EventClaim + EventUnclaim + EventAbsence +) + +func (et EventType) String() string { + switch et { + case EventFlush: + return "FLUSH" + case EventClaim: + return "CLAIM" + case EventUnclaim: + return "UNCLAIM" + case EventAbsence: + return "ABSENT" + default: + return "" + } +} + +func EventTypeFromString(ev string) (EventType, error) { + switch ev { + case "FLUSH": + return EventFlush, nil + case "CLAIM": + return EventClaim, nil + case "UNCLAIM": + return EventUnclaim, nil + case "ABSENT": + return EventAbsence, nil + default: + return EventType(9999), errors.New("no such event type") + } +} + +type AuditableEvent struct { + userId string + eventType EventType + Timestamp time.Time +} + +// Audit writes to the audit table, returns nothing because it is meant to be +// used in a defered statement on functions that write to the database. +func (s *Store) Audit(ev *AuditableEvent) { + ctx := context.Background() + + tx, err := s.db.Begin() + if err != nil { + log.Error().Err(err).Msg("failed to start transaction") + } + defer tx.Commit() //nolint:errcheck + + stmt, err := s.db.PrepareContext(ctx, "INSERT INTO audit_log (userid, event_type, ts) VALUES (?, ?, ?)") + if err != nil { + log.Error().Err(err).Msg("failed to prepare audit log insert") + } + + if _, err := stmt.ExecContext(ctx, ev.userId, ev.eventType.String(), time.Now()); err != nil { + log.Error().Err(err).Msg("failed to insert audit log") + } +} + +func (s *Store) LastOf(ctx context.Context, t EventType) (AuditableEvent, error) { + stmt, err := s.db.PrepareContext(ctx, `SELECT userid, event_type, ts FROM audit_log WHERE event_type = ? ORDER BY ts DESC LIMIT 1`) + if err != nil { + return AuditableEvent{}, fmt.Errorf("failed to get last event of type %s: %w", t.String(), err) + } + + row := stmt.QueryRowContext(ctx, t.String()) + + ev := AuditableEvent{} + var rawEventType string + err = row.Scan(&ev.userId, &rawEventType, &ev.Timestamp) + if err == sql.ErrNoRows { + return AuditableEvent{}, errors.New("") + } + if err != nil { + return AuditableEvent{}, fmt.Errorf("failed to scan row: %w", err) + } + + ev.eventType, err = EventTypeFromString(rawEventType) + if err != nil { + return AuditableEvent{}, fmt.Errorf("failed to parse event type %s: %w", rawEventType, err) + } + + return ev, nil +} diff --git a/cmd/themis-server/main.go b/cmd/themis-server/main.go index 490d310..32529ed 100644 --- a/cmd/themis-server/main.go +++ b/cmd/themis-server/main.go @@ -675,7 +675,7 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) { sub := i.ModalSubmitData().Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value sub = strings.ToLower(sub) if sub == "y" || sub == "ye" || sub == "yes" { - err := store.Flush(context.Background()) + err := store.Flush(context.Background(), i.Member.User.ID) msg := "Flushed all claims!" if err != nil { log.Error().Err(err).Msg("failed to flush claims") diff --git a/migrations/20231202211824_add_audit.down.sql b/migrations/20231202211824_add_audit.down.sql new file mode 100644 index 0000000..c58ea10 --- /dev/null +++ b/migrations/20231202211824_add_audit.down.sql @@ -0,0 +1 @@ +DROP TABLE audit_log; diff --git a/migrations/20231202211824_add_audit.up.sql b/migrations/20231202211824_add_audit.up.sql new file mode 100644 index 0000000..8bc7696 --- /dev/null +++ b/migrations/20231202211824_add_audit.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_type TEXT, + userid TEXT, + ts TIMESTAMP +); diff --git a/store.go b/store.go index 9b7820f..fe133ca 100644 --- a/store.go +++ b/store.go @@ -58,6 +58,11 @@ func (s *Store) Close() error { } func (s *Store) Claim(ctx context.Context, userId, player, province string, claimType ClaimType) (int, error) { + defer s.Audit(&AuditableEvent{ + userId: userId, + eventType: EventClaim, + }) + tx, err := s.db.Begin() if err != nil { return 0, fmt.Errorf("failed to begin transaction: %w", err) @@ -237,6 +242,11 @@ func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error) } func (s *Store) DeleteClaim(ctx context.Context, ID int, userId string) error { + defer s.Audit(&AuditableEvent{ + userId: userId, + eventType: EventUnclaim, + }) + stmt, err := s.db.PrepareContext(ctx, "DELETE FROM claims WHERE id = ? AND userid = ?") if err != nil { return fmt.Errorf("failed to prepare query: %w", err) @@ -272,7 +282,12 @@ func (s *Store) CountClaims(ctx context.Context) (total, uniquePlayers int, err return total, uniquePlayers, nil } -func (s *Store) Flush(ctx context.Context) error { +func (s *Store) Flush(ctx context.Context, userId string) error { + defer s.Audit(&AuditableEvent{ + userId: userId, + eventType: EventFlush, + }) + _, err := s.db.ExecContext(ctx, "DELETE FROM claims;") if err != nil { return fmt.Errorf("failed to execute delete query: %w", err) diff --git a/store_test.go b/store_test.go index 10bc12e..1107836 100644 --- a/store_test.go +++ b/store_test.go @@ -212,7 +212,7 @@ func TestFlush(t *testing.T) { store.Claim(context.TODO(), "000000000000000001", "foo", "Iberia", CLAIM_TYPE_REGION) store.Claim(context.TODO(), "000000000000000001", "foo", "Ragusa", CLAIM_TYPE_TRADE) - assert.NoError(t, store.Flush(context.TODO())) + assert.NoError(t, store.Flush(context.TODO(), "bob")) claims, err := store.ListClaims(context.TODO()) assert.NoError(t, err) assert.Equal(t, 0, len(claims))