otto 1.1.0.pre.alpha3 → 1.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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.rspec +4 -0
- data/.rubocop.yml +11 -9
- data/.rubocop_todo.yml +152 -0
- data/Gemfile +16 -5
- data/Gemfile.lock +60 -10
- data/LICENSE.txt +1 -1
- data/README.md +61 -112
- data/VERSION.yml +3 -2
- data/examples/basic/app.rb +78 -0
- data/examples/basic/config.ru +30 -0
- data/examples/basic/routes +20 -0
- data/examples/dynamic_pages/app.rb +115 -0
- data/examples/dynamic_pages/config.ru +30 -0
- data/{example → examples/dynamic_pages}/routes +5 -3
- data/examples/security_features/app.rb +273 -0
- data/examples/security_features/config.ru +81 -0
- data/examples/security_features/routes +11 -0
- data/lib/otto/design_system.rb +463 -0
- data/lib/otto/helpers/request.rb +126 -15
- data/lib/otto/helpers/response.rb +99 -13
- data/lib/otto/route.rb +106 -14
- data/lib/otto/security/config.rb +316 -0
- data/lib/otto/security/csrf.rb +181 -0
- data/lib/otto/security/validator.rb +296 -0
- data/lib/otto/static.rb +18 -5
- data/lib/otto/version.rb +6 -4
- data/lib/otto.rb +337 -107
- data/otto.gemspec +16 -12
- metadata +55 -18
- data/CHANGES.txt +0 -35
- data/example/app.rb +0 -58
- data/example/config.ru +0 -35
- /data/{example/public → public}/favicon.ico +0 -0
- /data/{example/public → public}/img/otto.jpg +0 -0
@@ -0,0 +1,463 @@
|
|
1
|
+
# lib/otto/design_system.rb
|
2
|
+
|
3
|
+
class Otto
|
4
|
+
module DesignSystem
|
5
|
+
# Shared design system for Otto framework examples
|
6
|
+
# Provides consistent styling, components, and utilities
|
7
|
+
|
8
|
+
def otto_page(content, title = "Otto Framework", additional_head = "")
|
9
|
+
<<~HTML
|
10
|
+
<!DOCTYPE html>
|
11
|
+
<html lang="en">
|
12
|
+
<head>
|
13
|
+
<meta charset="UTF-8">
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
15
|
+
<title>#{escape_html(title)}</title>
|
16
|
+
#{otto_styles}
|
17
|
+
#{additional_head}
|
18
|
+
</head>
|
19
|
+
<body>
|
20
|
+
<div class="otto-container">
|
21
|
+
#{content}
|
22
|
+
</div>
|
23
|
+
</body>
|
24
|
+
</html>
|
25
|
+
HTML
|
26
|
+
end
|
27
|
+
|
28
|
+
def otto_input(name, type: "text", placeholder: "", value: "", required: false)
|
29
|
+
req_attr = required ? "required" : ""
|
30
|
+
val_attr = value.empty? ? "" : %{value="#{escape_html(value)}"}
|
31
|
+
|
32
|
+
<<~HTML
|
33
|
+
<input
|
34
|
+
type="#{type}"
|
35
|
+
name="#{name}"
|
36
|
+
placeholder="#{escape_html(placeholder)}"
|
37
|
+
#{val_attr}
|
38
|
+
#{req_attr}
|
39
|
+
class="otto-input"
|
40
|
+
/>
|
41
|
+
HTML
|
42
|
+
end
|
43
|
+
|
44
|
+
def otto_textarea(name, placeholder: "", value: "", rows: 4, required: false)
|
45
|
+
req_attr = required ? "required" : ""
|
46
|
+
|
47
|
+
<<~HTML
|
48
|
+
<textarea
|
49
|
+
name="#{name}"
|
50
|
+
rows="#{rows}"
|
51
|
+
placeholder="#{escape_html(placeholder)}"
|
52
|
+
#{req_attr}
|
53
|
+
class="otto-input"
|
54
|
+
>#{escape_html(value)}</textarea>
|
55
|
+
HTML
|
56
|
+
end
|
57
|
+
|
58
|
+
def otto_button(text, type: "submit", variant: "primary", size: "default")
|
59
|
+
size_class = size == "small" ? "otto-btn-sm" : ""
|
60
|
+
|
61
|
+
<<~HTML
|
62
|
+
<button type="#{type}" class="otto-btn otto-btn-#{variant} #{size_class}">
|
63
|
+
#{escape_html(text)}
|
64
|
+
</button>
|
65
|
+
HTML
|
66
|
+
end
|
67
|
+
|
68
|
+
def otto_alert(type, title, message, dismissible: false)
|
69
|
+
dismiss_btn = dismissible ? '<button class="otto-alert-dismiss" onclick="this.parentElement.remove()">×</button>' : ""
|
70
|
+
|
71
|
+
<<~HTML
|
72
|
+
<div class="otto-alert otto-alert-#{type}">
|
73
|
+
#{dismiss_btn}
|
74
|
+
<h3 class="otto-alert-title">#{escape_html(title)}</h3>
|
75
|
+
<p class="otto-alert-message">#{escape_html(message)}</p>
|
76
|
+
</div>
|
77
|
+
HTML
|
78
|
+
end
|
79
|
+
|
80
|
+
def otto_card(title = nil, &block)
|
81
|
+
content = block_given? ? yield : ""
|
82
|
+
title_html = title ? "<h2 class=\"otto-card-title\">#{escape_html(title)}</h2>" : ""
|
83
|
+
|
84
|
+
<<~HTML
|
85
|
+
<div class="otto-card">
|
86
|
+
#{title_html}
|
87
|
+
#{content}
|
88
|
+
</div>
|
89
|
+
HTML
|
90
|
+
end
|
91
|
+
|
92
|
+
def otto_link(text, href, external: false)
|
93
|
+
target_attr = external ? 'target="_blank" rel="noopener noreferrer"' : ""
|
94
|
+
|
95
|
+
<<~HTML
|
96
|
+
<a href="#{escape_html(href)}" class="otto-link" #{target_attr}>
|
97
|
+
#{escape_html(text)}
|
98
|
+
</a>
|
99
|
+
HTML
|
100
|
+
end
|
101
|
+
|
102
|
+
def otto_code_block(code, language = "")
|
103
|
+
<<~HTML
|
104
|
+
<div class="otto-code-block">
|
105
|
+
<pre><code class="language-#{language}">#{escape_html(code)}</code></pre>
|
106
|
+
</div>
|
107
|
+
HTML
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
def escape_html(text)
|
113
|
+
return '' if text.nil?
|
114
|
+
text.to_s
|
115
|
+
.gsub('&', '&')
|
116
|
+
.gsub('<', '<')
|
117
|
+
.gsub('>', '>')
|
118
|
+
.gsub('"', '"')
|
119
|
+
.gsub("'", ''')
|
120
|
+
end
|
121
|
+
|
122
|
+
def otto_styles
|
123
|
+
<<~CSS
|
124
|
+
<style>
|
125
|
+
:root {
|
126
|
+
/* Otto Character-Inspired Colors */
|
127
|
+
--otto-primary: #E879F9; /* Otto's pink shirt */
|
128
|
+
--otto-primary-dark: #C026D3; /* Deeper pink */
|
129
|
+
--otto-primary-light: #F3E8FF; /* Light pink tint */
|
130
|
+
--otto-secondary: #A855F7; /* Otto's purple shorts */
|
131
|
+
--otto-accent: #FB923C; /* Otto's orange hat */
|
132
|
+
|
133
|
+
/* Semantic Colors */
|
134
|
+
--otto-success: #059669;
|
135
|
+
--otto-success-light: #D1FAE5;
|
136
|
+
--otto-warning: #D97706;
|
137
|
+
--otto-warning-light: #FEF3C7;
|
138
|
+
--otto-error: #DC2626;
|
139
|
+
--otto-error-light: #FEE2E2;
|
140
|
+
--otto-info: #0284C7;
|
141
|
+
--otto-info-light: #E0F2FE;
|
142
|
+
|
143
|
+
/* Neutral Palette */
|
144
|
+
--otto-gray-50: #F9FAFB;
|
145
|
+
--otto-gray-100: #F3F4F6;
|
146
|
+
--otto-gray-200: #E5E7EB;
|
147
|
+
--otto-gray-300: #D1D5DB;
|
148
|
+
--otto-gray-400: #9CA3AF;
|
149
|
+
--otto-gray-500: #6B7280;
|
150
|
+
--otto-gray-600: #4B5563;
|
151
|
+
--otto-gray-700: #374151;
|
152
|
+
--otto-gray-800: #1F2937;
|
153
|
+
--otto-gray-900: #111827;
|
154
|
+
|
155
|
+
/* Typography */
|
156
|
+
--otto-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
157
|
+
--otto-font-mono: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
158
|
+
|
159
|
+
/* Spacing Scale */
|
160
|
+
--otto-space-xs: 0.25rem;
|
161
|
+
--otto-space-sm: 0.5rem;
|
162
|
+
--otto-space-md: 1rem;
|
163
|
+
--otto-space-lg: 1.5rem;
|
164
|
+
--otto-space-xl: 2rem;
|
165
|
+
--otto-space-2xl: 3rem;
|
166
|
+
|
167
|
+
/* Border Radius */
|
168
|
+
--otto-radius-sm: 4px;
|
169
|
+
--otto-radius-md: 8px;
|
170
|
+
--otto-radius-lg: 12px;
|
171
|
+
--otto-radius-xl: 16px;
|
172
|
+
|
173
|
+
/* Shadows */
|
174
|
+
--otto-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
175
|
+
--otto-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
176
|
+
--otto-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
177
|
+
--otto-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
178
|
+
|
179
|
+
/* Transitions */
|
180
|
+
--otto-transition: all 0.2s ease;
|
181
|
+
--otto-transition-fast: all 0.1s ease;
|
182
|
+
--otto-transition-slow: all 0.3s ease;
|
183
|
+
}
|
184
|
+
|
185
|
+
* {
|
186
|
+
box-sizing: border-box;
|
187
|
+
}
|
188
|
+
|
189
|
+
body {
|
190
|
+
font-family: var(--otto-font-family);
|
191
|
+
line-height: 1.6;
|
192
|
+
color: var(--otto-gray-900);
|
193
|
+
background: linear-gradient(135deg, var(--otto-gray-50) 0%, #ffffff 100%);
|
194
|
+
margin: 0;
|
195
|
+
min-height: 100vh;
|
196
|
+
font-size: 16px;
|
197
|
+
}
|
198
|
+
|
199
|
+
/* Logo Component */
|
200
|
+
.otto-logo {
|
201
|
+
max-width: 120px;
|
202
|
+
height: auto;
|
203
|
+
margin-bottom: var(--otto-space-md);
|
204
|
+
border-radius: var(--otto-radius-md);
|
205
|
+
display: block;
|
206
|
+
margin-left: auto;
|
207
|
+
margin-right: auto;
|
208
|
+
}
|
209
|
+
|
210
|
+
/* Layout Components */
|
211
|
+
.otto-container {
|
212
|
+
max-width: 800px;
|
213
|
+
margin: 0 auto;
|
214
|
+
padding: var(--otto-space-xl);
|
215
|
+
}
|
216
|
+
|
217
|
+
.otto-card {
|
218
|
+
background: white;
|
219
|
+
padding: var(--otto-space-xl);
|
220
|
+
margin-bottom: var(--otto-space-xl);
|
221
|
+
border-radius: var(--otto-radius-lg);
|
222
|
+
box-shadow: var(--otto-shadow);
|
223
|
+
border: 1px solid var(--otto-gray-100);
|
224
|
+
}
|
225
|
+
|
226
|
+
.otto-card-title {
|
227
|
+
margin: 0 0 var(--otto-space-lg) 0;
|
228
|
+
color: var(--otto-gray-900);
|
229
|
+
font-size: 1.5rem;
|
230
|
+
font-weight: 600;
|
231
|
+
}
|
232
|
+
|
233
|
+
/* Form Components */
|
234
|
+
.otto-form {
|
235
|
+
display: flex;
|
236
|
+
flex-direction: column;
|
237
|
+
gap: var(--otto-space-md);
|
238
|
+
}
|
239
|
+
|
240
|
+
.otto-input {
|
241
|
+
padding: 0.75rem;
|
242
|
+
border: 2px solid var(--otto-gray-200);
|
243
|
+
border-radius: var(--otto-radius-md);
|
244
|
+
font-size: 1rem;
|
245
|
+
transition: var(--otto-transition);
|
246
|
+
background: white;
|
247
|
+
font-family: var(--otto-font-family);
|
248
|
+
}
|
249
|
+
|
250
|
+
.otto-input:focus {
|
251
|
+
outline: none;
|
252
|
+
border-color: var(--otto-primary);
|
253
|
+
box-shadow: 0 0 0 3px rgba(45, 125, 210, 0.1);
|
254
|
+
}
|
255
|
+
|
256
|
+
.otto-input::placeholder {
|
257
|
+
color: var(--otto-gray-400);
|
258
|
+
}
|
259
|
+
|
260
|
+
/* Button Components */
|
261
|
+
.otto-btn {
|
262
|
+
padding: 0.75rem var(--otto-space-lg);
|
263
|
+
border: none;
|
264
|
+
border-radius: var(--otto-radius-md);
|
265
|
+
font-size: 1rem;
|
266
|
+
font-weight: 600;
|
267
|
+
cursor: pointer;
|
268
|
+
transition: var(--otto-transition);
|
269
|
+
text-decoration: none;
|
270
|
+
display: inline-flex;
|
271
|
+
align-items: center;
|
272
|
+
justify-content: center;
|
273
|
+
min-height: 44px;
|
274
|
+
font-family: var(--otto-font-family);
|
275
|
+
}
|
276
|
+
|
277
|
+
.otto-btn-primary {
|
278
|
+
background: linear-gradient(135deg, var(--otto-primary) 0%, var(--otto-primary-dark) 100%);
|
279
|
+
color: white;
|
280
|
+
box-shadow: var(--otto-shadow);
|
281
|
+
}
|
282
|
+
|
283
|
+
.otto-btn-primary:hover {
|
284
|
+
transform: translateY(-1px);
|
285
|
+
box-shadow: var(--otto-shadow-lg);
|
286
|
+
}
|
287
|
+
|
288
|
+
.otto-btn-secondary {
|
289
|
+
background: var(--otto-gray-100);
|
290
|
+
color: var(--otto-gray-700);
|
291
|
+
border: 1px solid var(--otto-gray-200);
|
292
|
+
}
|
293
|
+
|
294
|
+
.otto-btn-secondary:hover {
|
295
|
+
background: var(--otto-gray-200);
|
296
|
+
transform: translateY(-1px);
|
297
|
+
}
|
298
|
+
|
299
|
+
.otto-btn-sm {
|
300
|
+
padding: 0.5rem var(--otto-space-md);
|
301
|
+
font-size: 0.875rem;
|
302
|
+
min-height: 36px;
|
303
|
+
}
|
304
|
+
|
305
|
+
.otto-btn:active {
|
306
|
+
transform: translateY(0);
|
307
|
+
}
|
308
|
+
|
309
|
+
.otto-btn:disabled {
|
310
|
+
opacity: 0.6;
|
311
|
+
cursor: not-allowed;
|
312
|
+
transform: none !important;
|
313
|
+
}
|
314
|
+
|
315
|
+
/* Link Components */
|
316
|
+
.otto-link {
|
317
|
+
color: var(--otto-primary);
|
318
|
+
text-decoration: none;
|
319
|
+
font-weight: 500;
|
320
|
+
transition: var(--otto-transition);
|
321
|
+
}
|
322
|
+
|
323
|
+
.otto-link:hover {
|
324
|
+
color: var(--otto-primary-dark);
|
325
|
+
text-decoration: underline;
|
326
|
+
}
|
327
|
+
|
328
|
+
/* Alert Components */
|
329
|
+
.otto-alert {
|
330
|
+
padding: var(--otto-space-lg);
|
331
|
+
border-radius: var(--otto-radius-md);
|
332
|
+
margin-bottom: var(--otto-space-lg);
|
333
|
+
border-left: 4px solid;
|
334
|
+
position: relative;
|
335
|
+
}
|
336
|
+
|
337
|
+
.otto-alert-title {
|
338
|
+
margin: 0 0 var(--otto-space-sm) 0;
|
339
|
+
font-size: 1.125rem;
|
340
|
+
font-weight: 600;
|
341
|
+
}
|
342
|
+
|
343
|
+
.otto-alert-message {
|
344
|
+
margin: 0;
|
345
|
+
line-height: 1.5;
|
346
|
+
}
|
347
|
+
|
348
|
+
.otto-alert-success {
|
349
|
+
background-color: var(--otto-success-light);
|
350
|
+
border-left-color: var(--otto-success);
|
351
|
+
color: #166534;
|
352
|
+
}
|
353
|
+
|
354
|
+
.otto-alert-error {
|
355
|
+
background-color: var(--otto-error-light);
|
356
|
+
border-left-color: var(--otto-error);
|
357
|
+
color: #991B1B;
|
358
|
+
}
|
359
|
+
|
360
|
+
.otto-alert-warning {
|
361
|
+
background-color: var(--otto-warning-light);
|
362
|
+
border-left-color: var(--otto-warning);
|
363
|
+
color: #92400E;
|
364
|
+
}
|
365
|
+
|
366
|
+
.otto-alert-info {
|
367
|
+
background-color: var(--otto-info-light);
|
368
|
+
border-left-color: var(--otto-info);
|
369
|
+
color: #1E40AF;
|
370
|
+
}
|
371
|
+
|
372
|
+
.otto-alert-dismiss {
|
373
|
+
position: absolute;
|
374
|
+
top: var(--otto-space-sm);
|
375
|
+
right: var(--otto-space-sm);
|
376
|
+
background: none;
|
377
|
+
border: none;
|
378
|
+
font-size: 1.5rem;
|
379
|
+
cursor: pointer;
|
380
|
+
color: inherit;
|
381
|
+
opacity: 0.7;
|
382
|
+
width: 24px;
|
383
|
+
height: 24px;
|
384
|
+
display: flex;
|
385
|
+
align-items: center;
|
386
|
+
justify-content: center;
|
387
|
+
}
|
388
|
+
|
389
|
+
.otto-alert-dismiss:hover {
|
390
|
+
opacity: 1;
|
391
|
+
}
|
392
|
+
|
393
|
+
/* Code Components */
|
394
|
+
.otto-code-block {
|
395
|
+
background: var(--otto-gray-50);
|
396
|
+
border: 1px solid var(--otto-gray-200);
|
397
|
+
border-radius: var(--otto-radius-md);
|
398
|
+
overflow: auto;
|
399
|
+
}
|
400
|
+
|
401
|
+
.otto-code-block pre {
|
402
|
+
margin: 0;
|
403
|
+
padding: var(--otto-space-md);
|
404
|
+
font-family: var(--otto-font-mono);
|
405
|
+
font-size: 0.875rem;
|
406
|
+
line-height: 1.4;
|
407
|
+
}
|
408
|
+
|
409
|
+
.otto-code-block code {
|
410
|
+
color: var(--otto-gray-800);
|
411
|
+
}
|
412
|
+
|
413
|
+
/* Utility Classes */
|
414
|
+
.otto-text-center { text-align: center; }
|
415
|
+
.otto-text-left { text-align: left; }
|
416
|
+
.otto-text-right { text-align: right; }
|
417
|
+
|
418
|
+
.otto-mb-0 { margin-bottom: 0; }
|
419
|
+
.otto-mb-sm { margin-bottom: var(--otto-space-sm); }
|
420
|
+
.otto-mb-md { margin-bottom: var(--otto-space-md); }
|
421
|
+
.otto-mb-lg { margin-bottom: var(--otto-space-lg); }
|
422
|
+
|
423
|
+
.otto-mt-0 { margin-top: 0; }
|
424
|
+
.otto-mt-sm { margin-top: var(--otto-space-sm); }
|
425
|
+
.otto-mt-md { margin-top: var(--otto-space-md); }
|
426
|
+
.otto-mt-lg { margin-top: var(--otto-space-lg); }
|
427
|
+
|
428
|
+
/* Responsive Design */
|
429
|
+
@media (max-width: 640px) {
|
430
|
+
.otto-container {
|
431
|
+
padding: var(--otto-space-md);
|
432
|
+
}
|
433
|
+
|
434
|
+
.otto-card {
|
435
|
+
padding: var(--otto-space-lg);
|
436
|
+
}
|
437
|
+
|
438
|
+
.otto-btn {
|
439
|
+
width: 100%;
|
440
|
+
}
|
441
|
+
}
|
442
|
+
|
443
|
+
/* Print Styles */
|
444
|
+
@media print {
|
445
|
+
.otto-btn,
|
446
|
+
.otto-alert-dismiss {
|
447
|
+
display: none;
|
448
|
+
}
|
449
|
+
|
450
|
+
body {
|
451
|
+
background: white;
|
452
|
+
}
|
453
|
+
|
454
|
+
.otto-card {
|
455
|
+
box-shadow: none;
|
456
|
+
border: 1px solid var(--otto-gray-300);
|
457
|
+
}
|
458
|
+
}
|
459
|
+
</style>
|
460
|
+
CSS
|
461
|
+
end
|
462
|
+
end
|
463
|
+
end
|
data/lib/otto/helpers/request.rb
CHANGED
@@ -1,61 +1,172 @@
|
|
1
|
+
# lib/otto/helpers/request.rb
|
2
|
+
|
1
3
|
class Otto
|
2
4
|
module RequestHelpers
|
3
5
|
def user_agent
|
4
6
|
env['HTTP_USER_AGENT']
|
5
7
|
end
|
8
|
+
|
6
9
|
def client_ipaddress
|
7
|
-
env['
|
8
|
-
|
9
|
-
|
10
|
+
remote_addr = env['REMOTE_ADDR']
|
11
|
+
|
12
|
+
# If we don't have a security config or trusted proxies, use direct connection
|
13
|
+
if !otto_security_config || !trusted_proxy?(remote_addr)
|
14
|
+
return validate_ip_address(remote_addr)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Check forwarded headers from trusted proxies
|
18
|
+
forwarded_ips = [
|
19
|
+
env['HTTP_X_FORWARDED_FOR'],
|
20
|
+
env['HTTP_X_REAL_IP'],
|
21
|
+
env['HTTP_CLIENT_IP']
|
22
|
+
].compact.map { |header| header.split(/,\s*/) }.flatten
|
23
|
+
|
24
|
+
# Return the first valid IP that's not a private/loopback address
|
25
|
+
forwarded_ips.each do |ip|
|
26
|
+
clean_ip = validate_ip_address(ip.strip)
|
27
|
+
return clean_ip if clean_ip && !private_ip?(clean_ip)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Fallback to remote address
|
31
|
+
validate_ip_address(remote_addr)
|
32
|
+
end
|
33
|
+
|
10
34
|
def request_method
|
11
35
|
env['REQUEST_METHOD']
|
12
36
|
end
|
37
|
+
|
13
38
|
def current_server
|
14
39
|
[current_server_name, env['SERVER_PORT']].join(':')
|
15
40
|
end
|
41
|
+
|
16
42
|
def current_server_name
|
17
43
|
env['SERVER_NAME']
|
18
44
|
end
|
45
|
+
|
19
46
|
def http_host
|
20
47
|
env['HTTP_HOST']
|
21
48
|
end
|
49
|
+
|
22
50
|
def request_path
|
23
51
|
env['REQUEST_PATH']
|
24
52
|
end
|
53
|
+
|
25
54
|
def request_uri
|
26
55
|
env['REQUEST_URI']
|
27
56
|
end
|
57
|
+
|
28
58
|
def root_path
|
29
59
|
env['SCRIPT_NAME']
|
30
60
|
end
|
31
|
-
|
61
|
+
|
62
|
+
def absolute_suri(host = current_server_name)
|
32
63
|
prefix = local? ? 'http://' : 'https://'
|
33
64
|
[prefix, host, request_path].join
|
34
65
|
end
|
66
|
+
|
35
67
|
def local?
|
36
|
-
Otto.env?(:dev, :development)
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
68
|
+
return false unless Otto.env?(:dev, :development)
|
69
|
+
|
70
|
+
ip = client_ipaddress
|
71
|
+
return false unless ip
|
72
|
+
|
73
|
+
local_or_private_ip?(ip)
|
74
|
+
end
|
75
|
+
|
41
76
|
def secure?
|
42
|
-
#
|
43
|
-
|
44
|
-
|
45
|
-
|
77
|
+
# Check direct HTTPS connection
|
78
|
+
return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'
|
79
|
+
|
80
|
+
remote_addr = env['REMOTE_ADDR']
|
81
|
+
|
82
|
+
# Only trust forwarded proto headers from trusted proxies
|
83
|
+
if otto_security_config && trusted_proxy?(remote_addr)
|
84
|
+
# X-Scheme is set by nginx
|
85
|
+
# X-FORWARDED-PROTO is set by elastic load balancer
|
86
|
+
return env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
|
87
|
+
end
|
88
|
+
|
89
|
+
false
|
90
|
+
end
|
91
|
+
|
46
92
|
# See: http://stackoverflow.com/questions/10013812/how-to-prevent-jquery-ajax-from-following-a-redirect-after-a-post
|
47
93
|
def ajax?
|
48
94
|
env['HTTP_X_REQUESTED_WITH'].to_s.downcase == 'xmlhttprequest'
|
49
95
|
end
|
50
|
-
|
96
|
+
|
97
|
+
def cookie(name)
|
51
98
|
cookies[name.to_s]
|
52
99
|
end
|
53
|
-
|
100
|
+
|
101
|
+
def cookie?(name)
|
54
102
|
!cookie(name).to_s.empty?
|
55
103
|
end
|
104
|
+
|
56
105
|
def current_absolute_uri
|
57
106
|
prefix = secure? && !local? ? 'https://' : 'http://'
|
58
107
|
[prefix, http_host, request_path].join
|
59
108
|
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
def otto_security_config
|
113
|
+
# Try to get security config from various sources
|
114
|
+
if respond_to?(:otto) && otto.respond_to?(:security_config)
|
115
|
+
otto.security_config
|
116
|
+
elsif defined?(Otto) && Otto.respond_to?(:security_config)
|
117
|
+
Otto.security_config
|
118
|
+
else
|
119
|
+
nil
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def trusted_proxy?(ip)
|
124
|
+
config = otto_security_config
|
125
|
+
return false unless config
|
126
|
+
|
127
|
+
config.trusted_proxy?(ip)
|
128
|
+
end
|
129
|
+
|
130
|
+
def validate_ip_address(ip)
|
131
|
+
return nil if ip.nil? || ip.empty?
|
132
|
+
|
133
|
+
# Remove any port number
|
134
|
+
clean_ip = ip.split(':').first
|
135
|
+
|
136
|
+
# Basic IP format validation
|
137
|
+
return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
|
138
|
+
|
139
|
+
# Validate each octet
|
140
|
+
octets = clean_ip.split('.')
|
141
|
+
return nil unless octets.all? { |octet| (0..255).include?(octet.to_i) }
|
142
|
+
|
143
|
+
clean_ip
|
144
|
+
end
|
145
|
+
|
146
|
+
def private_ip?(ip)
|
147
|
+
return false unless ip
|
148
|
+
|
149
|
+
# RFC 1918 private ranges and loopback
|
150
|
+
private_ranges = [
|
151
|
+
/\A10\./, # 10.0.0.0/8
|
152
|
+
/\A172\.(1[6-9]|2[0-9]|3[01])\./, # 172.16.0.0/12
|
153
|
+
/\A192\.168\./, # 192.168.0.0/16
|
154
|
+
/\A169\.254\./, # 169.254.0.0/16 (link-local)
|
155
|
+
/\A224\./, # 224.0.0.0/4 (multicast)
|
156
|
+
/\A0\./ # 0.0.0.0/8
|
157
|
+
]
|
158
|
+
|
159
|
+
private_ranges.any? { |range| ip.match?(range) }
|
160
|
+
end
|
161
|
+
|
162
|
+
def local_or_private_ip?(ip)
|
163
|
+
return false unless ip
|
164
|
+
|
165
|
+
# Check for localhost
|
166
|
+
return true if ip == '127.0.0.1' || ip == '::1'
|
167
|
+
|
168
|
+
# Check for private IP ranges
|
169
|
+
private_ip?(ip)
|
170
|
+
end
|
60
171
|
end
|
61
172
|
end
|