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.
data/README.md CHANGED
@@ -1,150 +1,99 @@
1
- # Otto - 1.0 (2024-04-05)
1
+ # Otto - 1.2 (2025-08-18)
2
2
 
3
- **Auto-define your rack-apps in plain-text.**
3
+ **Define your rack-apps in plain-text with built-in security.**
4
4
 
5
- ## Overview
5
+ ![Otto mascot](public/img/otto.jpg "Otto - All Rack, no Pinion")
6
6
 
7
- Apps built with Otto have three, basic parts: a rackup file, a ruby file, and a routes file. If you've built a [Rack app](https://github.com/rack/rack) before, then you've seen a rackup file before. The ruby file is your actual app and the routes file is what Otto uses to map URI paths to a Ruby class and method.
8
-
9
- A barebones app directory looks something like this:
7
+ Otto apps have three files: a rackup file, a Ruby class, and a routes file. The routes file is just plain text that maps URLs to Ruby methods.
10
8
 
11
9
  ```bash
12
- $ cd myapp
13
- $ ls
14
- config.ru app.rb routes
10
+ $ cd myapp && ls
11
+ config.ru app.rb routes
15
12
  ```
16
13
 
17
- See the examples/ directory for a working app.
14
+ ## Routes File
15
+ ```
16
+ # routes
18
17
 
18
+ GET / App#index
19
+ POST /feedback App#receive_feedback
20
+ GET /product/:id App#show_product
21
+ GET /robots.txt App#robots_text
22
+ GET /404 App#not_found
23
+ ```
19
24
 
20
- ### Routes
25
+ ## Ruby Class
26
+ ```ruby
27
+ # app.rb
21
28
 
22
- The routes file is just a plain-text file which defines the end points of your application. Each route has three parts:
29
+ class App
30
+ def initialize(req, res)
31
+ @req, @res = req, res
32
+ end
23
33
 
24
- * HTTP verb (GET, POST, PUT, DELETE or HEAD)
25
- * URI path
26
- * Ruby class and method to call
34
+ def index
35
+ res.body = '<h1>Hello Otto</h1>'
36
+ end
27
37
 
28
- Here is an example:
38
+ def show_product
39
+ product_id = req.params[:id]
40
+ res.body = "Product: #{product_id}"
41
+ end
29
42
 
30
- ```ruby
31
- GET / App#index
32
- POST / App#receive_feedback
33
- GET /redirect App#redirect
34
- GET /robots.txt App#robots_text
35
- GET /product/:prodid App#display_product
36
-
37
- # You can also define these handlers when no
38
- # route can be found or there's a server error. (optional)
39
- GET /404 App#not_found
40
- GET /500 App#server_error
43
+ def robots_text
44
+ res.header['Content-Type'] = "text/plain"
45
+ rules = 'User-agent: *', 'Disallow: /private/keep/out'
46
+ res.body = rules.join($/)
47
+ end
48
+ end
41
49
  ```
42
50
 
43
- ### App
51
+ ## Rackup File
52
+ ```ruby
53
+ # config.ru
44
54
 
45
- There is nothing special about the Ruby class. The only requirement is that the first two arguments to initialize be a Rack::Request object and a Rack::Response object. Otherwise, you can do anything you want. You're free to use any templating engine, any database mapper, etc. There is no magic.
55
+ require 'otto'
56
+ require 'app'
46
57
 
47
- ```ruby
48
- class App
49
- attr_reader :req, :res
50
-
51
- # Otto creates an instance of this class for every request
52
- # and passess the Rack::Request and Rack::Response objects.
53
- def initialize req, res
54
- @req, @res = req, res
55
- end
56
-
57
- def index
58
- res.header['Content-Type'] = "text/html; charset=utf-8"
59
- lines = [
60
- '<img src="/img/otto.jpg" /><br/><br/>',
61
- 'Send feedback:<br/>',
62
- '<form method="post"><input name="msg" /><input type="submit" /></form>',
63
- '<a href="/product/100">A product example</a>'
64
- ]
65
- res.body = lines.join($/)
66
- end
67
-
68
- def receive_feedback
69
- res.body = req.params.inspect
70
- end
71
-
72
- def redirect
73
- res.redirect '/robots.txt'
74
- end
75
-
76
- def robots_text
77
- res.header['Content-Type'] = "text/plain"
78
- rules = 'User-agent: *', 'Disallow: /private'
79
- res.body = rules.join($/)
80
- end
81
-
82
- def display_product
83
- res.header['Content-Type'] = "application/json; charset=utf-8"
84
- prodid = req.params[:prodid]
85
- res.body = '{"product":%s,"msg":"Hint: try another value"}' % [prodid]
86
- end
87
-
88
- def not_found
89
- res.status = 404
90
- res.body = "Item not found!"
91
- end
92
-
93
- def server_error
94
- res.status = 500
95
- res.body = "There was a server error!"
96
- end
97
- end
58
+ run Otto.new("./routes")
98
59
  ```
99
60
 
100
61
 
101
- ### Rackup
62
+ ## Security Features
102
63
 
103
- There is also nothing special about the rackup file. It just builds a Rack app using your routes file.
64
+ Otto includes optional security features for production apps:
104
65
 
105
66
  ```ruby
106
- require 'otto'
107
- require 'app'
108
-
109
- app = Otto.new("./routes")
110
-
111
- map('/') {
112
- run app
113
- }
67
+ # Enable security features
68
+ app = Otto.new("./routes", {
69
+ csrf_protection: true, # CSRF tokens and validation
70
+ request_validation: true, # Input sanitization and limits
71
+ trusted_proxies: ['10.0.0.0/8']
72
+ })
114
73
  ```
115
74
 
116
- See the examples/ directory for a working app.
75
+ Security features include CSRF protection, input validation, security headers, and trusted proxy configuration.
117
76
 
77
+ ## Requirements
118
78
 
119
- ## Installation
79
+ - Ruby 3.4+
80
+ - Rack 3.1+
120
81
 
121
- Get it in one of the following ways:
82
+ ## Installation
122
83
 
123
84
  ```bash
124
- $ gem install otto
125
-
126
- [ Add it to yer Gemfile]
127
- $ bundle install
128
-
129
- $ git clone git://github.com/delano/otto.git
85
+ gem install otto
130
86
  ```
131
87
 
88
+ ## AI Development Assistance
132
89
 
133
- You can also download via [tarball](https://github.com/delano/otto/tarball/latest) or [zip](https://github.com/delano/otto/zipball/latest).
134
-
135
-
136
- ## More Information
137
-
138
- * [Homepage](https://github.com/delano/otto)
139
-
140
-
141
- ## In the wild
142
-
143
- Services that use Otto:
144
-
145
- * [Onetime Secret](https://onetimesecret.com/) -- A safe way to share sensitive data.
90
+ Version 1.2.0's security features were developed with AI assistance:
146
91
 
92
+ * **Zed Agent (Claude Sonnet 4)** - Security implementation and testing
93
+ * **Claude Desktop** - Rack 3+ compatibility and debugging
94
+ * **GitHub Copilot** - Code completion
147
95
 
96
+ The maintainer remains responsible for all security decisions and implementation. We believe in transparency about development tools, especially for security-focused software.
148
97
 
149
98
  ## License
150
99
 
@@ -0,0 +1,78 @@
1
+ # examples/basic/app.rb (Streamlined with Design System)
2
+
3
+ require_relative '../../lib/otto/design_system'
4
+
5
+ class App
6
+ include Otto::DesignSystem
7
+
8
+ attr_reader :req, :res
9
+
10
+ def initialize(req, res)
11
+ @req = req
12
+ @res = res
13
+ res.headers['content-type'] = 'text/html; charset=utf-8'
14
+ end
15
+
16
+ def index
17
+ content = <<~HTML
18
+ <div class="otto-card otto-text-center">
19
+ <img src="/img/otto.jpg" alt="Otto Framework" class="otto-logo" />
20
+ <h1>Otto Framework</h1>
21
+ <p>Minimal Ruby web framework with style</p>
22
+ </div>
23
+
24
+ #{otto_card('Send Feedback') do
25
+ otto_form_wrapper do
26
+ otto_input('msg', placeholder: 'Your message...') +
27
+ otto_button('Send Feedback')
28
+ end
29
+ end}
30
+ HTML
31
+
32
+ res.send_secure_cookie :sess, 1_234_567, 3600
33
+ res.body = otto_page(content)
34
+ end
35
+
36
+ def receive_feedback
37
+ message = req.params['msg']&.strip
38
+
39
+ content = if message.nil? || message.empty?
40
+ otto_alert('error', 'Empty Message', 'Please enter a message before submitting.')
41
+ else
42
+ otto_alert('success', 'Feedback Received', 'Thanks for your message!') +
43
+ otto_card('Your Message') { otto_code_block(message, 'text') }
44
+ end
45
+
46
+ content += "<p>#{otto_link('← Back', '/')}</p>"
47
+ res.body = otto_page(content, 'Feedback')
48
+ end
49
+
50
+ def redirect
51
+ res.redirect '/robots.txt'
52
+ end
53
+
54
+ def robots_text
55
+ res.headers['content-type'] = 'text/plain'
56
+ res.body = ['User-agent: *', 'Disallow: /private'].join($/)
57
+ end
58
+
59
+ def display_product
60
+ res.headers['content-type'] = 'application/json; charset=utf-8'
61
+ prodid = req.params[:prodid]
62
+ res.body = format('{"product":%s,"msg":"Hint: try another value"}', prodid)
63
+ end
64
+
65
+ def not_found
66
+ res.status = 404
67
+ content = otto_alert('error', 'Not Found', 'The requested page could not be found.')
68
+ content += "<p>#{otto_link('← Home', '/')}</p>"
69
+ res.body = otto_page(content, '404')
70
+ end
71
+
72
+ def server_error
73
+ res.status = 500
74
+ content = otto_alert('error', 'Server Error', 'An internal server error occurred.')
75
+ content += "<p>#{otto_link('← Home', '/')}</p>"
76
+ res.body = otto_page(content, '500')
77
+ end
78
+ end
@@ -0,0 +1,30 @@
1
+ # examples/basic/config.ru
2
+
3
+ # OTTO EXAMPLE APP CONFIG - 2011-12-17
4
+ #
5
+ # Usage:
6
+ #
7
+ # $ thin -e dev -R config.ru -p 10770 start
8
+
9
+ public_path = File.expand_path('../../public', __dir__)
10
+
11
+ require_relative '../../lib/otto'
12
+ require_relative 'app'
13
+
14
+ app = Otto.new('routes')
15
+
16
+ # DEV: Run web apps with extra logging and reloading
17
+ if Otto.env?(:dev)
18
+
19
+ map('/') do
20
+ use Rack::CommonLogger
21
+ use Rack::Reloader, 0
22
+ app.option[:public] = public_path
23
+ app.add_static_path '/favicon.ico'
24
+ run app
25
+ end
26
+
27
+ # PROD: run the webapp on the metal
28
+ else
29
+ map('/') { run app }
30
+ end
@@ -0,0 +1,20 @@
1
+ # examples/basic/routes
2
+
3
+ # OTTO - ROUTES EXAMPLE
4
+
5
+ # Each route has three parts:
6
+ # * HTTP verb (GET, POST, PUT, DELETE or HEAD)
7
+ # * URI path
8
+ # * Ruby class and method to call
9
+
10
+ GET / App#index
11
+ POST / App#receive_feedback
12
+ GET /redirect App#redirect
13
+ GET /robots.txt App#robots_text
14
+
15
+ GET /bogus App#no_such_method
16
+
17
+ # You can also define these handlers when no
18
+ # route can be found or there's a server error. (optional)
19
+ GET /404 App#not_found
20
+ GET /500 App#server_error
@@ -0,0 +1,115 @@
1
+ # examples/basic/app.rb (Streamlined with Design System)
2
+
3
+ require_relative '../../lib/otto/design_system'
4
+
5
+ class App
6
+ include Otto::DesignSystem
7
+
8
+ attr_reader :req, :res
9
+
10
+ def initialize(req, res)
11
+ @req = req
12
+ @res = res
13
+ res.headers['content-type'] = 'text/html; charset=utf-8'
14
+ end
15
+
16
+ def index
17
+ # This demonstrates nested heredocs in Ruby
18
+ # The outer heredoc uses <<~HTML delimiter
19
+ content = <<~HTML
20
+ <div class="otto-card otto-text-center">
21
+ <img src="/img/otto.jpg" alt="Otto Framework" class="otto-logo" />
22
+ <h1>Otto Framework</h1>
23
+ <p>Minimal Ruby web framework with style</p>
24
+ </div>
25
+
26
+ #{otto_card('Dynamic Pages') do
27
+ # This is a nested heredoc within the outer HTML heredoc
28
+ # It uses a different delimiter (EXAMPLES) to avoid conflicts
29
+ # The #{} interpolation allows the inner heredoc to be embedded
30
+ <<~EXAMPLES
31
+ #{otto_link('Product #100', '/product/100')} - View product page<br>
32
+ #{otto_link('Product #42', '/product/42')} - Different product<br>
33
+ #{otto_link('API Data', '/product/100.json')} - JSON endpoint
34
+ EXAMPLES
35
+ end}
36
+ HTML
37
+
38
+ res.send_secure_cookie :sess, 1_234_567, 3600
39
+ res.body = otto_page(content)
40
+ end
41
+
42
+ def receive_feedback
43
+ message = req.params['msg']&.strip
44
+
45
+ content = if message.nil? || message.empty?
46
+ otto_alert('error', 'Empty Message', 'Please enter a message before submitting.')
47
+ else
48
+ otto_alert('success', 'Feedback Received', 'Thanks for your message!') +
49
+ otto_card('Your Message') { otto_code_block(message, 'text') }
50
+ end
51
+
52
+ content += "<p>#{otto_link('← Back', '/')}</p>"
53
+ res.body = otto_page(content, 'Feedback')
54
+ end
55
+
56
+ def redirect
57
+ res.redirect '/robots.txt'
58
+ end
59
+
60
+ def robots_text
61
+ res.headers['content-type'] = 'text/plain'
62
+ res.body = ['User-agent: *', 'Disallow: /private'].join($/)
63
+ end
64
+
65
+ def display_product
66
+ prodid = req.params[:prodid]
67
+
68
+ # Check if JSON is requested via .json extension or Accept header
69
+ wants_json = req.path_info.end_with?('.json') ||
70
+ req.env['HTTP_ACCEPT']&.include?('application/json')
71
+
72
+ if wants_json
73
+ res.headers['content-type'] = 'application/json; charset=utf-8'
74
+ res.body = format('{"product":%s,"msg":"Hint: try another value"}', prodid)
75
+ else
76
+ # Return HTML product page
77
+ product_data = {
78
+ 'Product ID' => prodid,
79
+ 'Name' => "Sample Product ##{prodid}",
80
+ 'Price' => "$#{rand(10..999)}.99",
81
+ 'Description' => 'This is a demonstration product showing dynamic routing with parameter :prodid',
82
+ 'Stock' => rand(0..50) > 5 ? 'In Stock' : 'Out of Stock',
83
+ }
84
+
85
+ product_html = product_data.map do |key, value|
86
+ "<p><strong>#{key}:</strong> #{escape_html(value.to_s)}</p>"
87
+ end.join
88
+
89
+ content = <<~HTML
90
+ #{otto_card('Product Details') { product_html }}
91
+
92
+ <p>
93
+ #{otto_link('← Back to Home', '/')} |
94
+ #{otto_link('View as JSON', "/product/#{prodid}.json")}
95
+ </p>
96
+ HTML
97
+
98
+ res.body = otto_page(content, "Product ##{prodid}")
99
+ end
100
+ end
101
+
102
+ def not_found
103
+ res.status = 404
104
+ content = otto_alert('error', 'Not Found', 'The requested page could not be found.')
105
+ content += "<p>#{otto_link('← Home', '/')}</p>"
106
+ res.body = otto_page(content, '404')
107
+ end
108
+
109
+ def server_error
110
+ res.status = 500
111
+ content = otto_alert('error', 'Server Error', 'An internal server error occurred.')
112
+ content += "<p>#{otto_link('← Home', '/')}</p>"
113
+ res.body = otto_page(content, '500')
114
+ end
115
+ end
@@ -0,0 +1,30 @@
1
+ # examples/basic/config.ru
2
+
3
+ # OTTO EXAMPLE APP CONFIG - 2025-08-18
4
+ #
5
+ # Usage:
6
+ #
7
+ # $ thin -e dev -R config.ru -p 10770 start
8
+
9
+ public_path = File.expand_path('../../public', __dir__)
10
+
11
+ require_relative '../../lib/otto'
12
+ require_relative 'app'
13
+
14
+ app = Otto.new('routes')
15
+
16
+ # DEV: Run web apps with extra logging and reloading
17
+ if Otto.env?(:dev)
18
+
19
+ map('/') do
20
+ use Rack::CommonLogger
21
+ use Rack::Reloader, 0
22
+ app.option[:public] = public_path
23
+ app.add_static_path '/favicon.ico'
24
+ run app
25
+ end
26
+
27
+ # PROD: run the webapp on the metal
28
+ else
29
+ map('/') { run app }
30
+ end
@@ -1,3 +1,5 @@
1
+ # examples/basic/routes
2
+
1
3
  # OTTO - ROUTES EXAMPLE
2
4
 
3
5
  # Each route has three parts:
@@ -13,7 +15,7 @@ GET /product/:prodid App#display_product
13
15
 
14
16
  GET /bogus App#no_such_method
15
17
 
16
- # You can also define these handlers when no
17
- # route can be found or there's a server error. (optional)
18
+ # You can also define these handlers when no
19
+ # route can be found or there's a server error. (optional)
18
20
  GET /404 App#not_found
19
- GET /500 App#server_error
21
+ GET /500 App#server_error