otto 1.1.0.pre.alpha4 → 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.
@@ -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('&', '&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,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['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
+ 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