package admin import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) // ErrAdminNotFound is returned when no admin row matches a lookup. var ErrAdminNotFound = errors.New("admin: not found") // Store is the data-access surface the admin backend needs. It is an interface // so handlers and the authenticator can be unit-tested with a fake; the // production implementation (DBStore) is backed by MySQL. type Store interface { // Admin identity. GetAdminByUsername(ctx context.Context, username string) (*Admin, error) CreateAdmin(ctx context.Context, username, pwHash string, totpSecretEnc []byte) (int64, error) UpdateLastLogin(ctx context.Context, id int64, at time.Time) error // Node catalogue (read-only here; mutations go through the #5/#14 services). ListNodes(ctx context.Context, eventsPerNode int) ([]NodeRow, error) GetNode(ctx context.Context, id int64) (*NodeRow, error) // Audit & events. WriteAudit(ctx context.Context, actor, action, target, metaJSON string) error QueryAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, int, error) QueryNodeEvents(ctx context.Context, nodeID int64, limit int) ([]NodeEvent, error) } // DBStore implements Store over MySQL. type DBStore struct { db *sql.DB } // NewDBStore creates a DBStore. func NewDBStore(db *sql.DB) *DBStore { return &DBStore{db: db} } // GetAdminByUsername loads an admin by username. func (s *DBStore) GetAdminByUsername(ctx context.Context, username string) (*Admin, error) { row := s.db.QueryRowContext(ctx, `SELECT id, username, pw_hash, totp_secret, status, created_at, last_login_at FROM admins WHERE username = ?`, username) var a Admin var last sql.NullTime err := row.Scan(&a.ID, &a.Username, &a.PwHash, &a.TOTPSecretEnc, &a.Status, &a.CreatedAt, &last) if errors.Is(err, sql.ErrNoRows) { return nil, ErrAdminNotFound } if err != nil { return nil, fmt.Errorf("admin.GetAdminByUsername: %w", err) } if last.Valid { a.LastLoginAt = &last.Time } return &a, nil } // CreateAdmin inserts a new admin and returns its id. func (s *DBStore) CreateAdmin(ctx context.Context, username, pwHash string, totpSecretEnc []byte) (int64, error) { res, err := s.db.ExecContext(ctx, `INSERT INTO admins (username, pw_hash, totp_secret, status, created_at) VALUES (?, ?, ?, 'active', ?)`, username, pwHash, totpSecretEnc, time.Now().UTC()) if err != nil { return 0, fmt.Errorf("admin.CreateAdmin: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("admin.CreateAdmin last id: %w", err) } return id, nil } // UpdateLastLogin records a successful login time. func (s *DBStore) UpdateLastLogin(ctx context.Context, id int64, at time.Time) error { _, err := s.db.ExecContext(ctx, `UPDATE admins SET last_login_at = ? WHERE id = ?`, at.UTC(), id) if err != nil { return fmt.Errorf("admin.UpdateLastLogin: %w", err) } return nil } // ListNodes returns all nodes with up to eventsPerNode recent events each. func (s *DBStore) ListNodes(ctx context.Context, eventsPerNode int) ([]NodeRow, error) { rows, err := s.db.QueryContext(ctx, `SELECT n.id, n.uuid, n.region, n.name_zh, n.name_en, n.role, n.tier, n.endpoint, n.status, n.weight, pr.name FROM nodes n JOIN providers pr ON pr.id = n.provider_id ORDER BY n.id`) if err != nil { return nil, fmt.Errorf("admin.ListNodes: %w", err) } defer rows.Close() var out []NodeRow for rows.Next() { var n NodeRow if err := rows.Scan(&n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Role, &n.Tier, &n.Endpoint, &n.Status, &n.Weight, &n.Provider); err != nil { return nil, fmt.Errorf("admin.ListNodes scan: %w", err) } out = append(out, n) } if err := rows.Err(); err != nil { return nil, err } if eventsPerNode > 0 { for i := range out { ev, err := s.QueryNodeEvents(ctx, out[i].ID, eventsPerNode) if err != nil { return nil, err } out[i].RecentEvents = ev } } return out, nil } // GetNode loads a single node by id. func (s *DBStore) GetNode(ctx context.Context, id int64) (*NodeRow, error) { row := s.db.QueryRowContext(ctx, `SELECT n.id, n.uuid, n.region, n.name_zh, n.name_en, n.role, n.tier, n.endpoint, n.status, n.weight, pr.name FROM nodes n JOIN providers pr ON pr.id = n.provider_id WHERE n.id = ?`, id) var n NodeRow err := row.Scan(&n.ID, &n.UUID, &n.Region, &n.NameZH, &n.NameEN, &n.Role, &n.Tier, &n.Endpoint, &n.Status, &n.Weight, &n.Provider) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("admin.GetNode: %w", err) } return &n, nil } // WriteAudit inserts an audit_log row. func (s *DBStore) WriteAudit(ctx context.Context, actor, action, target, metaJSON string) error { if metaJSON == "" { metaJSON = "null" } _, err := s.db.ExecContext(ctx, `INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`, actor, action, target, metaJSON, time.Now().UTC()) if err != nil { return fmt.Errorf("admin.WriteAudit: %w", err) } return nil } // QueryAudit returns filtered audit_log entries plus the total match count. func (s *DBStore) QueryAudit(ctx context.Context, f AuditFilter) ([]AuditEntry, int, error) { where := []string{"1=1"} args := []any{} if f.Actor != "" { where = append(where, "actor = ?") args = append(args, f.Actor) } if f.Action != "" { where = append(where, "action = ?") args = append(args, f.Action) } if f.Target != "" { where = append(where, "target LIKE ?") args = append(args, "%"+f.Target+"%") } if f.From != nil { where = append(where, "at >= ?") args = append(args, f.From.UTC()) } if f.To != nil { where = append(where, "at <= ?") args = append(args, f.To.UTC()) } clause := strings.Join(where, " AND ") var total int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log WHERE `+clause, args...).Scan(&total); err != nil { return nil, 0, fmt.Errorf("admin.QueryAudit count: %w", err) } limit := f.Limit if limit <= 0 || limit > 500 { limit = 50 } q := `SELECT id, actor, action, target, COALESCE(meta, ''), at FROM audit_log WHERE ` + clause + ` ORDER BY at DESC, id DESC LIMIT ? OFFSET ?` args = append(args, limit, f.Offset) rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, 0, fmt.Errorf("admin.QueryAudit: %w", err) } defer rows.Close() var out []AuditEntry for rows.Next() { var e AuditEntry var meta []byte if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Target, &meta, &e.At); err != nil { return nil, 0, fmt.Errorf("admin.QueryAudit scan: %w", err) } e.Meta = string(meta) out = append(out, e) } return out, total, rows.Err() } // QueryNodeEvents returns the most recent events for a node. func (s *DBStore) QueryNodeEvents(ctx context.Context, nodeID int64, limit int) ([]NodeEvent, error) { if limit <= 0 || limit > 200 { limit = 20 } rows, err := s.db.QueryContext(ctx, `SELECT id, node_id, event, COALESCE(detail, ''), at FROM node_events WHERE node_id = ? ORDER BY at DESC, id DESC LIMIT ?`, nodeID, limit) if err != nil { return nil, fmt.Errorf("admin.QueryNodeEvents: %w", err) } defer rows.Close() var out []NodeEvent for rows.Next() { var e NodeEvent var detail []byte if err := rows.Scan(&e.ID, &e.NodeID, &e.Event, &detail, &e.At); err != nil { return nil, fmt.Errorf("admin.QueryNodeEvents scan: %w", err) } e.Detail = string(detail) out = append(out, e) } return out, rows.Err() }