beskar 0.0.2 → 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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +298 -110
  4. data/app/controllers/beskar/application_controller.rb +170 -0
  5. data/app/controllers/beskar/banned_ips_controller.rb +280 -0
  6. data/app/controllers/beskar/dashboard_controller.rb +70 -0
  7. data/app/controllers/beskar/security_events_controller.rb +182 -0
  8. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +6 -6
  9. data/app/models/beskar/banned_ip.rb +68 -27
  10. data/app/models/beskar/security_event.rb +14 -0
  11. data/app/services/beskar/banned_ip_manager.rb +78 -0
  12. data/app/views/beskar/banned_ips/edit.html.erb +259 -0
  13. data/app/views/beskar/banned_ips/index.html.erb +361 -0
  14. data/app/views/beskar/banned_ips/new.html.erb +310 -0
  15. data/app/views/beskar/banned_ips/show.html.erb +310 -0
  16. data/app/views/beskar/dashboard/index.html.erb +280 -0
  17. data/app/views/beskar/security_events/index.html.erb +309 -0
  18. data/app/views/beskar/security_events/show.html.erb +307 -0
  19. data/app/views/layouts/beskar/application.html.erb +647 -5
  20. data/config/routes.rb +41 -0
  21. data/lib/beskar/configuration.rb +24 -10
  22. data/lib/beskar/engine.rb +4 -4
  23. data/lib/beskar/logger.rb +293 -0
  24. data/lib/beskar/middleware/request_analyzer.rb +128 -53
  25. data/lib/beskar/models/security_trackable_authenticable.rb +11 -11
  26. data/lib/beskar/models/security_trackable_devise.rb +5 -5
  27. data/lib/beskar/models/security_trackable_generic.rb +12 -12
  28. data/lib/beskar/services/account_locker.rb +12 -12
  29. data/lib/beskar/services/geolocation_service.rb +8 -8
  30. data/lib/beskar/services/ip_whitelist.rb +2 -2
  31. data/lib/beskar/services/waf.rb +307 -78
  32. data/lib/beskar/version.rb +1 -1
  33. data/lib/beskar.rb +1 -0
  34. data/lib/generators/beskar/install/install_generator.rb +158 -0
  35. data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
  36. data/lib/tasks/beskar_tasks.rake +11 -2
  37. metadata +35 -6
  38. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -1,17 +1,659 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="en">
3
3
  <head>
4
- <title>Beskar</title>
4
+ <title>Beskar Security Dashboard</title>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
7
  <%= csrf_meta_tags %>
6
8
  <%= csp_meta_tag %>
7
9
 
8
- <%= yield :head %>
10
+ <!-- Embedded Styles (No external dependencies) -->
11
+ <style>
12
+ :root {
13
+ --primary: #635BFF;
14
+ --primary-dark: #5243CC;
15
+ --danger: #E91E63;
16
+ --warning: #FF9800;
17
+ --success: #4CAF50;
18
+ --neutral: #6B7280;
19
+ --sidebar-width: 240px;
20
+ --header-height: 60px;
21
+ }
22
+
23
+ * {
24
+ margin: 0;
25
+ padding: 0;
26
+ box-sizing: border-box;
27
+ }
28
+
29
+ body {
30
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
31
+ font-size: 14px;
32
+ line-height: 1.5;
33
+ color: #1A1F36;
34
+ background: #F6F9FC;
35
+ }
36
+
37
+ .dashboard-container {
38
+ display: flex;
39
+ min-height: 100vh;
40
+ }
41
+
42
+ /* Sidebar */
43
+ .sidebar {
44
+ width: var(--sidebar-width);
45
+ background: white;
46
+ border-right: 1px solid #E3E8EE;
47
+ position: fixed;
48
+ height: 100vh;
49
+ overflow-y: auto;
50
+ z-index: 100;
51
+ }
52
+
53
+ .sidebar-header {
54
+ padding: 1.5rem;
55
+ border-bottom: 1px solid #E3E8EE;
56
+ }
57
+
58
+ .sidebar-logo {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 0.75rem;
62
+ text-decoration: none;
63
+ color: #1A1F36;
64
+ }
65
+
66
+ .sidebar-logo-icon {
67
+ width: 32px;
68
+ height: 32px;
69
+ background: var(--primary);
70
+ border-radius: 8px;
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ color: white;
75
+ font-weight: bold;
76
+ font-size: 18px;
77
+ }
78
+
79
+ .sidebar-logo-text {
80
+ font-size: 18px;
81
+ font-weight: 600;
82
+ letter-spacing: -0.02em;
83
+ }
84
+
85
+ .sidebar-nav {
86
+ padding: 1rem 0;
87
+ }
88
+
89
+ .nav-section {
90
+ padding: 0.5rem 1rem;
91
+ margin-bottom: 0.25rem;
92
+ }
93
+
94
+ .nav-section-title {
95
+ font-size: 11px;
96
+ font-weight: 600;
97
+ text-transform: uppercase;
98
+ letter-spacing: 0.05em;
99
+ color: #8792A2;
100
+ margin-bottom: 0.5rem;
101
+ }
102
+
103
+ .nav-link {
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 0.75rem;
107
+ padding: 0.625rem 1rem;
108
+ text-decoration: none;
109
+ color: #697386;
110
+ border-radius: 6px;
111
+ transition: all 0.15s ease;
112
+ margin-bottom: 0.125rem;
113
+ }
114
+
115
+ .nav-link:hover {
116
+ background: #F7FAFC;
117
+ color: #1A1F36;
118
+ }
119
+
120
+ .nav-link.active {
121
+ background: #F7F6FF;
122
+ color: var(--primary);
123
+ font-weight: 500;
124
+ }
125
+
126
+ .nav-icon {
127
+ width: 18px;
128
+ height: 18px;
129
+ opacity: 0.8;
130
+ }
131
+
132
+ /* Main Content */
133
+ .main-content {
134
+ flex: 1;
135
+ margin-left: var(--sidebar-width);
136
+ display: flex;
137
+ flex-direction: column;
138
+ }
139
+
140
+ /* Header */
141
+ .header {
142
+ height: var(--header-height);
143
+ background: white;
144
+ border-bottom: 1px solid #E3E8EE;
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: space-between;
148
+ padding: 0 2rem;
149
+ position: sticky;
150
+ top: 0;
151
+ z-index: 50;
152
+ }
153
+
154
+ .header-title {
155
+ font-size: 20px;
156
+ font-weight: 600;
157
+ color: #1A1F36;
158
+ }
159
+
160
+ .header-actions {
161
+ display: flex;
162
+ align-items: center;
163
+ gap: 1rem;
164
+ }
165
+
166
+ /* Content Area */
167
+ .content {
168
+ flex: 1;
169
+ padding: 2rem;
170
+ max-width: 1400px;
171
+ width: 100%;
172
+ margin: 0 auto;
173
+ }
174
+
175
+ /* Cards */
176
+ .card {
177
+ background: white;
178
+ border-radius: 8px;
179
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
180
+ margin-bottom: 1.5rem;
181
+ overflow: hidden;
182
+ }
183
+
184
+ .card-header {
185
+ padding: 1.25rem 1.5rem;
186
+ border-bottom: 1px solid #E3E8EE;
187
+ background: #FAFBFC;
188
+ }
189
+
190
+ .card-title {
191
+ font-size: 16px;
192
+ font-weight: 600;
193
+ color: #1A1F36;
194
+ margin: 0;
195
+ }
196
+
197
+ .card-subtitle {
198
+ font-size: 13px;
199
+ color: #697386;
200
+ margin-top: 0.25rem;
201
+ }
202
+
203
+ .card-body {
204
+ padding: 1.5rem;
205
+ }
206
+
207
+ /* Buttons */
208
+ .btn {
209
+ display: inline-flex;
210
+ align-items: center;
211
+ justify-content: center;
212
+ padding: 0.5rem 1rem;
213
+ font-size: 14px;
214
+ font-weight: 500;
215
+ border-radius: 6px;
216
+ border: none;
217
+ cursor: pointer;
218
+ text-decoration: none;
219
+ transition: all 0.15s ease;
220
+ gap: 0.5rem;
221
+ line-height: 1.5;
222
+ }
223
+
224
+ .btn-primary {
225
+ background: var(--primary);
226
+ color: white;
227
+ }
228
+
229
+ .btn-primary:hover {
230
+ background: var(--primary-dark);
231
+ }
232
+
233
+ .btn-secondary {
234
+ background: white;
235
+ color: #697386;
236
+ border: 1px solid #E3E8EE;
237
+ }
238
+
239
+ .btn-secondary:hover {
240
+ background: #F7FAFC;
241
+ border-color: #D1D9E0;
242
+ }
243
+
244
+ .btn-danger {
245
+ background: var(--danger);
246
+ color: white;
247
+ }
248
+
249
+ .btn-danger:hover {
250
+ background: #D81B60;
251
+ }
252
+
253
+ .btn-sm {
254
+ padding: 0.375rem 0.75rem;
255
+ font-size: 13px;
256
+ }
257
+
258
+ /* Badges */
259
+ .badge {
260
+ display: inline-flex;
261
+ align-items: center;
262
+ padding: 0.25rem 0.625rem;
263
+ font-size: 11px;
264
+ font-weight: 600;
265
+ text-transform: uppercase;
266
+ letter-spacing: 0.025em;
267
+ border-radius: 9999px;
268
+ }
269
+
270
+ .badge-success {
271
+ background: #E6F6E6;
272
+ color: #2E7D32;
273
+ }
274
+
275
+ .badge-warning {
276
+ background: #FFF3E0;
277
+ color: #E65100;
278
+ }
279
+
280
+ .badge-danger {
281
+ background: #FCE4EC;
282
+ color: #C2185B;
283
+ }
284
+
285
+ .badge-critical {
286
+ background: #E91E63;
287
+ color: white;
288
+ }
289
+
290
+ .badge-neutral {
291
+ background: #F4F5F7;
292
+ color: #697386;
293
+ }
294
+
295
+ /* Tables */
296
+ .table-container {
297
+ overflow-x: auto;
298
+ }
299
+
300
+ table {
301
+ width: 100%;
302
+ border-collapse: collapse;
303
+ }
304
+
305
+ thead {
306
+ background: #FAFBFC;
307
+ border-top: 1px solid #E3E8EE;
308
+ border-bottom: 1px solid #E3E8EE;
309
+ }
310
+
311
+ th {
312
+ padding: 0.75rem 1rem;
313
+ text-align: left;
314
+ font-size: 12px;
315
+ font-weight: 600;
316
+ text-transform: uppercase;
317
+ letter-spacing: 0.025em;
318
+ color: #697386;
319
+ }
320
+
321
+ td {
322
+ padding: 1rem;
323
+ border-bottom: 1px solid #E3E8EE;
324
+ color: #1A1F36;
325
+ font-size: 14px;
326
+ }
9
327
 
10
- <%= stylesheet_link_tag "beskar/application", media: "all" %>
328
+ tbody tr:hover {
329
+ background: #FAFBFC;
330
+ }
331
+
332
+ /* Stats Grid */
333
+ .stats-grid {
334
+ display: grid;
335
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
336
+ gap: 1.5rem;
337
+ margin-bottom: 2rem;
338
+ }
339
+
340
+ .stat-card {
341
+ background: white;
342
+ border-radius: 8px;
343
+ padding: 1.5rem;
344
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
345
+ }
346
+
347
+ .stat-label {
348
+ font-size: 12px;
349
+ font-weight: 600;
350
+ text-transform: uppercase;
351
+ letter-spacing: 0.025em;
352
+ color: #697386;
353
+ margin-bottom: 0.5rem;
354
+ }
355
+
356
+ .stat-value {
357
+ font-size: 28px;
358
+ font-weight: 600;
359
+ color: #1A1F36;
360
+ line-height: 1;
361
+ }
362
+
363
+ .stat-change {
364
+ font-size: 12px;
365
+ margin-top: 0.5rem;
366
+ color: #697386;
367
+ }
368
+
369
+ /* Forms */
370
+ .form-group {
371
+ margin-bottom: 1.25rem;
372
+ }
373
+
374
+ label {
375
+ display: block;
376
+ font-size: 13px;
377
+ font-weight: 500;
378
+ color: #1A1F36;
379
+ margin-bottom: 0.375rem;
380
+ }
381
+
382
+ input[type="text"],
383
+ input[type="email"],
384
+ input[type="password"],
385
+ input[type="number"],
386
+ input[type="date"],
387
+ select,
388
+ textarea {
389
+ width: 100%;
390
+ padding: 0.5rem 0.75rem;
391
+ font-size: 14px;
392
+ border: 1px solid #E3E8EE;
393
+ border-radius: 6px;
394
+ background: white;
395
+ transition: border-color 0.15s ease;
396
+ }
397
+
398
+ input:focus,
399
+ select:focus,
400
+ textarea:focus {
401
+ outline: none;
402
+ border-color: var(--primary);
403
+ box-shadow: 0 0 0 3px rgba(99, 91, 255, 0.1);
404
+ }
405
+
406
+ /* Alerts */
407
+ .alert {
408
+ padding: 0.75rem 1rem;
409
+ border-radius: 6px;
410
+ margin-bottom: 1rem;
411
+ font-size: 14px;
412
+ }
413
+
414
+ .alert-info {
415
+ background: #E3F2FD;
416
+ color: #1565C0;
417
+ border: 1px solid #90CAF9;
418
+ }
419
+
420
+ .alert-success {
421
+ background: #E8F5E9;
422
+ color: #2E7D32;
423
+ border: 1px solid #81C784;
424
+ }
425
+
426
+ .alert-warning {
427
+ background: #FFF3E0;
428
+ color: #E65100;
429
+ border: 1px solid #FFB74D;
430
+ }
431
+
432
+ .alert-danger {
433
+ background: #FFEBEE;
434
+ color: #C62828;
435
+ border: 1px solid #EF5350;
436
+ }
437
+
438
+ /* Pagination */
439
+ .pagination {
440
+ display: flex;
441
+ align-items: center;
442
+ justify-content: center;
443
+ gap: 0.25rem;
444
+ margin-top: 2rem;
445
+ }
446
+
447
+ .pagination-link {
448
+ display: inline-flex;
449
+ align-items: center;
450
+ justify-content: center;
451
+ min-width: 32px;
452
+ height: 32px;
453
+ padding: 0 0.75rem;
454
+ font-size: 13px;
455
+ font-weight: 500;
456
+ color: #697386;
457
+ text-decoration: none;
458
+ border-radius: 6px;
459
+ transition: all 0.15s ease;
460
+ }
461
+
462
+ .pagination-link:hover {
463
+ background: #F7FAFC;
464
+ color: #1A1F36;
465
+ }
466
+
467
+ .pagination-link.active {
468
+ background: var(--primary);
469
+ color: white;
470
+ }
471
+
472
+ .pagination-link.disabled {
473
+ opacity: 0.5;
474
+ cursor: not-allowed;
475
+ }
476
+
477
+ /* Responsive */
478
+ @media (max-width: 768px) {
479
+ .sidebar {
480
+ transform: translateX(-100%);
481
+ }
482
+
483
+ .main-content {
484
+ margin-left: 0;
485
+ }
486
+
487
+ .stats-grid {
488
+ grid-template-columns: 1fr;
489
+ }
490
+ }
491
+ </style>
492
+
493
+ <%= yield :head %>
11
494
  </head>
12
495
  <body>
496
+ <div class="dashboard-container">
497
+ <!-- Sidebar -->
498
+ <aside class="sidebar">
499
+ <div class="sidebar-header">
500
+ <%= link_to root_path, class: "sidebar-logo" do %>
501
+ <div class="sidebar-logo-icon">B</div>
502
+ <div class="sidebar-logo-text">Beskar</div>
503
+ <% end %>
504
+ </div>
505
+
506
+ <nav class="sidebar-nav">
507
+ <div class="nav-section">
508
+ <div class="nav-section-title">Overview</div>
509
+ <%= link_to dashboard_path, class: "nav-link #{controller_name == 'dashboard' ? 'active' : ''}" do %>
510
+ <svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
511
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
512
+ </svg>
513
+ Dashboard
514
+ <% end %>
515
+ </div>
516
+
517
+ <div class="nav-section">
518
+ <div class="nav-section-title">Security</div>
519
+ <%= link_to security_events_path, class: "nav-link #{controller_name == 'security_events' ? 'active' : ''}" do %>
520
+ <svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
521
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
522
+ </svg>
523
+ Security Events
524
+ <% end %>
525
+
526
+ <%= link_to banned_ips_path, class: "nav-link #{controller_name == 'banned_ips' ? 'active' : ''}" do %>
527
+ <svg class="nav-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
528
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
529
+ </svg>
530
+ Banned IPs
531
+ <% end %>
532
+ </div>
533
+
534
+ <div class="nav-section">
535
+ <div class="nav-section-title">Configuration</div>
536
+ <% if Beskar.configuration.monitor_only? %>
537
+ <div style="padding: 0.5rem 1rem;">
538
+ <div class="badge badge-warning" style="width: 100%; justify-content: center;">
539
+ Monitor Only Mode
540
+ </div>
541
+ </div>
542
+ <% end %>
543
+ </div>
544
+ </nav>
545
+ </aside>
546
+
547
+ <!-- Main Content -->
548
+ <main class="main-content">
549
+ <!-- Header -->
550
+ <header class="header">
551
+ <h1 class="header-title"><%= yield :page_title %></h1>
552
+
553
+ <div class="header-actions">
554
+ <%= yield :header_actions %>
555
+
556
+ <div style="display: flex; align-items: center; gap: 0.5rem; padding-left: 1rem; border-left: 1px solid #E3E8EE;">
557
+ <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24" style="color: #697386;">
558
+ <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path>
559
+ </svg>
560
+ <span style="font-size: 13px; color: #697386;">Protected by Beskar</span>
561
+ </div>
562
+ </div>
563
+ </header>
564
+
565
+ <!-- Content -->
566
+ <div class="content">
567
+ <% if notice.present? %>
568
+ <div class="alert alert-success">
569
+ <%= notice %>
570
+ </div>
571
+ <% end %>
572
+
573
+ <% if alert.present? %>
574
+ <div class="alert alert-danger">
575
+ <%= alert %>
576
+ </div>
577
+ <% end %>
578
+
579
+ <%= yield %>
580
+ </div>
581
+ </main>
582
+ </div>
583
+
584
+ <%= javascript_tag nonce: true do %>
585
+ // Add active state to current nav link
586
+ document.addEventListener('DOMContentLoaded', function() {
587
+ // Handle nav link clicks
588
+ document.querySelectorAll('.nav-link').forEach(link => {
589
+ if (link.href === window.location.href) {
590
+ link.classList.add('active');
591
+ }
592
+ });
593
+
594
+ // Auto-dismiss alerts after 5 seconds
595
+ setTimeout(() => {
596
+ document.querySelectorAll('.alert').forEach(alert => {
597
+ alert.style.transition = 'opacity 0.5s';
598
+ alert.style.opacity = '0';
599
+ setTimeout(() => alert.remove(), 500);
600
+ });
601
+ }, 5000);
602
+
603
+ // Handle data-turbo-method links (Rails UJS replacement)
604
+ document.addEventListener('click', function(e) {
605
+ const link = e.target.closest('a[data-turbo-method]');
606
+ if (!link) return;
607
+
608
+ e.preventDefault();
609
+
610
+ const method = link.dataset.turboMethod || link.dataset['turbo-method'];
611
+ const confirm = link.dataset.turboConfirm || link.dataset['turbo-confirm'];
612
+
613
+ if (confirm && !window.confirm(confirm)) {
614
+ return;
615
+ }
616
+
617
+ const form = document.createElement('form');
618
+ form.method = 'POST';
619
+ form.action = link.href;
620
+ form.style.display = 'none';
621
+
622
+ // Add CSRF token
623
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
624
+ const csrfParam = document.querySelector('meta[name="csrf-param"]').content;
625
+
626
+ if (csrfToken && csrfParam) {
627
+ const input = document.createElement('input');
628
+ input.type = 'hidden';
629
+ input.name = csrfParam;
630
+ input.value = csrfToken;
631
+ form.appendChild(input);
632
+ }
633
+
634
+ // Add method override for DELETE, PUT, PATCH
635
+ if (method && method.toLowerCase() !== 'post') {
636
+ const methodInput = document.createElement('input');
637
+ methodInput.type = 'hidden';
638
+ methodInput.name = '_method';
639
+ methodInput.value = method;
640
+ form.appendChild(methodInput);
641
+ }
13
642
 
14
- <%= yield %>
643
+ // Add any URL parameters as form inputs
644
+ const url = new URL(link.href);
645
+ url.searchParams.forEach((value, key) => {
646
+ const input = document.createElement('input');
647
+ input.type = 'hidden';
648
+ input.name = key;
649
+ input.value = value;
650
+ form.appendChild(input);
651
+ });
15
652
 
653
+ document.body.appendChild(form);
654
+ form.submit();
655
+ });
656
+ });
657
+ <% end %>
16
658
  </body>
17
659
  </html>
data/config/routes.rb CHANGED
@@ -1,2 +1,43 @@
1
1
  Beskar::Engine.routes.draw do
2
+ # Root route - dashboard
3
+ root to: 'dashboard#index'
4
+
5
+ # Dashboard
6
+ get 'dashboard', to: 'dashboard#index', as: :dashboard
7
+
8
+ # Security Events
9
+ resources :security_events, only: [:index, :show] do
10
+ collection do
11
+ get 'export'
12
+ end
13
+ end
14
+
15
+ # Banned IPs
16
+ resources :banned_ips do
17
+ member do
18
+ post 'extend'
19
+ end
20
+
21
+ collection do
22
+ post 'bulk_action'
23
+ get 'export'
24
+ end
25
+ end
26
+
27
+ # API endpoints (optional, for future AJAX calls)
28
+ namespace :api do
29
+ namespace :v1 do
30
+ resources :security_events, only: [:index, :show] do
31
+ collection do
32
+ get 'stats'
33
+ end
34
+ end
35
+
36
+ resources :banned_ips, only: [:index, :show, :create, :destroy] do
37
+ member do
38
+ post 'extend'
39
+ end
40
+ end
41
+ end
42
+ end
2
43
  end