A skeleton web application configured to use Sinatra and ActiveRecord
1#
2# Copyright (c) 2017-2018 joshua stein <jcs@jcs.org>
3#
4# Permission to use, copy, modify, and distribute this software for any
5# purpose with or without fee is hereby granted, provided that the above
6# copyright notice and this permission notice appear in all copies.
7#
8# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
9# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
10# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
11# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
12# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
13# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
14# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
15#
16
17Encoding.default_internal = Encoding.default_external = Encoding::UTF_8
18
19APP_ROOT = File.realpath(File.dirname(__FILE__) + "/../")
20
21require "active_record"
22require "active_support/time"
23require "securerandom"
24
25require "sinatra/base"
26require "sinatra/namespace"
27require "sinatra/activerecord"
28require "cgi"
29require "rack/csrf"
30
31# configure mail early in case of exceptions
32require "pony"
33require "#{APP_ROOT}/config/mail.rb"
34
35# setup our custom logging to STDOUT
36require "#{APP_ROOT}/lib/logging.rb"
37
38# patch up Rack::Csrf to look at request.path for matching skipping rather than
39# request.path_info which is relative to the controller's path. since we don't
40# route to the per-request controller if Rack::Csrf aborts, we can't do
41# controller-relative matching anyway.
42module Rack
43 class Csrf
44 def any?(list, request)
45 pi = request.path.empty? ? '/' : request.path
46 list.any? do |route|
47 if route =~ (request.request_method + ':' + pi)
48 return true
49 end
50 end
51 end
52 end
53end
54
55class App < Sinatra::Base
56 register Sinatra::Namespace
57 register Sinatra::ActiveRecordExtension
58
59 # defaults, to be overridden with App.X = "..." in config/app.rb
60
61 # app name used in various places (emails, etc.)
62 cattr_accessor :name
63 @@name = "App"
64
65 # for controllers to be relative to a global base path
66 cattr_accessor :base_path
67 @@base_path = "/"
68
69 # to be replaced by an absolute url with scheme/domain
70 cattr_accessor :base_url
71 @@base_url = "/"
72
73 # email addresses to be notified of exceptions
74 cattr_accessor :exception_recipients
75 @@exception_recipients = []
76
77 # parameters to be filtered from exception notifications and verbose logging
78 cattr_accessor :filtered_parameters
79 # regexes or strings
80 @@filtered_parameters = [ /password/ ]
81
82
83 # where we're at
84 set :root, File.realpath(__dir__ + "/../")
85
86 # config for active record
87 set :database_file, "#{App.root}/db/config.yml"
88
89 # gathered later from all controllers
90 @@all_routes = {}
91 cattr_accessor :all_routes
92
93 # app/views/(controller)/
94 set :views, Proc.new { App.root + "/app/views/#{cur_controller}/" }
95
96 # app/views/layouts/(controller).erb or app/views/layouts/application.erb
97 set :erb, :layout => Proc.new {
98 @@layouts ||= {}
99 cc = cur_controller
100
101 if File.exist?(f = App.root + "/app/views/layouts/#{cc}.erb")
102 @@layouts[cc] ||= File.read(f)
103 else
104 @@layouts["application"] ||= File.read(App.root +
105 "/app/views/layouts/application.erb")
106 end
107 }
108
109 # store timestamps in the db in UTC, but convert to Time.zone time when
110 # instantiating objects
111 Time.zone = "Central Time (US & Canada)"
112 ActiveRecord::Base.time_zone_aware_attributes = true
113
114 # disable built-in apache-style logging in sinatra and disable color from AR
115 disable :logging
116 ActiveSupport::LogSubscriber.colorize_logging = false
117 @@logger = ::Logger.new(STDOUT)
118 use Sinatree::Logger, @@logger
119
120 # encrypted sessions, requiring a per-app secret to be configured
121 enable :sessions
122 set :sessions, {
123 :key => "_session",
124 :httponly => true,
125 :same_site => :lax,
126 }
127 begin
128 set :session_secret, File.read("#{App.root}/config/session_secret")
129 rescue => e
130 STDERR.puts e.message
131 STDERR.puts "no session secret file",
132 "ruby -e 'require \"securerandom\"; " +
133 "print SecureRandom.hex(64)' > config/session_secret"
134 exit 1
135 end
136
137 # allow erb views to be named view.html.erb
138 Tilt.prefer Tilt::ErubiTemplate
139 Tilt.register Tilt::ErubiTemplate, "html.erb"
140
141 # before every request, store controller for Logger
142 before do
143 request.current_controller = self.class
144 end
145
146 class << self
147 alias_method :env, :environment
148 attr_accessor :path
149
150 def cur_controller
151 raise
152 rescue => e
153 e.backtrace.each do |z|
154 if m = z.match(/app\/controllers\/(.+?)_controller\.rb:/)
155 return m[1]
156 end
157 end
158
159 nil
160 end
161
162 def filter_parameters(params)
163 params.reduce({}) do |acc, (key,value)|
164 if value.is_a?(Hash)
165 acc[key] = filter_parameters(value)
166 elsif App.filtered_parameters.detect{|fp| key.match(fp) }
167 acc[key] = "[filtered]"
168 else
169 acc[key] = value
170 end
171 acc
172 end
173 end
174
175 def logger
176 @@logger
177 end
178
179 def production?
180 env.to_s == "production"
181 end
182 def development?
183 env.to_s == "development"
184 end
185 def test?
186 env.to_s == "test"
187 end
188 end
189
190 def flash
191 session[:flash] ||= {}
192 end
193
194 def logger
195 @@logger
196 end
197
198 # per-environment configuration
199 if File.exist?(_c = "#{App.root}/config/#{App.environment}.rb")
200 require _c
201 end
202
203 # per-app initialization, not specific to environment
204 if File.exist?(_c = "#{App.root}/config/app.rb")
205 require _c
206 end
207end
208
209# bring in model base
210require "#{App.root}/lib/db.rb"
211require "#{App.root}/lib/db_model.rb"
212
213# and sinatra_more helpers
214require "#{App.root}/lib/sinatra_more/markup_plugin.rb"
215require "#{App.root}/lib/sinatra_more/render_plugin.rb"
216
217class App
218 include SinatraMore::AssetTagHelpers
219 include SinatraMore::FormHelpers
220 include SinatraMore::FormatHelpers
221 include SinatraMore::OutputHelpers
222 include SinatraMore::RenderHelpers
223 include SinatraMore::TagHelpers
224end
225
226# bring in user's mixins, models, and helpers
227Dir.glob("#{App.root}/app/mixins/*.rb").each{|f| require f }
228Dir.glob("#{App.root}/app/models/*.rb").each{|f| require f }
229
230require "#{App.root}/lib/helpers.rb"
231Dir.glob("#{App.root}/app/helpers/*.rb").each do |f|
232 require f
233end
234
235# and controllers, binding each's helper
236(
237 [ "#{App.root}/app/controllers/application_controller.rb" ] +
238 Dir.glob("#{App.root}/app/controllers/*.rb")
239).uniq.each do |f|
240 mc = Module.constants
241 require f
242 newmc = (Module.constants - mc)
243
244 if newmc.count != 1
245 raise "#{f} introduced #{newmc.count} new classes instead of 1"
246 end
247 cont = newmc[0]
248
249 if Kernel.const_defined?(c = cont.to_s.gsub(/Controller$/, "Helper"))
250 Kernel.const_get(cont).send(:helpers, Kernel.const_get(c))
251 end
252end
253
254# and extras
255Dir.glob("#{App.root}/app/extras/*.rb").each{|f| require f }
256
257# bring up active record connection
258Db.connect(:environment => App.env.to_s)
259
260# dynamically build routes for config.ru
261ApplicationController.subclasses.each do |subklass|
262 path = subklass.path
263 if !path
264 # WidgetsController -> "widgets"
265 path = "/" << ActiveSupport::Inflector.underscore(
266 subklass.to_s.gsub(/Controller$/, ""))
267 end
268
269 if !path.is_a?(Array)
270 path = [ path ]
271 end
272
273 path.each do |p|
274 if App.all_routes[p]
275 raise "duplicate route for #{p.inspect}: " <<
276 "#{App.all_routes[p].inspect} and #{subklass.inspect}"
277 end
278
279 App.all_routes[p] = subklass
280 end
281end