otto 1.1.0.pre.alpha4 → 1.3.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,463 @@
1
+ # lib/otto/design_system.rb
2
+
3
+ class Otto
4
+ # Shared design system for Otto framework examples
5
+ # Provides consistent styling, components, and utilities
6
+ module DesignSystem
7
+ def otto_page(content, title = 'Otto Framework', additional_head = '')
8
+ <<~HTML
9
+ <!DOCTYPE html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="UTF-8">
13
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
14
+ <title>#{escape_html(title)}</title>
15
+ #{otto_styles}
16
+ #{additional_head}
17
+ </head>
18
+ <body>
19
+ <div class="otto-container">
20
+ #{content}
21
+ </div>
22
+ </body>
23
+ </html>
24
+ HTML
25
+ end
26
+
27
+ def otto_input(name, type: 'text', placeholder: '', value: '', required: false)
28
+ req_attr = required ? 'required' : ''
29
+ val_attr = value.empty? ? '' : %(value="#{escape_html(value)}")
30
+
31
+ <<~HTML
32
+ <input
33
+ type="#{type}"
34
+ name="#{name}"
35
+ placeholder="#{escape_html(placeholder)}"
36
+ #{val_attr}
37
+ #{req_attr}
38
+ class="otto-input"
39
+ />
40
+ HTML
41
+ end
42
+
43
+ def otto_textarea(name, placeholder: '', value: '', rows: 4, required: false)
44
+ req_attr = required ? 'required' : ''
45
+
46
+ <<~HTML
47
+ <textarea
48
+ name="#{name}"
49
+ rows="#{rows}"
50
+ placeholder="#{escape_html(placeholder)}"
51
+ #{req_attr}
52
+ class="otto-input"
53
+ >#{escape_html(value)}</textarea>
54
+ HTML
55
+ end
56
+
57
+ def otto_button(text, type: 'submit', variant: 'primary', size: 'default')
58
+ size_class = size == 'small' ? 'otto-btn-sm' : ''
59
+
60
+ <<~HTML
61
+ <button type="#{type}" class="otto-btn otto-btn-#{variant} #{size_class}">
62
+ #{escape_html(text)}
63
+ </button>
64
+ HTML
65
+ end
66
+
67
+ def otto_alert(type, title, message, dismissible: false)
68
+ dismiss_btn = dismissible ? '<button class="otto-alert-dismiss" onclick="this.parentElement.remove()">×</button>' : ''
69
+
70
+ <<~HTML
71
+ <div class="otto-alert otto-alert-#{type}">
72
+ #{dismiss_btn}
73
+ <h3 class="otto-alert-title">#{escape_html(title)}</h3>
74
+ <p class="otto-alert-message">#{escape_html(message)}</p>
75
+ </div>
76
+ HTML
77
+ end
78
+
79
+ def otto_card(title = nil, &)
80
+ content = block_given? ? yield : ''
81
+ title_html = title ? "<h2 class=\"otto-card-title\">#{escape_html(title)}</h2>" : ''
82
+
83
+ <<~HTML
84
+ <div class="otto-card">
85
+ #{title_html}
86
+ #{content}
87
+ </div>
88
+ HTML
89
+ end
90
+
91
+ def otto_link(text, href, external: false)
92
+ target_attr = external ? 'target="_blank" rel="noopener noreferrer"' : ''
93
+
94
+ <<~HTML
95
+ <a href="#{escape_html(href)}" class="otto-link" #{target_attr}>
96
+ #{escape_html(text)}
97
+ </a>
98
+ HTML
99
+ end
100
+
101
+ def otto_code_block(code, language = '')
102
+ <<~HTML
103
+ <div class="otto-code-block">
104
+ <pre><code class="language-#{language}">#{escape_html(code)}</code></pre>
105
+ </div>
106
+ HTML
107
+ end
108
+
109
+ private
110
+
111
+ def escape_html(text)
112
+ return '' if text.nil?
113
+
114
+ text.to_s
115
+ .gsub('&', '&amp;')
116
+ .gsub('<', '&lt;')
117
+ .gsub('>', '&gt;')
118
+ .gsub('"', '&quot;')
119
+ .gsub("'", '&#x27;')
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
@@ -1,61 +1,170 @@
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['HTTP_X_FORWARDED_FOR'].to_s.split(/,\s*/).first ||
8
- env['HTTP_X_REAL_IP'] || env['REMOTE_ADDR']
9
- end
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
- def absolute_suri host=current_server_name
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
- (client_ipaddress == '127.0.0.1' ||
38
- !client_ipaddress.match(/^10\.0\./).nil? ||
39
- !client_ipaddress.match(/^192\.168\./).nil?)
40
- end
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
- # X-Scheme is set by nginx
43
- # X-FORWARDED-PROTO is set by elastic load balancer
44
- (env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == "https")
45
- end
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
- def cookie name
96
+
97
+ def cookie(name)
51
98
  cookies[name.to_s]
52
99
  end
53
- def cookie? name
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
+ end
119
+ end
120
+
121
+ def trusted_proxy?(ip)
122
+ config = otto_security_config
123
+ return false unless config
124
+
125
+ config.trusted_proxy?(ip)
126
+ end
127
+
128
+ def validate_ip_address(ip)
129
+ return nil if ip.nil? || ip.empty?
130
+
131
+ # Remove any port number
132
+ clean_ip = ip.split(':').first
133
+
134
+ # Basic IP format validation
135
+ return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
136
+
137
+ # Validate each octet
138
+ octets = clean_ip.split('.')
139
+ return nil unless octets.all? { |octet| (0..255).include?(octet.to_i) }
140
+
141
+ clean_ip
142
+ end
143
+
144
+ def private_ip?(ip)
145
+ return false unless ip
146
+
147
+ # RFC 1918 private ranges and loopback
148
+ private_ranges = [
149
+ /\A10\./, # 10.0.0.0/8
150
+ /\A172\.(1[6-9]|2[0-9]|3[01])\./, # 172.16.0.0/12
151
+ /\A192\.168\./, # 192.168.0.0/16
152
+ /\A169\.254\./, # 169.254.0.0/16 (link-local)
153
+ /\A224\./, # 224.0.0.0/4 (multicast)
154
+ /\A0\./, # 0.0.0.0/8
155
+ ]
156
+
157
+ private_ranges.any? { |range| ip.match?(range) }
158
+ end
159
+
160
+ def local_or_private_ip?(ip)
161
+ return false unless ip
162
+
163
+ # Check for localhost
164
+ return true if ['127.0.0.1', '::1'].include?(ip)
165
+
166
+ # Check for private IP ranges
167
+ private_ip?(ip)
168
+ end
60
169
  end
61
170
  end