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.
- checksums.yaml +4 -4
- data/.gitignore +3 -0
- data/.rspec +4 -0
- data/.rubocop.yml +371 -25
- data/Gemfile +16 -5
- data/Gemfile.lock +115 -39
- data/LICENSE.txt +1 -1
- data/README.md +61 -112
- 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 +276 -0
- data/examples/security_features/config.ru +83 -0
- data/examples/security_features/routes +11 -0
- data/lib/otto/design_system.rb +463 -0
- data/lib/otto/helpers/request.rb +124 -15
- data/lib/otto/helpers/response.rb +72 -14
- data/lib/otto/route.rb +111 -19
- data/lib/otto/security/config.rb +312 -0
- data/lib/otto/security/csrf.rb +177 -0
- data/lib/otto/security/validator.rb +299 -0
- data/lib/otto/static.rb +18 -5
- data/lib/otto/version.rb +2 -24
- data/lib/otto.rb +378 -127
- data/otto.gemspec +15 -15
- metadata +30 -29
- data/CHANGES.txt +0 -35
- data/VERSION.yml +0 -4
- 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
data/README.md
CHANGED
@@ -1,150 +1,99 @@
|
|
1
|
-
# Otto - 1.
|
1
|
+
# Otto - 1.2 (2025-08-18)
|
2
2
|
|
3
|
-
**
|
3
|
+
**Define your rack-apps in plain-text with built-in security.**
|
4
4
|
|
5
|
-
|
5
|
+

|
6
6
|
|
7
|
-
|
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
|
-
|
13
|
-
|
14
|
-
config.ru app.rb routes
|
10
|
+
$ cd myapp && ls
|
11
|
+
config.ru app.rb routes
|
15
12
|
```
|
16
13
|
|
17
|
-
|
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
|
-
|
25
|
+
## Ruby Class
|
26
|
+
```ruby
|
27
|
+
# app.rb
|
21
28
|
|
22
|
-
|
29
|
+
class App
|
30
|
+
def initialize(req, res)
|
31
|
+
@req, @res = req, res
|
32
|
+
end
|
23
33
|
|
24
|
-
|
25
|
-
|
26
|
-
|
34
|
+
def index
|
35
|
+
res.body = '<h1>Hello Otto</h1>'
|
36
|
+
end
|
27
37
|
|
28
|
-
|
38
|
+
def show_product
|
39
|
+
product_id = req.params[:id]
|
40
|
+
res.body = "Product: #{product_id}"
|
41
|
+
end
|
29
42
|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
51
|
+
## Rackup File
|
52
|
+
```ruby
|
53
|
+
# config.ru
|
44
54
|
|
45
|
-
|
55
|
+
require 'otto'
|
56
|
+
require 'app'
|
46
57
|
|
47
|
-
|
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
|
-
|
62
|
+
## Security Features
|
102
63
|
|
103
|
-
|
64
|
+
Otto includes optional security features for production apps:
|
104
65
|
|
105
66
|
```ruby
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
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
|
-
|
75
|
+
Security features include CSRF protection, input validation, security headers, and trusted proxy configuration.
|
117
76
|
|
77
|
+
## Requirements
|
118
78
|
|
119
|
-
|
79
|
+
- Ruby 3.4+
|
80
|
+
- Rack 3.1+
|
120
81
|
|
121
|
-
|
82
|
+
## Installation
|
122
83
|
|
123
84
|
```bash
|
124
|
-
|
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
|
-
|
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
|