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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +137 -0
- data/Rakefile +3 -0
- data/app/controllers/bro_hub/application_controller.rb +20 -0
- data/app/controllers/bro_hub/home_controller.rb +12 -0
- data/app/helpers/bro_hub/application_helper.rb +82 -0
- data/app/views/bro_hub/shared/_hero.html.erb +21 -0
- data/app/views/bro_hub/shared/forbidden.html.erb +3 -0
- data/app/views/layouts/bro_hub/application.html.erb +58 -0
- data/config/routes.rb +8 -0
- data/lib/bro_hub/configuration.rb +26 -0
- data/lib/bro_hub/engine.rb +7 -0
- data/lib/bro_hub/layout.rb +31 -0
- data/lib/bro_hub/registry.rb +34 -0
- data/lib/bro_hub/root_middleware.rb +17 -0
- data/lib/bro_hub/section.rb +92 -0
- data/lib/bro_hub/skull.png +0 -0
- data/lib/bro_hub/styles.css +139 -0
- data/lib/bro_hub/version.rb +3 -0
- data/lib/bro_hub.rb +45 -0
- data/lib/generators/bro_hub/install/install_generator.rb +107 -0
- data/lib/generators/bro_hub/install/templates/bro_hub.rb +10 -0
- data/lib/generators/bro_hub/installable.rb +48 -0
- metadata +84 -0
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,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,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&family=Archivo:wdth,wght@75..125,400..800&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,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,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
|
+
}
|
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: []
|