parry 0.1.0 → 0.2.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.
@@ -0,0 +1,341 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Parry</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <style>
8
+ :root {
9
+ --parry-bg: #f5f6f8;
10
+ --parry-surface: #ffffff;
11
+ --parry-border: #dfe3e8;
12
+ --parry-text: #1c1e21;
13
+ --parry-muted: #6b7280;
14
+ --parry-accent: #2f6feb;
15
+ --parry-danger: #c0392b;
16
+ --parry-danger-bg: #fdecea;
17
+ --parry-success: #1e7f4f;
18
+ --parry-success-bg: #e7f6ee;
19
+ --parry-warning-bg: #fdf3d8;
20
+ --parry-warning: #8a6100;
21
+ }
22
+
23
+ @media (prefers-color-scheme: dark) {
24
+ :root {
25
+ --parry-bg: #16181d;
26
+ --parry-surface: #1e2127;
27
+ --parry-border: #333842;
28
+ --parry-text: #e7e9ee;
29
+ --parry-muted: #9aa1ad;
30
+ --parry-accent: #6ea0ff;
31
+ --parry-danger: #ff8a7a;
32
+ --parry-danger-bg: #3a2320;
33
+ --parry-success: #7fd6a6;
34
+ --parry-success-bg: #1d3129;
35
+ --parry-warning-bg: #3a3220;
36
+ --parry-warning: #e5c46b;
37
+ }
38
+ }
39
+
40
+ * { box-sizing: border-box; }
41
+
42
+ body {
43
+ margin: 0;
44
+ background: var(--parry-bg);
45
+ color: var(--parry-text);
46
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
47
+ font-size: 15px;
48
+ line-height: 1.5;
49
+ }
50
+
51
+ a { color: var(--parry-accent); }
52
+
53
+ .parry-header {
54
+ background: var(--parry-surface);
55
+ border-bottom: 1px solid var(--parry-border);
56
+ }
57
+
58
+ .parry-header-inner,
59
+ .parry-main {
60
+ max-width: 1040px;
61
+ margin: 0 auto;
62
+ padding: 0 20px;
63
+ }
64
+
65
+ .parry-header-inner {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 24px;
69
+ height: 60px;
70
+ }
71
+
72
+ .parry-brand {
73
+ font-weight: 700;
74
+ font-size: 18px;
75
+ letter-spacing: 0.02em;
76
+ text-decoration: none;
77
+ color: var(--parry-text);
78
+ }
79
+
80
+ .parry-nav { display: flex; gap: 4px; }
81
+
82
+ .parry-nav a {
83
+ padding: 6px 12px;
84
+ border-radius: 6px;
85
+ text-decoration: none;
86
+ color: var(--parry-muted);
87
+ font-weight: 500;
88
+ }
89
+
90
+ .parry-nav a:hover { background: var(--parry-bg); }
91
+
92
+ .parry-nav a.is-active {
93
+ background: var(--parry-bg);
94
+ color: var(--parry-text);
95
+ }
96
+
97
+ .parry-main { padding-top: 28px; padding-bottom: 60px; }
98
+
99
+ .parry-page-head {
100
+ display: flex;
101
+ align-items: center;
102
+ justify-content: space-between;
103
+ gap: 16px;
104
+ margin-bottom: 20px;
105
+ }
106
+
107
+ .parry-page-head h1 { font-size: 22px; margin: 0; }
108
+
109
+ .parry-page-head p { margin: 4px 0 0; color: var(--parry-muted); font-size: 14px; }
110
+
111
+ .parry-card {
112
+ background: var(--parry-surface);
113
+ border: 1px solid var(--parry-border);
114
+ border-radius: 10px;
115
+ overflow: hidden;
116
+ }
117
+
118
+ .parry-card + .parry-card { margin-top: 20px; }
119
+
120
+ table { width: 100%; border-collapse: collapse; }
121
+
122
+ .parry-table-scroll { overflow-x: auto; }
123
+
124
+ .parry-table-scroll table { min-width: 760px; }
125
+
126
+ .parry-table-scroll .parry-actions {
127
+ position: sticky;
128
+ right: 0;
129
+ z-index: 1;
130
+ background: var(--parry-surface);
131
+ box-shadow: -8px 0 8px -8px rgba(0, 0, 0, 0.2);
132
+ }
133
+
134
+ th, td {
135
+ text-align: left;
136
+ padding: 12px 16px;
137
+ border-bottom: 1px solid var(--parry-border);
138
+ vertical-align: middle;
139
+ }
140
+
141
+ th {
142
+ font-size: 12px;
143
+ text-transform: uppercase;
144
+ letter-spacing: 0.05em;
145
+ color: var(--parry-muted);
146
+ font-weight: 600;
147
+ }
148
+
149
+ tbody tr:last-child td { border-bottom: none; }
150
+
151
+ td.parry-actions { text-align: right; white-space: nowrap; }
152
+
153
+ td.parry-actions form { display: inline-block; margin-left: 8px; }
154
+
155
+ code, .parry-mono {
156
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
157
+ font-size: 13px;
158
+ }
159
+
160
+ .parry-tag {
161
+ display: inline-block;
162
+ padding: 2px 8px;
163
+ border-radius: 999px;
164
+ font-size: 12px;
165
+ font-weight: 600;
166
+ }
167
+
168
+ .parry-tag--blocklist, .parry-tag--blocked, .parry-tag--honeypot, .parry-tag--caught {
169
+ background: var(--parry-danger-bg);
170
+ color: var(--parry-danger);
171
+ }
172
+ .parry-tag--safelist { background: var(--parry-success-bg); color: var(--parry-success); }
173
+ .parry-tag--regex {
174
+ background: var(--parry-bg);
175
+ color: var(--parry-muted);
176
+ border: 1px solid var(--parry-border);
177
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
178
+ }
179
+ .parry-tag--throttle { background: var(--parry-warning-bg); color: var(--parry-warning); }
180
+
181
+ .parry-muted { color: var(--parry-muted); }
182
+
183
+ .parry-btn {
184
+ display: inline-block;
185
+ padding: 7px 14px;
186
+ border-radius: 7px;
187
+ border: 1px solid var(--parry-border);
188
+ background: var(--parry-surface);
189
+ color: var(--parry-text);
190
+ font-size: 14px;
191
+ font-weight: 500;
192
+ text-decoration: none;
193
+ cursor: pointer;
194
+ font-family: inherit;
195
+ }
196
+
197
+ .parry-btn:hover { border-color: var(--parry-muted); }
198
+
199
+ .parry-btn--primary {
200
+ background: var(--parry-accent);
201
+ border-color: var(--parry-accent);
202
+ color: #fff;
203
+ }
204
+
205
+ .parry-btn--danger { color: var(--parry-danger); }
206
+
207
+ .parry-flash {
208
+ padding: 12px 16px;
209
+ border-radius: 8px;
210
+ margin-bottom: 20px;
211
+ font-size: 14px;
212
+ }
213
+
214
+ .parry-flash--notice { background: var(--parry-success-bg); color: var(--parry-success); }
215
+ .parry-flash--alert { background: var(--parry-danger-bg); color: var(--parry-danger); }
216
+
217
+ .parry-empty {
218
+ padding: 48px 16px;
219
+ text-align: center;
220
+ color: var(--parry-muted);
221
+ }
222
+
223
+ .parry-form { padding: 20px; max-width: 560px; }
224
+
225
+ .parry-field { margin-bottom: 18px; }
226
+
227
+ .parry-field label {
228
+ display: block;
229
+ font-weight: 600;
230
+ font-size: 14px;
231
+ margin-bottom: 6px;
232
+ }
233
+
234
+ .parry-field input[type="text"],
235
+ .parry-field input[type="number"],
236
+ .parry-field select {
237
+ width: 100%;
238
+ padding: 9px 11px;
239
+ border-radius: 7px;
240
+ border: 1px solid var(--parry-border);
241
+ background: var(--parry-bg);
242
+ color: var(--parry-text);
243
+ font-size: 14px;
244
+ font-family: inherit;
245
+ }
246
+
247
+ .parry-hint { display: block; margin-top: 5px; font-size: 13px; color: var(--parry-muted); }
248
+
249
+ .parry-form-actions {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 12px;
253
+ margin-top: 24px;
254
+ }
255
+
256
+ .parry-errors {
257
+ background: var(--parry-danger-bg);
258
+ color: var(--parry-danger);
259
+ border-radius: 8px;
260
+ padding: 12px 16px;
261
+ margin-bottom: 20px;
262
+ font-size: 14px;
263
+ }
264
+
265
+ .parry-errors ul { margin: 6px 0 0; padding-left: 18px; }
266
+
267
+ .parry-head-actions { display: flex; gap: 8px; align-items: center; }
268
+
269
+ .parry-choice {
270
+ display: flex;
271
+ align-items: center;
272
+ gap: 8px;
273
+ font-weight: 400;
274
+ margin-bottom: 6px;
275
+ }
276
+
277
+ .parry-choice input { margin: 0; }
278
+
279
+ .parry-field textarea {
280
+ width: 100%;
281
+ padding: 9px 11px;
282
+ border-radius: 7px;
283
+ border: 1px solid var(--parry-border);
284
+ background: var(--parry-bg);
285
+ color: var(--parry-text);
286
+ font-size: 13px;
287
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
288
+ resize: vertical;
289
+ }
290
+
291
+ .parry-pagination {
292
+ display: flex;
293
+ align-items: center;
294
+ justify-content: space-between;
295
+ gap: 16px;
296
+ flex-wrap: wrap;
297
+ padding: 12px 16px;
298
+ border-top: 1px solid var(--parry-border);
299
+ font-size: 14px;
300
+ }
301
+
302
+ .parry-pagination-controls {
303
+ display: flex;
304
+ align-items: center;
305
+ gap: 12px;
306
+ }
307
+
308
+ .parry-btn--disabled {
309
+ opacity: 0.45;
310
+ cursor: default;
311
+ }
312
+
313
+ .parry-row-group { display: flex; gap: 16px; }
314
+
315
+ .parry-row-group > * { flex: 1; }
316
+ </style>
317
+ </head>
318
+
319
+ <body>
320
+ <header class="parry-header">
321
+ <div class="parry-header-inner">
322
+ <a class="parry-brand" href="<%= root_path %>">Parry</a>
323
+ <nav class="parry-nav">
324
+ <%= link_to "Rules", rules_path, class: ("is-active" if controller_name == "rules") %>
325
+ <%= link_to "Blocked hosts", blocked_hosts_path, class: ("is-active" if controller_name == "blocked_hosts") %>
326
+ </nav>
327
+ </div>
328
+ </header>
329
+
330
+ <main class="parry-main">
331
+ <% if flash[:notice] %>
332
+ <div class="parry-flash parry-flash--notice"><%= flash[:notice] %></div>
333
+ <% end %>
334
+ <% if flash[:alert] %>
335
+ <div class="parry-flash parry-flash--alert"><%= flash[:alert] %></div>
336
+ <% end %>
337
+
338
+ <%= yield %>
339
+ </main>
340
+ </body>
341
+ </html>
@@ -0,0 +1,88 @@
1
+ <div class="parry-page-head">
2
+ <div>
3
+ <h1>Blocked hosts</h1>
4
+ <p>Hosts Rack::Attack blocked or throttled, most recent first.</p>
5
+ </div>
6
+ </div>
7
+
8
+ <div class="parry-card">
9
+ <% if @hosts.empty? %>
10
+ <div class="parry-empty">
11
+ <p>Nothing has been blocked or throttled yet.</p>
12
+ </div>
13
+ <% else %>
14
+ <div class="parry-table-scroll">
15
+ <table>
16
+ <thead>
17
+ <tr>
18
+ <th>IP</th>
19
+ <th>Matched</th>
20
+ <th>Hits</th>
21
+ <th>Last seen</th>
22
+ <th>Status</th>
23
+ <th class="parry-actions"></th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <% @hosts.each do |host| %>
28
+ <tr>
29
+ <td class="parry-mono"><%= host.ip %></td>
30
+ <td>
31
+ <%= host.rule.presence || "—" %>
32
+ <% if host.path.present? %>
33
+ <div class="parry-muted parry-mono"><%= host.path %></div>
34
+ <% end %>
35
+ </td>
36
+ <td><%= host.hits.positive? ? number_with_delimiter(host.hits) : "—" %></td>
37
+ <td class="parry-muted"><%= parry_time(host.last_seen) %></td>
38
+ <td>
39
+ <% if (rule = @rule_set.blocklist_rule_for(host.ip)) %>
40
+ <span class="parry-tag parry-tag--blocked">Blocklisted</span>
41
+ <div class="parry-muted parry-mono"><%= rule.value %></div>
42
+ <% elsif (caught = @rule_set.caught_host(host.ip)) %>
43
+ <span class="parry-tag parry-tag--caught">Caught</span>
44
+ <div class="parry-muted parry-mono"><%= caught.path %></div>
45
+ <% elsif host.throttled? %>
46
+ <span class="parry-tag parry-tag--throttle">Throttled</span>
47
+ <% else %>
48
+ <span class="parry-muted">Not blocked</span>
49
+ <% end %>
50
+ </td>
51
+ <td class="parry-actions">
52
+ <%= button_to "Unblock", unblock_blocked_hosts_path, params: { ip: host.ip, page: @page },
53
+ class: "parry-btn",
54
+ form: { data: { turbo_confirm: "Unblock #{host.ip}?" } },
55
+ data: { confirm: "Unblock #{host.ip}?" } %>
56
+ </td>
57
+ </tr>
58
+ <% end %>
59
+ </tbody>
60
+ </table>
61
+ </div>
62
+
63
+ <div class="parry-pagination">
64
+ <span class="parry-muted">
65
+ Showing <%= number_with_delimiter(@offset + 1) %>-<%= number_with_delimiter(@offset + @hosts.size) %>
66
+ of <%= number_with_delimiter(@total) %> <%= "host".pluralize(@total) %>
67
+ </span>
68
+
69
+ <% if @total_pages > 1 %>
70
+ <span class="parry-pagination-controls">
71
+ <% if @page > 1 %>
72
+ <%= link_to "Previous", blocked_hosts_path(page: (@page - 1 if @page > 2)), class: "parry-btn" %>
73
+ <% else %>
74
+ <span class="parry-btn parry-btn--disabled">Previous</span>
75
+ <% end %>
76
+
77
+ <span class="parry-muted">Page <%= @page %> of <%= @total_pages %></span>
78
+
79
+ <% if @page < @total_pages %>
80
+ <%= link_to "Next", blocked_hosts_path(page: @page + 1), class: "parry-btn" %>
81
+ <% else %>
82
+ <span class="parry-btn parry-btn--disabled">Next</span>
83
+ <% end %>
84
+ </span>
85
+ <% end %>
86
+ </div>
87
+ <% end %>
88
+ </div>
@@ -0,0 +1,101 @@
1
+ <%= form_with model: rule, url: url, method: method, scope: :rule, class: "parry-form" do |form| %>
2
+ <% if rule.errors.any? %>
3
+ <div class="parry-errors">
4
+ <strong><%= pluralize(rule.errors.count, "error") %> prevented this rule from being saved:</strong>
5
+ <ul>
6
+ <% rule.errors.full_messages.each do |message| %>
7
+ <li><%= message %></li>
8
+ <% end %>
9
+ </ul>
10
+ </div>
11
+ <% end %>
12
+
13
+ <div class="parry-field">
14
+ <%= form.label :kind, "Rule type" %>
15
+ <%= form.select :kind, Parry::Rule::KINDS.map { |kind| [kind.titleize, kind] }, {}, data: { parry_kind_select: true } %>
16
+ <span class="parry-hint">
17
+ Blocklisted requests get a 403, throttled ones a 429. A honeypot blocklists any host that requests its path.
18
+ A safelist entry wins over all of them.
19
+ </span>
20
+ </div>
21
+
22
+ <div class="parry-field" data-parry-when="blocklist safelist">
23
+ <%= form.label :value, "IP address or CIDR range" %>
24
+ <%= form.text_field :value, placeholder: "203.0.113.4 or 203.0.113.0/24" %>
25
+ <span class="parry-hint">Both IPv4 and IPv6 are accepted.</span>
26
+ </div>
27
+
28
+ <div class="parry-field">
29
+ <%= form.label :name, "Name" %>
30
+ <%= form.text_field :name, placeholder: "Scraper from the datacenter range" %>
31
+ <span class="parry-hint" data-parry-when="blocklist safelist honeypot">Optional note shown in the rules list.</span>
32
+ <span class="parry-hint" data-parry-when="throttle">Required, and used as the Rack::Attack throttle name.</span>
33
+ </div>
34
+
35
+ <div class="parry-row-group" data-parry-when="throttle">
36
+ <div class="parry-field">
37
+ <%= form.label :limit, "Requests" %>
38
+ <%= form.number_field :limit, min: 1, placeholder: "60" %>
39
+ </div>
40
+ <div class="parry-field">
41
+ <%= form.label :period, "Per (seconds)" %>
42
+ <%= form.number_field :period, min: 1, placeholder: "60" %>
43
+ </div>
44
+ </div>
45
+
46
+ <div class="parry-field" data-parry-when="honeypot">
47
+ <%= form.label :path_match, "Match the path by" %>
48
+ <%= form.select :path_match,
49
+ [["Prefix", "prefix"], ["Regular expression", "regex"]],
50
+ {}, data: { parry_match_select: true } %>
51
+ </div>
52
+
53
+ <div class="parry-field" data-parry-when="throttle honeypot">
54
+ <%= form.label :path, "Path" %>
55
+ <%= form.text_field :path, placeholder: "/wp-login.php" %>
56
+ <span class="parry-hint" data-parry-when="throttle">
57
+ Optional prefix. Leave blank to throttle every request from an IP.
58
+ </span>
59
+ <span data-parry-when="honeypot">
60
+ <span class="parry-hint" data-parry-when-match="prefix">
61
+ Required. Every path starting with this springs the honeypot, so pick something no real visitor requests.
62
+ The host is blocked until you unblock it.
63
+ </span>
64
+ <span class="parry-hint" data-parry-when-match="regex">
65
+ Required. A Ruby regular expression, matched anywhere in the path unless you anchor it -
66
+ <span class="parry-mono">\.(env|git)</span> catches both <span class="parry-mono">/.env</span> and
67
+ <span class="parry-mono">/.git/config</span>. Keep it tight: anything a real visitor can reach gets
68
+ blocked, and the pattern runs on every request.
69
+ </span>
70
+ </span>
71
+ </div>
72
+
73
+ <div class="parry-form-actions">
74
+ <%= form.submit submit_label, class: "parry-btn parry-btn--primary" %>
75
+ <%= link_to "Cancel", rules_path, class: "parry-muted" %>
76
+ </div>
77
+ <% end %>
78
+
79
+ <script>
80
+ (function () {
81
+ var kindSelect = document.querySelector("[data-parry-kind-select]");
82
+ var matchSelect = document.querySelector("[data-parry-match-select]");
83
+ if (!kindSelect) return;
84
+
85
+ function toggle(attribute, current) {
86
+ document.querySelectorAll("[" + attribute + "]").forEach(function (el) {
87
+ var wanted = el.getAttribute(attribute).split(" ");
88
+ el.style.display = wanted.indexOf(current) === -1 ? "none" : "";
89
+ });
90
+ }
91
+
92
+ function refresh() {
93
+ toggle("data-parry-when", kindSelect.value);
94
+ toggle("data-parry-when-match", matchSelect ? matchSelect.value : "prefix");
95
+ }
96
+
97
+ kindSelect.addEventListener("change", refresh);
98
+ if (matchSelect) matchSelect.addEventListener("change", refresh);
99
+ refresh();
100
+ })();
101
+ </script>
@@ -0,0 +1,10 @@
1
+ <div class="parry-page-head">
2
+ <div>
3
+ <h1>Edit rule</h1>
4
+ <p class="parry-mono"><%= @rule.throttle? ? @rule.name : @rule.value %></p>
5
+ </div>
6
+ </div>
7
+
8
+ <div class="parry-card">
9
+ <%= render "form", rule: @rule, url: rule_path(@rule.id), method: :patch, submit_label: "Save rule" %>
10
+ </div>
@@ -0,0 +1,56 @@
1
+ <div class="parry-page-head">
2
+ <div>
3
+ <h1>Import rules</h1>
4
+ <p>Load rules from a file exported by Parry.</p>
5
+ </div>
6
+ <div>
7
+ <%= link_to "Back to rules", rules_path, class: "parry-btn" %>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="parry-card">
12
+ <%= form_with url: perform_import_rules_path, method: :post, multipart: true, class: "parry-form" do |form| %>
13
+ <% if @result && !@result.ok? %>
14
+ <div class="parry-errors">
15
+ <strong>Nothing was imported:</strong>
16
+ <ul>
17
+ <% @result.errors.each do |message| %>
18
+ <li><%= message %></li>
19
+ <% end %>
20
+ </ul>
21
+ </div>
22
+ <% end %>
23
+
24
+ <div class="parry-field">
25
+ <%= form.label :file, "File" %>
26
+ <%= form.file_field :file, accept: "application/json,.json" %>
27
+ </div>
28
+
29
+ <div class="parry-field">
30
+ <%= form.label :pasted, "Or paste the file" %>
31
+ <%= form.text_area :pasted, rows: 8, class: "parry-mono", placeholder: '{"rules": [ ... ]}' %>
32
+ <span class="parry-hint">Used only when no file is chosen.</span>
33
+ </div>
34
+
35
+ <div class="parry-field">
36
+ <%= form.label :mode, "What to do with the rules already here" %>
37
+ <label class="parry-choice">
38
+ <%= form.radio_button :mode, "merge", checked: params[:mode] != "replace" %>
39
+ Keep them and add the ones from the file
40
+ </label>
41
+ <label class="parry-choice">
42
+ <%= form.radio_button :mode, "replace" %>
43
+ Replace them all with the file
44
+ </label>
45
+ <span class="parry-hint">
46
+ Either way it is all or nothing: if a single rule in the file is rejected, nothing is imported and
47
+ what you have now is left alone.
48
+ </span>
49
+ </div>
50
+
51
+ <div class="parry-form-actions">
52
+ <%= form.submit "Import rules", class: "parry-btn parry-btn--primary" %>
53
+ <%= link_to "Cancel", rules_path, class: "parry-muted" %>
54
+ </div>
55
+ <% end %>
56
+ </div>
@@ -0,0 +1,80 @@
1
+ <div class="parry-page-head">
2
+ <div>
3
+ <h1>Rules</h1>
4
+ <p>Blocklists, safelists and throttles applied by Rack::Attack. Changes take effect immediately.</p>
5
+ </div>
6
+ <div class="parry-head-actions">
7
+ <%= link_to "Import", import_rules_path, class: "parry-btn" %>
8
+ <%= link_to "Export", export_rules_path, class: "parry-btn" %>
9
+ <%= link_to "New rule", new_rule_path, class: "parry-btn parry-btn--primary" %>
10
+ </div>
11
+ </div>
12
+
13
+ <div class="parry-card">
14
+ <% if @rules.empty? %>
15
+ <div class="parry-empty">
16
+ <p>No rules yet.</p>
17
+ <%= link_to "Add the first one", new_rule_path, class: "parry-btn" %>
18
+ <%= link_to "or import a file", import_rules_path, class: "parry-muted" %>
19
+ </div>
20
+ <% else %>
21
+ <table>
22
+ <thead>
23
+ <tr>
24
+ <th>Type</th>
25
+ <th>Target</th>
26
+ <th>Details</th>
27
+ <th>Added</th>
28
+ <th></th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ <% @rules.each do |rule| %>
33
+ <tr>
34
+ <td><%= parry_kind_label(rule.kind) %></td>
35
+ <td>
36
+ <% if rule.throttle? %>
37
+ <span class="parry-mono"><%= rule.name %></span>
38
+ <% elsif rule.honeypot? %>
39
+ <span class="parry-mono"><%= rule.path %></span>
40
+ <% if rule.regex_match? %>
41
+ <span class="parry-tag parry-tag--regex">regex</span>
42
+ <% end %>
43
+ <% if rule.name.present? %>
44
+ <div class="parry-muted"><%= rule.name %></div>
45
+ <% end %>
46
+ <% else %>
47
+ <span class="parry-mono"><%= rule.value %></span>
48
+ <% if rule.name.present? %>
49
+ <div class="parry-muted"><%= rule.name %></div>
50
+ <% end %>
51
+ <% end %>
52
+ </td>
53
+ <td class="parry-muted">
54
+ <% if rule.throttle? %>
55
+ <%= rule.limit %> requests per <%= parry_period(rule.period) %> per IP
56
+ <% if rule.path.present? %>
57
+ on <span class="parry-mono"><%= rule.path %></span>
58
+ <% end %>
59
+ <% elsif rule.honeypot? %>
60
+ <%= rule.regex_match? ? "Blocklists any host whose path matches" : "Blocklists any host requesting this path" %>
61
+ <% elsif rule.kind == "safelist" %>
62
+ Always allowed
63
+ <% else %>
64
+ Always blocked
65
+ <% end %>
66
+ </td>
67
+ <td class="parry-muted"><%= parry_time(rule.created_at_time) %></td>
68
+ <td class="parry-actions">
69
+ <%= link_to "Edit", edit_rule_path(rule.id), class: "parry-btn" %>
70
+ <%= button_to "Delete", rule_path(rule.id), method: :delete,
71
+ class: "parry-btn parry-btn--danger",
72
+ form: { data: { turbo_confirm: "Delete this rule?" } },
73
+ data: { confirm: "Delete this rule?" } %>
74
+ </td>
75
+ </tr>
76
+ <% end %>
77
+ </tbody>
78
+ </table>
79
+ <% end %>
80
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="parry-page-head">
2
+ <div>
3
+ <h1>New rule</h1>
4
+ <p>Applied to every application process as soon as it is saved.</p>
5
+ </div>
6
+ </div>
7
+
8
+ <div class="parry-card">
9
+ <%= render "form", rule: @rule, url: rules_path, method: :post, submit_label: "Create rule" %>
10
+ </div>