bro_hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5e8da50149016fb9bbf5775f4257a85855b058372b1077ee0e1f8e24d6ac4811
4
+ data.tar.gz: c609ff0fca64a15b7a3682cdcc74c5a61ff8b944e18c075d0583b6515e0fa1e2
5
+ SHA512:
6
+ metadata.gz: 2ee002d30eff44fa14bbff1261b76c0ac101ea6adf6c756e328abb6d059e9e7971f75912e03db7f1a05e490205250a0396109d9db6742a22ba87a70c9d6fae9a
7
+ data.tar.gz: 8f963753dd73fa6a0ac48e4c59e130ddd5f5d5bbb47e5e73228580c1bef3f48e2067b21ce004498f8dcd494913ae2e82937e6336ad08c276f3d4b7cdf92d059c
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Ruby Dev SRL (https://www.rubydev.ro)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # BroHub
2
+
3
+ One dashboard shell for the Bro gems ([EnvBro](https://github.com/rubydevro/env_bro), [SafeBro](https://github.com/rubydevro/safe_bro), and whatever comes next).
4
+
5
+ - **One mount point.** `/bro_hub/safe_bro`, `/bro_hub/env_bro`, ... BroHub mounts the gems' engines itself.
6
+ - **One look.** Top bar, sidebar, breadcrumb bar and page styles are shared, so every gem feels the same.
7
+ - **One sidebar.** Each gem registers its pages and shows up as a section. A section only appears for people allowed to use it.
8
+ - **One place for "who is signed in".** Set it once for all gems.
9
+
10
+ It is a shell, not a security boundary: hiding a section is navigation. Every Bro gem still authorizes its own pages.
11
+
12
+ Rails 7.1+, Ruby 3.2+. No database, no JavaScript, no asset pipeline.
13
+
14
+ ## Install
15
+
16
+ ```ruby
17
+ gem "bro_hub"
18
+ ```
19
+
20
+ ```sh
21
+ bin/rails generate bro_hub:install
22
+ ```
23
+
24
+ This sets up the whole dashboard in one go:
25
+
26
+ 1. adds `config/initializers/bro_hub.rb`,
27
+ 2. mounts BroHub (it asks where, default `/bro_hub`; or `--mount-path=/ops/bro`):
28
+ ```ruby
29
+ mount BroHub::Engine => "/bro_hub"
30
+ ```
31
+ 3. offers to comment out old direct mounts such as `mount SafeBro::Engine`, since BroHub now mounts those gems itself
32
+ (`--remove-direct-mounts` says yes without asking; without a terminal the lines are left alone with a warning),
33
+ 4. runs the installer of every Bro gem in your Gemfile (SafeBro's initializer and migrations, EnvBro's, ...).
34
+
35
+ You can also run a gem's own installer on its own: `bin/rails generate safe_bro:install` sets up BroHub first, then SafeBro.
36
+ Running both, in any order, mounts BroHub once.
37
+
38
+ ```ruby
39
+ # config/initializers/bro_hub.rb
40
+ BroHub.configure do |config|
41
+ config.current_user = ->(controller) { controller.current_user } # default: calls current_user if present
42
+ config.user_label = ->(user) { user.email }
43
+ config.parent_controller = "::ApplicationController"
44
+ end
45
+ ```
46
+
47
+ Opening `/bro_hub` sends people to the first section they may use.
48
+
49
+ ## For gem authors: plugging in
50
+
51
+ **1. Register your section** (in your engine file, so it runs before routes are drawn):
52
+
53
+ ```ruby
54
+ BroHub.register :safe_bro,
55
+ label: "SafeBro · audit", # sidebar heading
56
+ name: "SafeBro", # breadcrumb (default: label up to the "·")
57
+ accent: :red, # :red :green :violet :amber :blue, or "#hex"
58
+ engine: "SafeBro::Engine", # BroHub mounts it at /<key>
59
+ order: 10, # lower comes first
60
+ visible_to: ->(user) { SafeBro.config.can_audit.call(user) },
61
+ items: [
62
+ { label: "Sessions", path: "/", badge: -> { SafeBro::Session.pending.count } },
63
+ { label: "Flagged commands", path: "/flagged" },
64
+ { label: "Rules", path: "/rules", match: %r{\A/rules} },
65
+ { label: "Raw export", path: "/export", visible_to: ->(user) { user.admin? } } # per-item visibility
66
+ ]
67
+ ```
68
+
69
+ - `path` is relative to your engine. Badges are hidden when `nil` or `0`, and a badge that raises is skipped.
70
+ - Active item: exact match for `"/"`, prefix match otherwise, or your own `match:` regexp.
71
+ - A section only shows up in the shared sidebar when you pass `engine:` (BroHub then knows where it lives). A gem the host mounts elsewhere still gets the shell, with just its own section.
72
+ - Never put anything that came from a user in `accent` or `label`. `accent` is validated; `label` is escaped.
73
+
74
+ **2. Render inside the shell:**
75
+
76
+ ```ruby
77
+ class SafeBro::ApplicationController < ::ApplicationController
78
+ include BroHub::Layout
79
+ bro_hub_section :safe_bro
80
+ end
81
+ ```
82
+
83
+ `BroHub::Layout` sets the layout and adds `bro_hub_user`. Override it if your gem has its own idea of who is signed in.
84
+
85
+ **3. Give your gem an installer** that sets up BroHub first, then itself. Name it `<section key>:install` so
86
+ `bro_hub:install` finds it:
87
+
88
+ ```ruby
89
+ # lib/generators/safe_bro/install/install_generator.rb
90
+ require "generators/bro_hub/installable"
91
+
92
+ class SafeBro::Generators::InstallGenerator < Rails::Generators::Base
93
+ include BroHub::Generators::Installable # keep this first: it adds the BroHub step and --mount-path
94
+
95
+ def copy_initializer ... end
96
+ def next_steps
97
+ say "Visit #{bro_hub_path}/safe_bro" # bro_hub_path: where BroHub ended up
98
+ end
99
+ end
100
+ ```
101
+
102
+ **4. Fill in the page:**
103
+
104
+ ```erb
105
+ <% bro_hub_crumb "Console sessions", "#5" %> <%# "SafeBro / Console sessions / #5" %>
106
+ <% content_for :bro_hub_crumb_actions do %>
107
+ <%= bro_hub_crumb_link "← All sessions", sessions_path %> <%# right side of the bar %>
108
+ <% end %>
109
+
110
+ <%= bro_hub_hero eyebrow: "console · web-1 (pid 4417)", title: "Session #5", subtitle: "Pending",
111
+ subtitle_tone: :green, # :red :green :amber :violet :muted
112
+ lead: "jane was on a production console for 1m.",
113
+ stats: [ [ 4, "unlocks", :amber ], [ 6, "flagged commands", :red ] ] %>
114
+ ```
115
+
116
+ Page styles are plain CSS classes prefixed `bh-` (see `lib/bro_hub/styles.css`): `bh-block`, `bh-split`, `bh-table`/`bh-row`, `bh-tag`, `bh-filters`, `bh-input`, `bh-btn`, `bh-seg`, `bh-log`/`bh-cmd`, `bh-dl`, `bh-pager`, and so on. `bh-tag` is uppercase; add `is-code` for case-sensitive text such as method names. Tables take their columns from `style="--cols: 150px 1fr 80px"`.
117
+
118
+ ## Notes
119
+
120
+ - The layout loads IBM Plex Mono and Archivo from Google Fonts, and embeds the skull as a `data:` image. Under a strict Content Security Policy allow `fonts.googleapis.com`, `fonts.gstatic.com` and `img-src data:`. Without the fonts it falls back to system fonts. The inline `<style>` uses your CSP nonce when you have one.
121
+ - The shell's own pages send `Cache-Control: no-store` and refuse plain HTTP in production (`config.require_ssl`).
122
+ - The look is dark only.
123
+
124
+ ## Development
125
+
126
+ ```sh
127
+ bin/rails test # Rails 8.x
128
+ BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rails test # Rails 7.1
129
+ bin/rubocop
130
+ cd test/dummy && bin/rails server # try it: /hub (Alpha, Beta), /solo (Gamma)
131
+ ```
132
+
133
+ The dummy app has three fake Bro gems (`test/support/fake_bros`). Alpha and Beta live in the hub, Gamma is mounted on its own.
134
+
135
+ ## License
136
+
137
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,20 @@
1
+ module BroHub
2
+ class ApplicationController < BroHub.config.parent_controller.constantize
3
+ include BroHub::Layout
4
+
5
+ protect_from_forgery with: :exception
6
+
7
+ before_action :bro_hub_require_ssl
8
+ before_action :bro_hub_no_store
9
+
10
+ private
11
+ def bro_hub_require_ssl
12
+ head :forbidden if BroHub.config.require_ssl && !request.ssl?
13
+ end
14
+
15
+ def bro_hub_no_store
16
+ response.headers["Cache-Control"] = "no-store"
17
+ response.headers["Pragma"] = "no-cache"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,12 @@
1
+ module BroHub
2
+ class HomeController < ApplicationController
3
+ # The hub itself has no page: send people to the first section they may use.
4
+ def index
5
+ section = BroHub.registry.visible_to(bro_hub_user).first
6
+ return render("bro_hub/shared/forbidden", status: :forbidden) unless section
7
+
8
+ path = section.first_path(bro_hub_user).to_s
9
+ redirect_to "#{request.script_name.delete_suffix("/")}/#{section.key}#{path == "/" ? "" : "/" + path.delete_prefix("/")}"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,82 @@
1
+ module BroHub
2
+ module ApplicationHelper
3
+ # The mount point of the hub, without a trailing slash ("" when mounted at "/"), or nil when this page is
4
+ # not served through the hub (a Bro gem the host mounted on its own). The hub records its mount point in the
5
+ # request as it hands over to an engine, so a gem mounted at a path that merely looks like "<hub>/<key>"
6
+ # is not mistaken for one.
7
+ def bro_hub_root
8
+ return @bro_hub_root if defined?(@bro_hub_root)
9
+
10
+ @bro_hub_root =
11
+ if request.env["bro_hub.root"] then request.env["bro_hub.root"]
12
+ elsif controller.try(:bro_hub_section_key).nil? then request.script_name.to_s.delete_suffix("/")
13
+ end
14
+ end
15
+
16
+ # The sidebar: [ [section, base_path, current?], ... ] for what this person may see.
17
+ def bro_hub_sections
18
+ @bro_hub_sections ||= begin
19
+ user = bro_hub_user
20
+ current_key = controller.try(:bro_hub_section_key)
21
+ sections = BroHub.registry.visible_to(user)
22
+ # We can only link to what lives under the hub (it mounts the engines) or to the page we are on.
23
+ # A section whose engine the host mounted elsewhere shows up inside its own pages only.
24
+ sections = sections.select { |section| section.key == current_key || (bro_hub_root && section.engine) }
25
+ sections.map do |section|
26
+ base = bro_hub_root ? "#{bro_hub_root}/#{section.key}" : request.script_name.to_s.delete_suffix("/")
27
+ [ section, base, section.key == current_key ]
28
+ end
29
+ end
30
+ end
31
+
32
+ def bro_hub_current_section
33
+ BroHub.registry[controller.try(:bro_hub_section_key)]
34
+ end
35
+
36
+ def bro_hub_link_path(base, item)
37
+ path = "#{base}#{item.path.to_s == "/" ? "" : "/" + item.path.to_s.delete_prefix("/")}"
38
+ path.presence || "/"
39
+ end
40
+
41
+ def bro_hub_item_active?(section_current, base, item)
42
+ return false unless section_current
43
+
44
+ item.active?(request.path.delete_prefix(base))
45
+ end
46
+
47
+ def bro_hub_user_label
48
+ user = bro_hub_user
49
+ BroHub.config.user_label.call(user) if user
50
+ end
51
+
52
+ def bro_hub_page_title
53
+ parts = [ content_for(:bro_hub_title).presence, bro_hub_current_section&.name, "Bro Hub" ].compact
54
+ parts.uniq.join(" · ")
55
+ end
56
+
57
+ # Breadcrumb after the section name: bro_hub_crumb "Console sessions", "#5"
58
+ def bro_hub_crumb(*parts)
59
+ content_for(:bro_hub_crumb, parts.join(" / "), flush: true)
60
+ content_for(:bro_hub_title, parts.last.to_s, flush: true)
61
+ nil
62
+ end
63
+
64
+ # Links on the right of the breadcrumb bar (filters, "back", environment tabs).
65
+ def bro_hub_crumb_link(label, href, active: false)
66
+ link_to label, href, class: [ "bh-tab", ("is-active" if active) ]
67
+ end
68
+
69
+ # The big page heading.
70
+ # bro_hub_hero eyebrow: "Console sessions · production", title: "3", subtitle: "awaiting a verdict",
71
+ # tone: :red, subtitle_tone: :green, lead: "Who used a production console...", stats: [ [ 4, "unlocks", :amber ] ]
72
+ def bro_hub_hero(eyebrow: nil, title:, subtitle: nil, tone: nil, subtitle_tone: nil, lead: nil, stats: [], &block)
73
+ render "bro_hub/shared/hero", eyebrow: eyebrow, title: title, subtitle: subtitle, tone: tone, subtitle_tone: subtitle_tone, lead: lead,
74
+ stats: stats, extra: (capture(&block) if block)
75
+ end
76
+
77
+ # Inline <style> that works under a nonce-based Content Security Policy.
78
+ def bro_hub_style_tag
79
+ tag.style(BroHub.styles.html_safe, nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce)))
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,21 @@
1
+ <header class="bh-hero">
2
+ <div class="bh-hero-main">
3
+ <% if eyebrow %><div class="bh-eyebrow"><%= eyebrow %></div><% end %>
4
+ <h1 class="bh-title">
5
+ <span<%= %( class="bh-tone-#{tone}").html_safe if tone %>><%= title %></span>
6
+ <% if subtitle %><span class="bh-title-sub<%= " bh-tone-#{subtitle_tone}" if subtitle_tone %>"><%= subtitle %></span><% end %>
7
+ </h1>
8
+ <% if lead %><p class="bh-lead"><%= lead %></p><% end %>
9
+ <%= extra %>
10
+ </div>
11
+ <% if stats.any? %>
12
+ <div class="bh-stats">
13
+ <% stats.each do |value, label, stat_tone| %>
14
+ <div class="bh-stat">
15
+ <div class="bh-stat-value<%= " bh-tone-#{stat_tone}" if stat_tone %>"><%= value %></div>
16
+ <div class="bh-stat-label"><%= label %></div>
17
+ </div>
18
+ <% end %>
19
+ </div>
20
+ <% end %>
21
+ </header>
@@ -0,0 +1,3 @@
1
+ <% bro_hub_crumb "Not allowed" %>
2
+ <%= bro_hub_hero eyebrow: "Access", title: "Nothing here", subtitle: "for you", tone: :red,
3
+ lead: "You can't see any Bro section. Ask whoever runs this app to give you access." %>
@@ -0,0 +1,58 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="robots" content="noindex, nofollow">
7
+ <title><%= bro_hub_page_title %></title>
8
+ <%= csrf_meta_tags %>
9
+ <%= csp_meta_tag %>
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&amp;family=Archivo:wdth,wght@75..125,400..800&amp;display=swap" rel="stylesheet">
12
+ <%= bro_hub_style_tag %>
13
+ <%= yield :head %>
14
+ </head>
15
+ <body class="bh">
16
+ <div class="bh-shell">
17
+ <div class="bh-top">
18
+ <span class="bh-brand">
19
+ <img src="<%= BroHub.skull_data_uri %>" alt="" width="26" height="26">
20
+ <span>powered by <%= link_to BroHub.config.brand_name, BroHub.config.brand_url, target: "_blank", rel: "noopener" %></span>
21
+ </span>
22
+ <% if bro_hub_user_label.present? %><span class="bh-user"><%= bro_hub_user_label %></span><% end %>
23
+ </div>
24
+
25
+ <div class="bh-body">
26
+ <% sections = bro_hub_sections %>
27
+ <% if sections.any? %>
28
+ <nav class="bh-side" aria-label="Bro sections">
29
+ <% sections.each do |section, base, current| %>
30
+ <div class="bh-group" style="--accent: <%= section.accent %>">
31
+ <span class="bh-group-label"><%= section.label %></span>
32
+ <% section.visible_items(bro_hub_user).each do |item| %>
33
+ <% badge = item.badge_value %>
34
+ <%= link_to bro_hub_link_path(base, item), class: [ "bh-nav", ("is-active" if bro_hub_item_active?(current, base, item)) ] do %>
35
+ <span><%= item.label %></span>
36
+ <% if badge %><span class="bh-badge"><%= badge %></span><% end %>
37
+ <% end %>
38
+ <% end %>
39
+ </div>
40
+ <% end %>
41
+ </nav>
42
+ <% end %>
43
+
44
+ <main class="bh-main">
45
+ <div class="bh-crumb">
46
+ <span>
47
+ <%= bro_hub_current_section&.name || "Bro Hub" %><% if content_for?(:bro_hub_crumb) %> / <%= yield :bro_hub_crumb %><% end %>
48
+ </span>
49
+ <% if content_for?(:bro_hub_crumb_actions) %><span class="bh-crumb-actions"><%= yield :bro_hub_crumb_actions %></span><% end %>
50
+ </div>
51
+ <% if notice %><div class="bh-alert bh-alert-ok"><%= notice %></div><% end %>
52
+ <% if alert %><div class="bh-alert bh-alert-error"><%= alert %></div><% end %>
53
+ <%= yield %>
54
+ </main>
55
+ </div>
56
+ </div>
57
+ </body>
58
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ BroHub::Engine.routes.draw do
2
+ root "home#index"
3
+
4
+ # Every registered engine lives under the hub: /<mount>/<section key>/...
5
+ BroHub.registry.each do |section|
6
+ mount section.engine => "/#{section.key}", as: section.key if section.engine
7
+ end
8
+ end
@@ -0,0 +1,26 @@
1
+ module BroHub
2
+ class Configuration
3
+ # Controller the shell's own pages inherit from, so the host's authentication runs.
4
+ attr_accessor :parent_controller
5
+
6
+ # ->(controller) { the signed-in user, or nil }. The default calls `current_user` when the controller has it.
7
+ attr_accessor :current_user
8
+
9
+ # ->(user) { text shown at the top right }
10
+ attr_accessor :user_label
11
+
12
+ # Refuse the shell's own pages over plain HTTP. Each Bro gem decides this for its own pages.
13
+ attr_accessor :require_ssl
14
+
15
+ attr_accessor :brand_name, :brand_url
16
+
17
+ def initialize
18
+ @parent_controller = "::ApplicationController"
19
+ @current_user = ->(controller) { controller.send(:current_user) if controller.respond_to?(:current_user, true) }
20
+ @user_label = ->(user) { user.try(:email) || user.try(:name) || user.to_s }
21
+ @require_ssl = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.production? : false
22
+ @brand_name = "DeadBro"
23
+ @brand_url = "https://www.deadbro.com"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,7 @@
1
+ module BroHub
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace BroHub
4
+
5
+ middleware.use BroHub::RootMiddleware
6
+ end
7
+ end
@@ -0,0 +1,31 @@
1
+ module BroHub
2
+ # Include in a Bro gem's controller to render inside the shared shell:
3
+ #
4
+ # class SafeBro::ApplicationController < ...
5
+ # include BroHub::Layout
6
+ # bro_hub_section :safe_bro
7
+ module Layout
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :bro_hub_section_key, instance_writer: false
12
+ layout "bro_hub/application"
13
+ helper BroHub::ApplicationHelper
14
+ helper_method :bro_hub_user, :bro_hub_section_key
15
+ end
16
+
17
+ class_methods do
18
+ def bro_hub_section(key)
19
+ self.bro_hub_section_key = key.to_sym
20
+ end
21
+ end
22
+
23
+ private
24
+ # Override when the gem has its own idea of who is signed in.
25
+ def bro_hub_user
26
+ return @bro_hub_user if defined?(@bro_hub_user)
27
+
28
+ @bro_hub_user = BroHub.config.current_user.call(self)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,34 @@
1
+ module BroHub
2
+ class Registry
3
+ include Enumerable
4
+
5
+ def initialize
6
+ @sections = {}
7
+ end
8
+
9
+ def register(section)
10
+ @sections[section.key] = section
11
+ end
12
+
13
+ def [](key)
14
+ @sections[key&.to_sym]
15
+ end
16
+
17
+ def each(&block)
18
+ sorted.each(&block)
19
+ end
20
+
21
+ def visible_to(user)
22
+ select { |section| section.visible_to?(user) }
23
+ end
24
+
25
+ def clear
26
+ @sections.clear
27
+ end
28
+
29
+ private
30
+ def sorted
31
+ @sections.values.each_with_index.sort_by { |section, index| [ section.order, index ] }.map(&:first)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ module BroHub
2
+ # Runs as the hub's engine is called, when SCRIPT_NAME is still the hub's own mount point.
3
+ # Engines mounted under the hub (and the shell's layout) read it from here.
4
+ class RootMiddleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ env["bro_hub.root"] ||= env["SCRIPT_NAME"].to_s.delete_suffix("/")
11
+ response = @app.call(env)
12
+ # Nothing here matched and the host router will try its next route: this was not a hub request after all.
13
+ env.delete("bro_hub.root") if response[1]["X-Cascade"] == "pass"
14
+ response
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,92 @@
1
+ module BroHub
2
+ # One block of the sidebar, owned by one gem.
3
+ class Section
4
+ ACCENTS = {
5
+ red: "#f04f54", green: "#2ecc7a", violet: "#7c6dfa", amber: "#f5b84a", blue: "#4aa8f5"
6
+ }.freeze
7
+
8
+ Item = Struct.new(:label, :path, :badge, :match, :visible_to, keyword_init: true) do
9
+ def visible_to?(user)
10
+ visible_to ? !!visible_to.call(user) : true
11
+ end
12
+
13
+ # The badge is computed on every page: a broken one must never take the shell down.
14
+ def badge_value
15
+ value = badge.respond_to?(:call) ? badge.call : badge
16
+ value.presence if value.to_s != "0"
17
+ rescue StandardError => error
18
+ Rails.logger.warn("BroHub: badge for #{label.inspect} failed: #{error.class}: #{error.message}") if defined?(Rails)
19
+ nil
20
+ end
21
+
22
+ # path is relative to the section, so "/flagged" and "" are both fine.
23
+ def active?(relative_path)
24
+ relative_path = normalize(relative_path)
25
+ if match
26
+ match.match?(relative_path)
27
+ elsif normalize(path) == "/"
28
+ relative_path == "/"
29
+ else
30
+ relative_path == normalize(path) || relative_path.start_with?("#{normalize(path)}/")
31
+ end
32
+ end
33
+
34
+ private
35
+ def normalize(value)
36
+ "/" + value.to_s.delete_prefix("/").delete_suffix("/")
37
+ end
38
+ end
39
+
40
+ attr_reader :key, :name, :label, :accent, :items, :order
41
+
42
+ # key: :safe_bro. Also the URL segment the engine is mounted under.
43
+ # name: short name for the breadcrumb ("SafeBro"). Defaults to the label up to the first "·".
44
+ # label: sidebar heading ("SafeBro · audit").
45
+ # accent: :red, :green, :violet, :amber, :blue, or a hex colour.
46
+ # engine: the Rails engine (class or class name) BroHub mounts under /<key>. Leave out if the host mounts it.
47
+ # items: [ { label:, path:, badge: (callable), match: (Regexp on the section-relative path),
48
+ # visible_to: ->(user) { true } } ]
49
+ # visible_to: ->(user) { true } decides whether this person sees the section. Navigation only, not security:
50
+ # every engine still authorizes its own pages.
51
+ # order: lower comes first.
52
+ def initialize(key, label:, items:, name: nil, accent: :violet, engine: nil, visible_to: nil, order: 100)
53
+ @key = key.to_sym
54
+ @label = label.to_s
55
+ @name = (name || @label.split("·").first).to_s.strip
56
+ @accent = resolve_accent(accent)
57
+ @engine = engine
58
+ @visible_to = visible_to
59
+ @order = order
60
+ @items = Array(items).map { |item| Item.new(**item.symbolize_keys.slice(:label, :path, :badge, :match, :visible_to)) }
61
+ raise ArgumentError, "BroHub section #{key} needs at least one item" if @items.empty?
62
+ end
63
+
64
+ def engine
65
+ @engine.is_a?(String) ? @engine.constantize : @engine
66
+ end
67
+
68
+ def visible_to?(user)
69
+ return false unless user
70
+
71
+ @visible_to ? !!@visible_to.call(user) : true
72
+ end
73
+
74
+ def visible_items(user)
75
+ items.select { |item| item.visible_to?(user) }
76
+ end
77
+
78
+ # Where to send someone who opens the section: its first item they may see.
79
+ def first_path(user = nil)
80
+ (user ? visible_items(user).first : items.first)&.path || items.first.path
81
+ end
82
+
83
+ private
84
+ def resolve_accent(accent)
85
+ return ACCENTS.fetch(accent) { raise ArgumentError, "unknown accent #{accent.inspect}, use one of #{ACCENTS.keys.inspect}" } if accent.is_a?(Symbol)
86
+ # The colour ends up in a style attribute, so only a plain hex colour is accepted.
87
+ return accent if accent.to_s.match?(/\A#\h{3,8}\z/)
88
+
89
+ raise ArgumentError, "accent must be one of #{ACCENTS.keys.inspect} or a hex colour like #7c6dfa"
90
+ end
91
+ end
92
+ end
Binary file
@@ -0,0 +1,139 @@
1
+ :root {
2
+ --bh-bg: #0c1020; --bh-panel: #0a0e1c; --bh-line: #1c2238; --bh-line-2: #2a3150; --bh-hover: #111630;
3
+ --bh-text: #dde4f5; --bh-muted: #8a90a8; --bh-soft: #b0bcd4;
4
+ --bh-violet: #7c6dfa; --bh-violet-l: #a78bfa; --bh-red: #f04f54; --bh-green: #2ecc7a; --bh-amber: #f5b84a; --bh-blue: #4aa8f5;
5
+ --bh-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
6
+ --bh-sans: "Archivo", system-ui, -apple-system, "Segoe UI", sans-serif;
7
+ --bh-pad: clamp(20px, 3vw, 40px);
8
+ }
9
+ * { box-sizing: border-box; }
10
+ html, body.bh { margin: 0; background: var(--bh-bg); }
11
+ body.bh { color: var(--bh-text); font: 400 14px/1.4 var(--bh-sans); }
12
+ .bh a { color: var(--bh-violet-l); }
13
+ .bh a:hover { color: #c4b5fd; }
14
+ .bh input::placeholder, .bh textarea::placeholder { color: var(--bh-muted); }
15
+
16
+ /* Shell */
17
+ .bh-shell { display: flex; flex-direction: column; min-height: 100vh; }
18
+ .bh-top { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 24px; padding: 10px 22px; background: var(--bh-panel); border-bottom: 1px solid var(--bh-line); font: 500 13px var(--bh-mono); }
19
+ .bh-brand { display: flex; align-items: center; gap: 10px; color: var(--bh-muted); }
20
+ .bh-brand a { color: var(--bh-violet-l); text-decoration: none; font-weight: 600; }
21
+ .bh-user { color: var(--bh-text); }
22
+ .bh-body { display: flex; flex: 1; min-height: 0; }
23
+ .bh-side { flex: none; width: 216px; background: var(--bh-panel); border-right: 1px solid var(--bh-line); padding: 22px 0; display: flex; flex-direction: column; gap: 24px; }
24
+ .bh-group { display: flex; flex-direction: column; }
25
+ .bh-group-label { padding: 0 22px 8px; font: 600 10px var(--bh-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--accent, var(--bh-violet)); }
26
+ .bh-nav { display: flex; justify-content: space-between; align-items: center; padding: 7px 22px 7px 18px; border-left: 4px solid transparent; color: var(--bh-soft); text-decoration: none; font-weight: 500; }
27
+ .bh .bh-nav { color: var(--bh-soft); }
28
+ .bh .bh-nav:hover { color: var(--bh-text); }
29
+ .bh .bh-nav.is-active { border-left-color: var(--bh-violet); color: var(--bh-text); font-weight: 700; }
30
+ .bh-badge { font: 600 11px var(--bh-mono); background: var(--accent, var(--bh-red)); color: var(--bh-bg); padding: 1px 6px; }
31
+ .bh-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
32
+ .bh-crumb { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 24px; padding: 12px var(--bh-pad); background: var(--bh-violet); color: var(--bh-bg); font: 600 13px var(--bh-mono); }
33
+ .bh-crumb-actions { display: flex; flex-wrap: wrap; gap: 16px; }
34
+ .bh .bh-tab { color: var(--bh-bg); text-decoration: none; border-bottom: 2px solid transparent; }
35
+ .bh .bh-tab:hover, .bh .bh-tab.is-active { color: var(--bh-bg); border-bottom-color: var(--bh-bg); }
36
+ .bh-alert { margin: 16px var(--bh-pad) 0; padding: 10px 14px; font: 500 13px var(--bh-mono); border: 1px solid; }
37
+ .bh-alert-ok { color: var(--bh-green); border-color: var(--bh-green); }
38
+ .bh-alert-error { color: var(--bh-red); border-color: var(--bh-red); }
39
+
40
+ /* Page heading */
41
+ .bh-hero { padding: clamp(28px, 3.5vw, 44px) var(--bh-pad) clamp(22px, 3vw, 36px); border-bottom: 2px solid var(--bh-text); display: flex; flex-wrap: wrap; gap: 24px 56px; align-items: flex-end; justify-content: space-between; }
42
+ .bh-hero-main { min-width: 0; }
43
+ .bh-eyebrow { font: 500 13px var(--bh-mono); color: var(--bh-muted); }
44
+ .bh-title { margin: 10px 0 0; font: 800 clamp(60px, 9.5vw, 132px)/.9 var(--bh-sans); font-stretch: 80%; letter-spacing: -.03em; text-transform: uppercase; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 20px; }
45
+ .bh-title-sub { font-size: .5em; letter-spacing: -.01em; }
46
+ .bh-lead { margin: 16px 0 0; font-size: 17px; color: var(--bh-muted); max-width: 720px; text-wrap: pretty; }
47
+ .bh-lead strong { color: var(--bh-text); }
48
+ .bh-stats { display: flex; gap: 36px; }
49
+ .bh-stat-value { font: 800 48px/1 var(--bh-sans); font-stretch: 80%; }
50
+ .bh-stat-label { margin-top: 6px; font: 500 12px var(--bh-mono); color: var(--bh-muted); text-transform: uppercase; letter-spacing: .08em; }
51
+ .bh-tone-red { color: var(--bh-red); } .bh-tone-green { color: var(--bh-green); } .bh-tone-amber { color: var(--bh-amber); }
52
+ .bh-tone-violet { color: var(--bh-violet-l); } .bh-tone-muted { color: var(--bh-muted); }
53
+
54
+ /* Blocks */
55
+ .bh-block { padding: 32px var(--bh-pad) 36px; border-bottom: 1px solid var(--bh-line); }
56
+ .bh-block-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: baseline; gap: 8px 16px; margin-bottom: 18px; }
57
+ .bh-h2 { margin: 0 0 16px; font: 800 22px var(--bh-sans); font-stretch: 85%; text-transform: uppercase; }
58
+ .bh-block-head .bh-h2 { margin: 0; }
59
+ .bh-meta { font: 400 12px var(--bh-mono); color: var(--bh-muted); }
60
+ .bh-split { display: flex; flex-wrap: wrap; flex: 1; }
61
+ .bh-split-main { flex: 1 1 520px; min-width: 0; padding: 32px var(--bh-pad) 36px; border-right: 1px solid var(--bh-line); }
62
+ .bh-split-aside { flex: 1 1 320px; max-width: 100%; min-width: 0; display: flex; flex-direction: column; }
63
+ .bh-split-aside .bh-block:last-child { flex: 1; border-bottom: 0; }
64
+ .bh-dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 20px; font: 400 13px var(--bh-mono); margin: 0; }
65
+ .bh-dl dt { color: var(--bh-muted); } .bh-dl dd { margin: 0; overflow-wrap: anywhere; }
66
+ .bh-empty { padding: 48px 0; font: 800 40px/1 var(--bh-sans); font-stretch: 80%; text-transform: uppercase; color: var(--bh-muted); }
67
+
68
+ /* Filters and form controls */
69
+ .bh-filters { padding: 24px var(--bh-pad); border-bottom: 1px solid var(--bh-line); display: flex; flex-wrap: wrap; gap: 14px 24px; align-items: flex-end; }
70
+ .bh-field { display: flex; flex-direction: column; gap: 2px; font: 500 10px var(--bh-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--bh-muted); }
71
+ .bh-input, .bh-textarea, .bh-select { background: transparent; border: 0; border-bottom: 2px solid var(--bh-line-2); color: var(--bh-text); font: 500 14px var(--bh-mono); padding: 8px 0; outline: none; color-scheme: dark; min-width: 0; }
72
+ .bh-input.is-strong { border-bottom-color: var(--bh-text); padding: 10px 0; flex: 1 1 220px; }
73
+ .bh-input:focus, .bh-textarea:focus, .bh-select:focus { border-bottom-color: var(--bh-violet); }
74
+ .bh-select option { background: var(--bh-bg); }
75
+ .bh-textarea { display: block; width: 100%; margin-top: 6px; background: var(--bh-panel); border-bottom-color: var(--bh-text); font: 400 14px/1.5 var(--bh-mono); letter-spacing: 0; text-transform: none; padding: 10px; resize: vertical; }
76
+ .bh-hint { margin-top: 8px; font: 400 12px var(--bh-mono); color: var(--bh-muted); }
77
+ .bh-hint.is-error { color: var(--bh-red); }
78
+ .bh .bh-chip { cursor: pointer; padding: 9px 12px; font: 600 12px var(--bh-mono); border: 1px solid var(--bh-line-2); background: transparent; color: var(--bh-text); text-decoration: none; }
79
+ .bh .bh-chip.is-on { border-color: var(--bh-red); background: var(--bh-red); color: var(--bh-bg); }
80
+ .bh .bh-link-quiet { font: 600 12px var(--bh-mono); color: var(--bh-muted); border-bottom: 1px solid var(--bh-muted); text-decoration: none; margin-bottom: 10px; }
81
+ .bh .bh-link-quiet:hover { color: var(--bh-text); }
82
+ .bh-btn { display: block; width: 100%; padding: 14px; background: var(--bh-violet); color: var(--bh-bg); border: 0; cursor: pointer; font: 800 18px var(--bh-sans); font-stretch: 85%; text-transform: uppercase; text-align: center; text-decoration: none; }
83
+ .bh .bh-btn { color: var(--bh-bg); }
84
+ .bh-btn:hover { background: var(--bh-violet-l); }
85
+ .bh-btn.is-green { background: var(--bh-green); } .bh-btn.is-green:hover { background: #5fe09b; }
86
+ .bh-btn.is-auto { display: inline-block; width: auto; padding: 14px 22px; }
87
+ .bh-btn:disabled { background: var(--bh-line); color: var(--bh-muted); cursor: not-allowed; }
88
+ .bh-seg { display: flex; gap: 8px; }
89
+ .bh-seg label { flex: 1; }
90
+ .bh-seg input { position: absolute; opacity: 0; pointer-events: none; }
91
+ .bh-seg span { display: block; text-align: center; cursor: pointer; padding: 12px; font: 700 16px var(--bh-sans); font-stretch: 85%; text-transform: uppercase; border: 2px solid var(--bh-line-2); }
92
+ .bh-seg input:checked + span { color: var(--bh-bg); }
93
+ .bh-seg .is-ok input:checked + span { background: var(--bh-green); border-color: var(--bh-green); }
94
+ .bh-seg .is-bad input:checked + span { background: var(--bh-red); border-color: var(--bh-red); }
95
+ .bh-seg input:focus-visible + span { outline: 2px solid var(--bh-violet-l); outline-offset: 2px; }
96
+ .bh-sticky { position: sticky; bottom: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; padding: 14px var(--bh-pad); background: var(--bh-panel); border-top: 2px solid var(--bh-text); }
97
+
98
+ /* Tables: set --cols on .bh-table (grid-template-columns) and --min for the scroll width */
99
+ .bh-table-wrap { padding: 0 var(--bh-pad) 36px; overflow-x: auto; flex: 1; }
100
+ .bh-table { min-width: var(--min, 640px); }
101
+ .bh-row { display: grid; grid-template-columns: var(--cols); gap: 16px; align-items: center; padding: 14px 0 14px 10px; border-bottom: 1px solid var(--bh-line); border-left: 4px solid transparent; font: 400 13px var(--bh-mono); color: inherit; text-decoration: none; }
102
+ .bh-row.is-head { padding: 18px 0 10px 14px; border-bottom: 2px solid var(--bh-text); border-left: 0; font: 700 13px var(--bh-sans); font-stretch: 85%; text-transform: uppercase; letter-spacing: .04em; color: var(--bh-muted); }
103
+ .bh a.bh-row, .bh a.bh-row:hover { color: inherit; }
104
+ a.bh-row:hover { background: var(--bh-hover); }
105
+ .bh-edge-red { border-left-color: var(--bh-red); } .bh-edge-amber { border-left-color: var(--bh-amber); } .bh-edge-violet { border-left-color: var(--bh-violet); }
106
+ .bh-cell-strong { font: 700 16px var(--bh-sans); font-stretch: 85%; }
107
+ .bh-cell-text { font: 500 16px var(--bh-sans); font-stretch: 90%; text-wrap: pretty; }
108
+ .bh-cell-muted { color: var(--bh-muted); }
109
+ .bh-tags { display: flex; gap: 6px; flex-wrap: wrap; }
110
+ .bh-tag { padding: 3px 8px; border: 1px solid var(--bh-muted); color: var(--bh-muted); font: 600 12px var(--bh-mono); text-transform: uppercase; white-space: nowrap; }
111
+ .bh-tag.is-amber { border-color: var(--bh-amber); color: var(--bh-amber); }
112
+ .bh-tag.is-red { border-color: var(--bh-red); background: var(--bh-red); color: var(--bh-bg); }
113
+ .bh-tag.is-green { border-color: var(--bh-green); color: var(--bh-green); }
114
+ .bh-tag.is-code { text-transform: none; }
115
+ .bh-status { font-weight: 600; text-transform: uppercase; }
116
+
117
+ /* Paging: .bh-pager with a .bh-meta count and prev/next links */
118
+ .bh-pager { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px; margin-top: 14px; font: 400 12px var(--bh-mono); color: var(--bh-muted); }
119
+
120
+ /* A command in a session log */
121
+ .bh-log { border-top: 2px solid var(--bh-text); }
122
+ .bh-cmd { padding: 14px 16px 14px 12px; border-bottom: 1px solid var(--bh-line); border-left: 4px solid transparent; }
123
+ .bh-cmd.is-flagged { border-left-color: var(--bh-red); background: rgba(240, 79, 84, .12); }
124
+ .bh-cmd.is-unlocked { border-left-color: var(--bh-amber); }
125
+ .bh-cmd-meta { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; font: 400 12px var(--bh-mono); color: var(--bh-muted); }
126
+ .bh-cmd-meta .bh-note { color: var(--bh-text); flex: 1 1 200px; text-wrap: pretty; }
127
+ .bh-cmd-code { margin: 6px 0 0; font: 500 16px var(--bh-mono); overflow-wrap: anywhere; white-space: pre-wrap; }
128
+ .bh-flag { padding: 1px 6px; background: var(--bh-red); color: var(--bh-bg); font-weight: 600; text-transform: uppercase; }
129
+ .bh-unlock { padding: 10px 16px; background: var(--bh-amber); color: var(--bh-bg); font: 600 13px var(--bh-mono); text-wrap: pretty; }
130
+
131
+ @media (max-width: 760px) {
132
+ .bh-body { flex-direction: column; }
133
+ .bh-side { width: auto; flex-direction: row; flex-wrap: wrap; gap: 12px 28px; padding: 14px 0; border-right: 0; border-bottom: 1px solid var(--bh-line); }
134
+ .bh-group { flex-direction: row; flex-wrap: wrap; align-items: center; }
135
+ .bh-group-label { padding: 0 18px 0 22px; }
136
+ .bh-nav { padding: 6px 10px; border-left: 0; border-bottom: 3px solid transparent; }
137
+ .bh .bh-nav.is-active { border-left-color: transparent; border-bottom-color: var(--bh-violet); }
138
+ .bh-split-main { border-right: 0; }
139
+ }
@@ -0,0 +1,3 @@
1
+ module BroHub
2
+ VERSION = "0.1.0"
3
+ end
data/lib/bro_hub.rb ADDED
@@ -0,0 +1,45 @@
1
+ require "bro_hub/version"
2
+ require "bro_hub/configuration"
3
+ require "bro_hub/section"
4
+ require "bro_hub/registry"
5
+ require "bro_hub/layout"
6
+ require "bro_hub/root_middleware"
7
+ require "bro_hub/engine"
8
+
9
+ # The shared admin shell for the Bro gems.
10
+ #
11
+ # BroHub.register :safe_bro, name: "SafeBro", label: "SafeBro · audit", accent: :red,
12
+ # engine: "SafeBro::Engine",
13
+ # items: [ { label: "Sessions", path: "/", badge: -> { SafeBro::Session.pending.count } } ],
14
+ # visible_to: ->(user) { SafeBro.config.can_audit.call(user) }
15
+ module BroHub
16
+ SKULL_PATH = File.expand_path("bro_hub/skull.png", __dir__)
17
+ STYLES_PATH = File.expand_path("bro_hub/styles.css", __dir__)
18
+
19
+ class << self
20
+ def config
21
+ @config ||= Configuration.new
22
+ end
23
+
24
+ def configure
25
+ yield config
26
+ end
27
+
28
+ def registry
29
+ @registry ||= Registry.new
30
+ end
31
+
32
+ # Adds (or replaces) a section in the sidebar. See Section for the options.
33
+ def register(key, **options)
34
+ registry.register(Section.new(key, **options))
35
+ end
36
+
37
+ def skull_data_uri
38
+ @skull_data_uri ||= "data:image/png;base64,#{[ File.binread(SKULL_PATH) ].pack("m0")}"
39
+ end
40
+
41
+ def styles
42
+ @styles ||= File.read(STYLES_PATH)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,107 @@
1
+ require "rails/generators"
2
+
3
+ module BroHub
4
+ module Generators
5
+ # bin/rails generate bro_hub:install
6
+ #
7
+ # Mounts the dashboard and installs every Bro gem in the bundle (the ones that registered a section).
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Mounts the BroHub dashboard and runs the installer of each Bro gem in your bundle."
12
+
13
+ DEFAULT_MOUNT_PATH = "/bro_hub"
14
+
15
+ class_option :mount_path, type: :string,
16
+ desc: "Where to mount the dashboard (asked interactively when omitted, default #{DEFAULT_MOUNT_PATH})"
17
+ class_option :install_gems, type: :boolean, default: true, hide: true,
18
+ desc: "Also run the installers of the Bro gems in the bundle"
19
+ class_option :remove_direct_mounts, type: :boolean, default: nil,
20
+ desc: "Comment out old `mount SafeBro::Engine`-style lines (asked interactively when omitted)"
21
+
22
+ def copy_initializer
23
+ copy_file "bro_hub.rb", "config/initializers/bro_hub.rb"
24
+ end
25
+
26
+ def mount_engine
27
+ if routes_source.include?("BroHub::Engine")
28
+ say_status :skip, "BroHub::Engine is already mounted", :yellow
29
+ else
30
+ route %(mount BroHub::Engine => "#{mount_path}")
31
+ end
32
+ end
33
+
34
+ # A gem mounted directly *and* under the hub would be served twice.
35
+ def retire_direct_mounts
36
+ direct_mounts.each do |line|
37
+ if remove_direct_mount?(line)
38
+ gsub_file "config/routes.rb", line, "# BroHub mounts this now: #{line.strip}"
39
+ else
40
+ say_status :warning, "#{line.strip} is still mounted directly; BroHub also serves it under #{mount_path}. " \
41
+ "Re-run with --remove-direct-mounts or delete the line.", :yellow
42
+ end
43
+ end
44
+ end
45
+
46
+ def install_bro_gems
47
+ return unless options[:install_gems]
48
+
49
+ bro_gem_installers.each do |section, namespace|
50
+ say_status :install, "#{section.name} (#{namespace})", :green
51
+ invoke namespace, [], skip_bro_hub: true, mount_path: mount_path
52
+ end
53
+ end
54
+
55
+ def next_steps
56
+ say <<~TEXT
57
+
58
+ BroHub is installed. Next:
59
+ 1. Check config/initializers/bro_hub.rb (how to find the signed-in user)
60
+ 2. Visit #{mount_path}. Each Bro gem lives under #{mount_path}/<gem>
61
+ TEXT
62
+ end
63
+
64
+ private
65
+ def routes_source
66
+ path = File.join(destination_root, "config/routes.rb")
67
+ File.exist?(path) ? File.read(path) : ""
68
+ end
69
+
70
+ def mount_path
71
+ @mount_path ||= begin
72
+ path = options[:mount_path].presence
73
+ path ||= existing_mount_path
74
+ path ||= ask("Where should the dashboard be mounted?", default: DEFAULT_MOUNT_PATH) if $stdin.tty?
75
+ path = path.to_s.strip.presence || DEFAULT_MOUNT_PATH
76
+ "/" + path.delete_prefix("/").delete_suffix("/")
77
+ end
78
+ end
79
+
80
+ def existing_mount_path
81
+ routes_source[/mount BroHub::Engine => "([^"]+)"/, 1]
82
+ end
83
+
84
+ # Routes that mount an engine BroHub now mounts itself.
85
+ def direct_mounts
86
+ engines = BroHub.registry.filter_map { |section| section.engine&.name }
87
+ routes_source.lines.map(&:chomp).select do |line|
88
+ !line.strip.start_with?("#") && engines.any? { |name| line.match?(/\A\s*mount\s+#{Regexp.escape(name)}\b/) }
89
+ end
90
+ end
91
+
92
+ def remove_direct_mount?(line)
93
+ return options[:remove_direct_mounts] unless options[:remove_direct_mounts].nil?
94
+
95
+ $stdin.tty? && yes?("#{line.strip} is now mounted by BroHub. Comment it out? (y/N)")
96
+ end
97
+
98
+ # [ [ section, "safe_bro:install" ], ... ] for the registered gems that ship an installer.
99
+ def bro_gem_installers
100
+ BroHub.registry.filter_map do |section|
101
+ namespace = "#{section.key}:install"
102
+ [ section, namespace ] if section.engine && Rails::Generators.find_by_namespace(namespace)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,10 @@
1
+ BroHub.configure do |config|
2
+ # How to find the signed-in user. The default calls `current_user` on the controller when it has one.
3
+ # config.current_user = ->(controller) { controller.current_user }
4
+
5
+ # What to show at the top right.
6
+ # config.user_label = ->(user) { user.email }
7
+
8
+ # Which controller the dashboard's own pages inherit from (so your authentication runs).
9
+ # config.parent_controller = "::ApplicationController"
10
+ end
@@ -0,0 +1,48 @@
1
+ require "rails/generators"
2
+ require "generators/bro_hub/install/install_generator"
3
+
4
+ module BroHub
5
+ module Generators
6
+ # The rule for a Bro gem's own installer: set up BroHub first, then yourself.
7
+ #
8
+ # class SafeBro::Generators::InstallGenerator < Rails::Generators::Base
9
+ # include BroHub::Generators::Installable # first line, so BroHub's step runs first
10
+ #
11
+ # def copy_initializer ... end
12
+ # end
13
+ #
14
+ # `rails g safe_bro:install` then mounts BroHub (once, whatever else is installed) and installs SafeBro.
15
+ # `rails g bro_hub:install` installs every Bro gem in the bundle by running their installers with
16
+ # --skip-bro-hub, since it has just done that step itself.
17
+ module Installable
18
+ extend ActiveSupport::Concern
19
+
20
+ included do
21
+ class_option :mount_path, type: :string,
22
+ desc: "Where to mount the BroHub dashboard (asked interactively when omitted, default #{BroHub::Generators::InstallGenerator::DEFAULT_MOUNT_PATH})"
23
+ class_option :remove_direct_mounts, type: :boolean, default: nil,
24
+ desc: "Comment out old `mount SafeBro::Engine`-style lines, since BroHub mounts the gems itself"
25
+ class_option :skip_bro_hub, type: :boolean, default: false, hide: true,
26
+ desc: "BroHub is already set up (used by bro_hub:install)"
27
+
28
+ # Defined here, not in the module body, so Thor registers it as the first command of the generator.
29
+ def install_bro_hub
30
+ return if options[:skip_bro_hub]
31
+
32
+ forward = { install_gems: false }
33
+ forward[:mount_path] = options[:mount_path] if options[:mount_path].present?
34
+ forward[:remove_direct_mounts] = options[:remove_direct_mounts] unless options[:remove_direct_mounts].nil?
35
+ invoke "bro_hub:install", [], forward
36
+ end
37
+ end
38
+
39
+ private
40
+ # Where BroHub ended up, read back from the routes (it may have been mounted before).
41
+ def bro_hub_path
42
+ routes = File.join(destination_root, "config/routes.rb")
43
+ mounted = File.read(routes)[/mount BroHub::Engine => "([^"]+)"/, 1] if File.exist?(routes)
44
+ mounted || BroHub::Generators::InstallGenerator::DEFAULT_MOUNT_PATH
45
+ end
46
+ end
47
+ end
48
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bro_hub
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ruby Dev SRL
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ description: 'BroHub is the shared admin shell: one mount point, one layout, one sidebar
27
+ and one place to configure who is signed in. Each Bro gem registers its pages and
28
+ shows up as a section. Sections only appear for people who may use them.'
29
+ email:
30
+ - office@rubydev.ro
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - MIT-LICENSE
36
+ - README.md
37
+ - Rakefile
38
+ - app/controllers/bro_hub/application_controller.rb
39
+ - app/controllers/bro_hub/home_controller.rb
40
+ - app/helpers/bro_hub/application_helper.rb
41
+ - app/views/bro_hub/shared/_hero.html.erb
42
+ - app/views/bro_hub/shared/forbidden.html.erb
43
+ - app/views/layouts/bro_hub/application.html.erb
44
+ - config/routes.rb
45
+ - lib/bro_hub.rb
46
+ - lib/bro_hub/configuration.rb
47
+ - lib/bro_hub/engine.rb
48
+ - lib/bro_hub/layout.rb
49
+ - lib/bro_hub/registry.rb
50
+ - lib/bro_hub/root_middleware.rb
51
+ - lib/bro_hub/section.rb
52
+ - lib/bro_hub/skull.png
53
+ - lib/bro_hub/styles.css
54
+ - lib/bro_hub/version.rb
55
+ - lib/generators/bro_hub/install/install_generator.rb
56
+ - lib/generators/bro_hub/install/templates/bro_hub.rb
57
+ - lib/generators/bro_hub/installable.rb
58
+ homepage: https://www.rubydev.ro
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ allowed_push_host: https://rubygems.org
63
+ rubygems_mfa_required: 'true'
64
+ homepage_uri: https://www.rubydev.ro
65
+ source_code_uri: https://github.com/rubydevro/bro_hub
66
+ bug_tracker_uri: https://github.com/rubydevro/bro_hub/issues
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.2'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.10
82
+ specification_version: 4
83
+ summary: One dashboard shell for the Bro gems (EnvBro, SafeBro, ...).
84
+ test_files: []