kiss 0.9.4 → 1.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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.4
1
+ 1.0
@@ -31,6 +31,32 @@ module Rack
31
31
  autoload :ShowExceptions, 'kiss/rack/show_exceptions'
32
32
  end
33
33
 
34
+ module Digest; end
35
+
36
+ class Class
37
+ # adapted from Rails, re-written for speed (only one class_eval call)
38
+ def cattr_reader(*syms)
39
+ class_eval(
40
+ syms.flatten.map do |sym|
41
+ sym.is_a?(Hash) ? '' : %Q(
42
+ unless defined? @@#{sym}
43
+ @@#{sym} = nil
44
+ end
45
+
46
+ def self.#{sym}
47
+ @@#{sym}
48
+ end
49
+
50
+ def #{sym}
51
+ @@#{sym}
52
+ end
53
+
54
+ )
55
+ end.join, __FILE__, __LINE__
56
+ )
57
+ end
58
+ end
59
+
34
60
  # Kiss - An MVC web application framework for Ruby, built on:
35
61
  # * Erubis template engine
36
62
  # * Sequel database ORM library
@@ -40,523 +66,580 @@ class Kiss
40
66
  autoload :Mailer, 'kiss/mailer'
41
67
  autoload :Iterator, 'kiss/iterator'
42
68
  autoload :Form, 'kiss/form'
69
+ autoload :Format, 'kiss/format'
43
70
  autoload :SequelSession, 'kiss/sequel_session'
71
+ autoload :StaticFile, 'kiss/static_file'
72
+ autoload :Bench, 'kiss/bench'
73
+ autoload :Debug, 'kiss/debug'
74
+
75
+ @@digest = {
76
+ :MD5 => "digest/md5",
77
+ :RMD160 => "digest/rmd160",
78
+ :SHA1 => "digest/sha1",
79
+ :SHA256 => "digest/sha2",
80
+ :SHA384 => "digest/sha2",
81
+ :SHA512 => "digest/sha2"
82
+ }
83
+ @@digest.each_pair do |type,path|
84
+ Digest.autoload type, path
85
+ end
44
86
 
45
- attr_reader :action, :action_subdir, :action_path, :extension, :host, :template_dir,
46
- :email_template_dir, :upload_dir, :evolution_dir, :asset_dir, :public_dir, :params, :args, :db,
47
- :login, :request, :environment
87
+ # attributes below are application-wide
88
+ cattr_reader :action_dir, :template_dir, :email_template_dir, :model_dir, :upload_dir,
89
+ :evolution_dir, :asset_dir, :public_dir, :db, :environment, :options, :layout, :rack_file
48
90
 
49
- @@default_action = 'index'
91
+ # attributes below are request-specific
92
+ attr_reader :params, :args, :action, :action_subdir, :action_path, :extension, :host, :request,
93
+ :session, :login
50
94
 
51
- @@cookie_name = 'Kiss'
95
+ attr_accessor :last_sql
96
+
97
+ @@default_action = 'index'
98
+ @@default_cookie_name = 'Kiss'
52
99
 
53
100
  # these supplement the mime types from Rack::File
54
101
  @@mime_types = {
55
102
  'rhtml' => 'text/html'
56
103
  }
57
104
 
58
- # Purposely empty class.
59
- # ActionDone exception is raised when render complete to abort execution.
60
- # Could probably use throw...catch instead.
61
- class ActionDone < Exception; end
62
-
63
105
  ### Class Methods
64
106
 
65
- # Creates a new Kiss instance and runs it.
66
- def self.run(options = {})
67
- new(options).run
68
- end
69
-
70
- # Converts date strings from m/d/y to y/m/d. Useful for MySQL.
71
- def self.mdy_to_ymd(date)
72
- return '0000-00-00' unless date && date =~ /\S/
73
- date.sub!(/\A\D+/,'')
74
- date.sub!(/\D+\Z/,'')
75
-
76
- month, day, year = date.split(/\D+/,3).each {|s| s.to_i}
77
-
78
- return 0 unless month && day
79
-
80
- current_year = Time.now.year
81
- if !year || year.length == 0
82
- # use current year if year is missing.
83
- year = current_year
84
- else
85
- # convert two-digit years to four-digit years
86
- year = year.to_i
87
- if year < 100
88
- year += 1900
89
- year += 100 if year < current_year - 95
90
- end
107
+ class << self
108
+ # Creates new controller instance to handle Rack request.
109
+ def call(env)
110
+ new.call(env)
91
111
  end
92
112
 
93
- return sprintf("%04d-%02d-%02d",year,month.to_i,day.to_i)
94
- end
95
-
96
- # Returns regexp and error message for validating specified format.
97
- # Will likely be rewritten in a future version of Kiss.
98
- def self.format_details(format)
99
- if format.is_a?(Regexp)
100
- return format, 'invalid value'
101
- end
113
+ # Runs Kiss application found at project_dir (default: '..'), with options
114
+ # read from config files plus additional options if passed in.
115
+ def run(options = nil)
116
+ begin
117
+ if @@options
118
+ merge_options(options) if options
119
+ else
120
+ load(options)
121
+ end
102
122
 
103
- case format
104
- when :integer
105
- return /\A\-?\d+\Z/,
106
- 'must be an integer'
123
+ # TODO: rewrite evolution file exists check for speed
124
+ check_evolution_number if @@db
125
+
126
+ app = self
127
+ builder_options = @@options[:rack_builder] || []
128
+ rack = Rack::Builder.new do
129
+ builder_options.each do |builder_option|
130
+ if builder_option.is_a?(Array)
131
+ builder_args = builder_option
132
+ builder_option = builder_args.shift
133
+ else
134
+ builder_args = []
135
+ end
107
136
 
108
- when :integer_positive, :positive_integer, :id
109
- return /\A\d*[1-9]\d*\Z/,
110
- 'must be a positive integer'
111
-
112
- when :integer_unsigned, :unsigned_integer, :id_or_zero, :id_zero
113
- return /\A\d+\Z/,
114
- 'must be a positive integer or zero'
115
-
116
- when :integer_negative, :negative_integer
117
- return /\A\-\d*[1-9]\d*\Z/,
118
- 'must be a negative integer'
137
+ unless builder_option.is_a?(Class)
138
+ builder_option = Rack.const_get(builder_option.to_s)
139
+ end
119
140
 
120
- when :decimal
121
- return /\A\-?(\d+(\.\d*)?|\.\d+)\Z/,
122
- 'must be a decimal number'
141
+ use(builder_option,*builder_args)
142
+ end
123
143
 
124
- when :alphanum
125
- return /\A[a-z0-9]+\Z/,
126
- 'only letters and numbers'
127
-
128
- when :word
129
- return /\A\w+\Z/,
130
- 'only letters, numbers, and _'
131
-
132
- when :email_address
133
- return /\A[A-Z0-9._%+-]+\@([A-Z0-9-]+\.)+[A-Z]{2,4}\Z/i,
134
- 'must be a valid email address'
135
-
136
- when :date
137
- return /\A\d+\D\d+(\D\d+)?\Z/,
138
- 'must be a valid date'
139
-
140
- when :time
141
- return /\A\d+\:\d+\s*[ap]m\Z/i,
142
- 'must be a valid time'
143
-
144
- when :datetime
145
- return /\A\d+\D\d+(\D\d+)?\s+\d{1,2}\:\d{2}\s*[ap]m\Z/i,
146
- 'must be a valid date and time'
144
+ run app
145
+ end
147
146
 
148
- end
149
- end
150
-
151
- # Validates value against specified format.
152
- # If required is true, value must contain a non-whitespace character.
153
- # If required is false, value need not match format if and only if value contains only whitespace.
154
- def self.validate_value(value,format,required = false)
155
- if required && (value !~ /\S/)
156
- # value required
157
- return 'required'
158
- elsif format && (value =~ /\S/)
159
- regexp,error = format_details(format)
160
- return error unless value =~ regexp
147
+ handler = @@options[:rack_handler] || Rack::Handler::WEBrick
148
+ if !handler.is_a?(Class)
149
+ handler = Rack::Handler.const_get(handler.to_s)
150
+ end
151
+ handler.run(rack,@@options[:rack_handler_options] || {:Port => 4000})
152
+ rescue StandardError, LoadError, SyntaxError => e
153
+ if @@options[:rack_handler] == :CGI
154
+ print "Content-type: text/html\n\n"
155
+ print Kiss::ExceptionReport.generate(e)
156
+ else
157
+ print "Content-type: text/plain\n\n"
158
+ puts "exception:\n" + e.message
159
+ puts "\ntraceback:\n" + e.backtrace.join("\n")
160
+ end
161
+ end
161
162
  end
162
163
 
163
- return nil
164
- end
165
-
166
- # Generates string of random text of the specified length.
167
- def self.random_text(length)
168
- chars = ('A'..'Z').to_a + ('0'..'9').to_a # array
169
-
170
- text = ''
171
- size = chars.size
172
- length.times { text += chars[rand(size)] }
164
+ # Load and set up Kiss application from config file options and
165
+ # any passed-in options.
166
+ def load(loader_options = nil)
167
+ # store cached files
168
+ @@file_cache = {}
169
+ @@directory_cache = {}
170
+ @@file_cache_time = {}
171
+
172
+ loader_options ||= {}
173
+ # if loader_options is string, then it specifies environment
174
+ # else it should be a hash of config options
175
+ if loader_options.is_a?(String)
176
+ loader_options = { :environment => loader_options }
177
+ end
173
178
 
174
- text
175
- end
176
-
177
- # Returns exception cache, for use in Kiss::ExceptionReport.
178
- def self.exception_cache
179
- @@exception_cache
180
- end
181
-
182
- ### Instance Methods
183
-
184
- # Adds specified data to exception cache, to be included in reports generated
185
- # by Kiss::ExceptionReport in case of an exception.
186
- def set_exception_cache(data)
187
- @@exception_cache.merge!(data)
188
- end
189
-
190
- # Clears exception cache.
191
- def clear_exception_cache
192
- @@exception_cache = {}
193
- end
194
-
195
- # Generates string of random text of the specified length.
196
- def random_text(*args)
197
- self.class.random_text(*args)
198
- end
199
-
200
- # Returns URL/URI of app root (corresponding to top level of action_dir).
201
- def app(suffix = nil)
202
- suffix ? @app_url + suffix : @app_url
203
- end
204
-
205
- # Returns path of current action, under action_dir.
206
- def action
207
- @action
208
- end
209
-
210
- # Kiss Model cache, used to invoke and store Kiss database models.
211
- #
212
- # Example:
213
- # models[:users] : database model for `users' table
214
- def models
215
- @dbm
216
- end
217
- alias_method :dbm, :models
218
-
219
- # Returns URL/URI of app's static assets (asset_host or public_uri).
220
- def assets(suffix = nil)
221
- @pub ||= @options[:asset_host]
222
- suffix ? @pub + '/' + suffix : @pub
223
- end
224
- alias_method :pub, :assets
225
-
226
- # Returns true if specified path is a directory.
227
- # Cache result if file_cache_no_reload option is set; otherwise, always check filesystem.
228
- def directory_exists?(dir)
229
- @options[:file_cache_no_reload] ? (
230
- @directory_cache.has_key?(path) ?
231
- @directory_cache[dir] :
232
- @directory_cache[dir] = File.directory?(dir)
233
- ) : File.directory?(dir)
234
- end
235
-
236
- # Caches the specified file and return its contents.
237
- # If block given, executes block on contents, then cache and return block result.
238
- # If fnf_file_type given, raises exception (of type fnf_exception_class) if file is not found.
239
- def file_cache(path, fnf_file_type = nil, fnf_exception_class = Kiss::FileNotFound)
240
- if (@options[:file_cache_no_reload] && @file_cache.has_key?(path)) || @files_cached_this_request[path]
241
- return @file_cache[path]
242
- end
179
+ # environment
180
+ @@environment = loader_options[:environment]
243
181
 
244
- @files_cached_this_request[path] = true
182
+ # directories
183
+ script_dir = $0.sub(/[^\/]+\Z/,'')
184
+ script_dir = '' if script_dir == './'
185
+ @@project_dir = script_dir + (loader_options[:project_dir] || loader_options[:root_dir] || '..')
186
+ Dir.chdir(@@project_dir)
245
187
 
246
- if !File.file?(path)
247
- raise fnf_exception_class, "#{fnf_file_type} file missing: '#{path}'" if fnf_file_type
248
-
249
- @file_cache[path] = nil
250
- contents = nil
251
- else
252
- # expire cache if file (or symlink) modified
253
- # TODO: what about symlinks to symlinks?
254
- if !@file_cache_time[path] ||
255
- @file_cache_time[path] < File.mtime(path) ||
256
- ( File.symlink?(path) && (@file_cache_time[path] < File.lstat(path).mtime) )
257
-
258
- @file_cache[path] = nil
259
- @file_cache_time[path] = Time.now
260
- contents = File.read(path)
261
- end
262
- end
263
-
264
- @file_cache[path] ||= begin
265
- (block_given?) ? yield(contents) : contents
266
- end
267
- end
268
-
269
- # Merges specified options into previously defined/merged Kiss options.
270
- def merge_options(config_options)
271
- if config_options
272
- if env_vars = config_options.delete(:ENV)
273
- env_vars.each_pair {|k,v| ENV[k] = v }
274
- end
275
- if lib_dirs = config_options.delete(:lib_dirs)
276
- @lib_dirs.push( lib_dirs )
188
+ @@config_dir = loader_options[:config_dir] || 'config'
189
+
190
+ # get environment name from config/environment
191
+ if (@@environment.nil?) && File.file?(env_file = @@config_dir+'/environment')
192
+ @@environment = File.read(env_file).sub(/\s+\Z/,'')
277
193
  end
278
- if gem_dirs = config_options.delete(:gem_dirs)
279
- @gem_dirs.push( gem_dirs )
194
+
195
+ # init options
196
+ @@options = {
197
+ :layout => '/_layout'
198
+ }
199
+ @@lib_dirs = ['lib']
200
+ @@gem_dirs = ['gems']
201
+ @@require = []
202
+
203
+ # common (shared) config
204
+ if (File.file?(config_file = @@config_dir+'/common.yml'))
205
+ merge_options( YAML::load(File.read(config_file)) )
280
206
  end
281
- if require_libs = config_options.delete(:require)
282
- @require.push( require_libs )
207
+ # environment config
208
+ if (File.file?(config_file = "#{@@config_dir}/environments/#{@@environment}.yml"))
209
+ merge_options( YAML::load(File.read(config_file)) )
283
210
  end
284
211
 
285
- @options.merge!( config_options )
286
- end
287
- end
288
-
289
- # Create a new Kiss application instance, based on specified options.
290
- def initialize(loader_options = {})
291
- # store cached files
292
- @file_cache = {}
293
- @directory_cache = {}
294
- @file_cache_time = {}
295
-
296
- # if loader_options is string, then it specifies environment
297
- # else it should be a hash of config options
298
- if loader_options.is_a?(String)
299
- loader_options = { :environment => loader_options }
300
- end
301
-
302
- # environment
303
- @environment = loader_options[:environment]
212
+ merge_options( loader_options )
304
213
 
305
- # directories
306
- script_dir = $0.sub(/[^\/]+\Z/,'')
307
- script_dir = '' if script_dir == './'
308
- @project_dir = script_dir + (loader_options[:project_dir] || loader_options[:root_dir] || '..')
309
- Dir.chdir(@project_dir)
214
+ # set class vars from options
215
+ @@action_dir = @@options[:action_dir] || 'actions'
216
+ @@template_dir = @@options[:template_dir] ? @@options[:template_dir] : @@action_dir
310
217
 
311
- @config_dir = loader_options[:config_dir] || 'config'
218
+ @@asset_dir = @@public_dir = @@options[:asset_dir] || @@options[:public_dir] || 'public_html'
312
219
 
313
- # get environment name from config/environment
314
- if (@environment.nil?) && File.file?(env_file = @config_dir+'/environment')
315
- @environment = File.read(env_file).sub(/\s+\Z/,'')
316
- end
220
+ @@model_dir = @@options[:model_dir] || 'models'
221
+ Kiss::ModelCache.model_dir = @@model_dir
222
+
223
+ @@evolution_dir = @@options[:evolution_dir] || 'evolutions'
317
224
 
318
- # init options
319
- @options = {}
320
- @lib_dirs = ['lib']
321
- @gem_dirs = ['gems']
322
- @require = []
225
+ @@email_template_dir = @@options[:email_template_dir] || 'email_templates'
226
+ @@upload_dir = @@options[:upload_dir] || 'uploads'
323
227
 
324
- # common (shared) config
325
- if (File.file?(config_file = @config_dir+'/common.yml'))
326
- merge_options( YAML::load(File.read(config_file)) )
327
- end
328
- # environment config
329
- if (File.file?(config_file = "#{@config_dir}/environments/#{@environment}.yml"))
330
- merge_options( YAML::load(File.read(config_file)) )
331
- end
228
+ @@cookie_name = @@options[:cookie_name] || @@default_cookie_name
229
+ @@default_action = @@options[:default_action] || @@default_action
230
+ @@action_root_class = @@options[:action_class] || Class.new(Kiss::Action)
231
+
232
+ # exception log
233
+ @@exception_log_file = @@options[:exception_log] ? ::File.open(@@options[:exception_log],'a') : nil
234
+
235
+ # default layout
236
+ @@layout = @@options[:layout]
332
237
 
333
- merge_options( loader_options )
238
+ # public_uri: uri of requests to serve from public_dir
239
+ @@asset_uri = @@public_uri = @@options[:asset_uri] || @@options[:public_uri]
240
+ @@rack_file = Rack::File.new(@@asset_dir) if @@asset_uri
334
241
 
335
- # set class vars from options
336
- @action_dir = @options[:action_dir] || 'actions'
337
- @template_dir = @options[:template_dir] ? @options[:template_dir] : @action_dir
242
+ # app_url: URL of the app actions root
243
+ @@app_host = @@options[:app_host] ? ('http://' + @@options[:app_host]) : ''
244
+ @@app_uri = @@options[:app_uri] || ''
245
+ @@app_url = @@app_host + @@app_uri
338
246
 
339
- @asset_dir = @public_dir = @options[:asset_dir] || @options[:public_dir] || 'public_html'
247
+ # include lib dirs
248
+ $LOAD_PATH.unshift(*( @@lib_dirs.flatten.select {|dir| File.directory?(dir) } ))
340
249
 
341
- @model_dir = @options[:model_dir] || 'models'
342
- @evolution_dir = @options[:evolution_dir] || 'evolutions'
250
+ # add gem dir to rubygems search path
251
+ Gem.path.unshift(*( @@gem_dirs.flatten.select {|dir| File.directory?(dir) } ))
343
252
 
344
- @email_template_dir = @options[:email_template_dir] || 'email_templates'
345
- @upload_dir = @options[:upload_dir] || 'uploads'
253
+ # require libs
254
+ @@require.flatten.each {|lib| require lib }
346
255
 
347
- @cookie_name = @options[:cookie_name] || 'Kiss'
348
- @default_action = @options[:default_action] || @@default_action
349
- @action_root_class = @options[:action_class] || Class.new(Kiss::Action)
256
+ # session class
257
+ if (@@options[:session_class])
258
+ @@session_class = (@@options[:session_class].class == Class) ? @@options[:session_class] :
259
+ @@options[:session_class].to_const
260
+ end
350
261
 
351
- # public_uri: uri of requests to serve from public_dir
352
- @asset_uri = @public_uri = @options[:asset_uri] || @options[:public_uri]
353
- @rack_file = Rack::File.new(@asset_dir) if @asset_uri
262
+ # load extensions to action class
263
+ action_extension_path = @@action_dir + '/_action.rb'
264
+ if File.file?(action_extension_path)
265
+ @@action_root_class.class_eval(File.read(action_extension_path),action_extension_path) rescue nil
266
+ end
354
267
 
355
- # include lib dirs
356
- $LOAD_PATH.unshift(*( @lib_dirs.flatten.select {|dir| File.directory?(dir) } ))
268
+ # database
269
+ if sequel = @@options[:database]
270
+ # open database connection (if not already open)
271
+ @@db = sequel.is_a?(String) ? (Sequel.open sequel) : sequel.is_a?(Hash) ? (Sequel.open sequel) : sequel
272
+
273
+ if @@db.class.name == 'Sequel::MySQL::Database'
274
+ # add fetch_arrays, all_arrays methods
275
+ require 'kiss/sequel_mysql'
276
+ # turn off convert_tinyint_to_bool, unless options say otherwise
277
+ Sequel.convert_tinyint_to_bool = false unless @@options[:convert_tinyint_to_bool]
278
+ end
279
+ end
357
280
 
358
- # add gem dir to rubygems search path
359
- Gem.path.unshift(*( @gem_dirs.flatten.select {|dir| File.directory?(dir) } ))
281
+ # setup session storage, if session class specified in config
282
+ @@session_class.setup_storage(self) if @@session_class
360
283
 
361
- # require libs
362
- @require.flatten.each {|lib| require lib }
284
+ # prepare authenticate_exclude
285
+ if @@options[:authenticate_all]
286
+ if @@options[:authenticate_exclude].is_a?(Array)
287
+ @@options[:authenticate_exclude] = @@options[:authenticate_exclude].map do |action|
288
+ action = '/'+action unless action =~ /\A\//
289
+ action
290
+ end
291
+ else
292
+ @@options[:authenticate_exclude] = []
293
+ end
294
+ end
363
295
 
364
- # session class
365
- if (@options[:session_class])
366
- @session_class = (@options[:session_class].class == Class) ? @options[:session_class] : @options[:session_class].to_const
296
+ self
367
297
  end
368
-
369
- # load extensions to action class
370
- action_extension_path = @action_dir + '/_action.rb'
371
- if File.file?(action_extension_path)
372
- @action_root_class.class_eval(File.read(action_extension_path),action_extension_path) rescue nil
298
+
299
+ # Returns URL/URI of app's static assets (asset_host or public_uri).
300
+ def assets(suffix = nil)
301
+ @@pub ||= @@options[:asset_host]
302
+ suffix ? @@pub + '/' + suffix : @@pub
303
+ end
304
+ alias_method :pub, :assets
305
+
306
+ # Returns true if specified path is a directory.
307
+ # Cache result if file_cache_no_reload option is set; otherwise, always check filesystem.
308
+ def directory_exists?(dir)
309
+ @@options[:file_cache_no_reload] ? (
310
+ @@directory_cache.has_key?(path) ?
311
+ @@directory_cache[dir] :
312
+ @@directory_cache[dir] = File.directory?(dir)
313
+ ) : File.directory?(dir)
373
314
  end
374
-
375
- # set controller access variables
376
- @action_root_class.set_controller(self)
377
- Kiss::Model.set_controller(self)
378
- Kiss::Mailer.set_controller(self)
379
-
380
- # database
381
- if sequel = @options[:database]
382
- # open database connection (if not already open)
383
- @db = sequel.is_a?(String) ? (Sequel.open sequel) : sequel.is_a?(Hash) ? (Sequel.open sequel) : sequel
384
- # add query logging to database class
385
- @db.class.class_eval do
386
- @@query = nil
387
- def self.last_query
388
- @@query
389
- end
390
315
 
391
- alias_method :execute_old, :execute
392
- def execute(sql, *args, &block)
393
- @@query = sql
394
- execute_old(sql, *args, &block)
316
+ # Merges specified options into previously defined/merged Kiss options.
317
+ def merge_options(config_options)
318
+ if config_options
319
+ if env_vars = config_options.delete(:ENV)
320
+ env_vars.each_pair {|k,v| ENV[k] = v }
395
321
  end
322
+ if lib_dirs = config_options.delete(:lib_dirs)
323
+ @@lib_dirs.push( lib_dirs )
324
+ end
325
+ if gem_dirs = config_options.delete(:gem_dirs)
326
+ @@gem_dirs.push( gem_dirs )
327
+ end
328
+ if require_libs = config_options.delete(:require)
329
+ @@require.push( require_libs )
330
+ end
331
+
332
+ @@options.merge!( config_options )
396
333
  end
397
-
398
- if @db.class.name == 'Sequel::MySQL::Database'
399
- # fix sequel mysql bugs; add all_rows
400
- require 'kiss/sequel_mysql'
401
- Sequel.convert_tinyint_to_bool = false unless @options[:convert_tinyint_to_bool]
402
- end
403
-
404
- # create models cache
405
- @dbm = Kiss::ModelCache.new(self,@model_dir)
406
334
  end
407
335
 
408
- # setup session storage, if session class specified in config
409
- @session_class.setup_storage(self) if @session_class
336
+ # Converts passed-in filename to absolute path if it does not start with '/'.
337
+ def absolute_path(filename)
338
+ filename = ( filename =~ /\A\// ? '' : (Dir.pwd + '/') ) + filename
339
+ end
410
340
 
411
- # prepare authenticate_exclude
412
- if @options[:authenticate_all]
413
- if @options[:authenticate_exclude].is_a?(Array)
414
- @options[:authenticate_exclude] = @options[:authenticate_exclude].map do |action|
415
- action = '/'+action unless action =~ /\A\//
416
- action
417
- end
341
+ # Returns string representation of object with HTML entities escaped.
342
+ def h(obj)
343
+ case obj
344
+ when String
345
+ Rack::Utils.escape_html(obj).gsub(/^(\s+)/) {'&nbsp;' * $1.length}
418
346
  else
419
- @options[:authenticate_exclude] = []
347
+ Rack::Utils.escape_html(obj.inspect)
420
348
  end
421
349
  end
422
350
 
423
- self
424
- end
425
-
426
- # Sets up Rack builder options and begins Rack execution.
427
- def run(options = {})
428
- merge_options(options)
429
-
430
- app = self
431
- builder_options = @options[:rack_builder] || []
432
- rack = Rack::Builder.new do
433
- builder_options.each do |builder_option|
434
- if builder_option.is_a?(Array)
435
- builder_args = builder_option
436
- builder_option = builder_args.shift
437
- else
438
- builder_args = []
351
+ # Returns MIME type corresponding to passed-in extension.
352
+ def mime_type(extension)
353
+ Rack::File::MIME_TYPES[extension] || @@mime_types[extension]
354
+ end
355
+
356
+ # Returns Digest class used to generate digest of specified type.
357
+ def digest_class(type)
358
+ type = type.to_sym
359
+ @@digest[type] ? Digest.const_get(type) : nil
360
+ end
361
+
362
+ # Converts date strings from m/d/y to y/m/d. Useful for MySQL.
363
+ def mdy_to_ymd(date)
364
+ return '0000-00-00' unless date && date =~ /\S/
365
+ date.sub!(/\A\D+/,'')
366
+ date.sub!(/\D+\Z/,'')
367
+
368
+ month, day, year = date.split(/\D+/,3).each {|s| s.to_i}
369
+
370
+ return 0 unless month && day
371
+
372
+ current_year = Time.now.year
373
+ if !year || year.length == 0
374
+ # use current year if year is missing.
375
+ year = current_year
376
+ else
377
+ # convert two-digit years to four-digit years
378
+ year = year.to_i
379
+ if year < 100
380
+ year += 1900
381
+ year += 100 if year < current_year - 95
439
382
  end
440
-
441
- unless builder_option.is_a?(Class)
442
- builder_option = Rack.const_get(builder_option.to_s)
383
+ end
384
+
385
+ return sprintf("%04d-%02d-%02d",year,month.to_i,day.to_i)
386
+ end
387
+
388
+ # Validates value against specified format.
389
+ # If required is true, value must contain a non-whitespace character.
390
+ # If required is false, value need not match format if and only if value contains only whitespace.
391
+ def validate_value(value, format, required = false, label = nil)
392
+ if required && (value !~ /\S/)
393
+ # value required
394
+ raise "#{label || 'value'} required"
395
+ elsif format && (value =~ /\S/)
396
+ format = Kiss::Format.lookup(format)
397
+
398
+ begin
399
+ format.validate(value)
400
+ rescue Kiss::Format::ValidateError => e
401
+ raise e.class, "#{label} validation error: #{e.message}"
443
402
  end
444
-
445
- use(builder_option,*builder_args)
446
403
  end
447
-
448
- run app
449
404
  end
405
+
406
+ # Generates string of random text of the specified length.
407
+ def random_text(length)
408
+ chars = ('A'..'Z').to_a + ('0'..'9').to_a # array
409
+
410
+ text = ''
411
+ size = chars.size
412
+ length.times { text += chars[rand(size)] }
450
413
 
451
- handler = @options[:rack_handler] || Rack::Handler::WEBrick
452
- if !handler.is_a?(Class)
453
- handler = Rack::Handler.const_get(handler.to_s)
414
+ text
454
415
  end
455
- handler.run(rack,@options[:rack_handler_options] || {:Port => 4000})
456
- end
457
416
 
458
- # Processes and responds to requests received via Rack.
459
- # Returns Rack::Response object.
460
- def call(env)
461
- clear_exception_cache
417
+ # Returns exception cache, for use in Kiss::ExceptionReport.
418
+ def exception_cache
419
+ @@exception_cache
420
+ end
421
+
422
+ # Given a file path, caches or returns the file's contents or the return value of
423
+ # the passed block applied to the file's contents.
424
+ # If file is not found, raises exception of type fnf_exception_class.
425
+ def file_cache(path, fnf_file_type = nil, fnf_exception_class = Kiss::FileNotFound)
426
+ if (@@options[:file_cache_no_reload] && @@file_cache.has_key?(path))
427
+ return @@file_cache[path]
428
+ end
429
+
430
+ if !File.file?(path)
431
+ raise fnf_exception_class, "#{fnf_file_type} file missing: '#{path}'" if fnf_file_type
462
432
 
463
- if (env["PATH_INFO"] == '/favicon.ico') ||
464
- (@asset_uri && env["PATH_INFO"].sub!(Regexp.new("\\A#{@asset_uri}"),''))
465
- return @rack_file.call(env)
433
+ @@file_cache[path] = nil
434
+ contents = nil
435
+ else
436
+ # expire cache if file (or symlink) modified
437
+ # TODO: what about symlinks to symlinks?
438
+ if !@@file_cache_time[path] ||
439
+ @@file_cache_time[path] < File.mtime(path) ||
440
+ ( File.symlink?(path) && (@@file_cache_time[path] < File.lstat(path).mtime) )
441
+
442
+ @@file_cache[path] = nil
443
+ @@file_cache_time[path] = Time.now
444
+ contents = File.read(path)
445
+ end
446
+ end
447
+
448
+ @@file_cache[path] ||= begin
449
+ (block_given?) ? yield(contents) : contents
450
+ end
466
451
  end
467
452
 
468
- check_evolution_number if @db
453
+ # Returns Sequel dataset to evolution_number table, which specifies app's current evolution number.
454
+ # Creates evolution_number table if it does not exist.
455
+ def evolution_number_table
456
+ unless db.table_exists?(:evolution_number)
457
+ db.create_table :evolution_number do
458
+ column :version, :integer, :null=> false
459
+ end
460
+ db[:evolution_number].insert(:version => 0)
461
+ end
462
+ db[:evolution_number]
463
+ end
464
+
465
+ # Returns app's current evolution number.
466
+ def evolution_number
467
+ evolution_number_table.first.version
468
+ end
469
+
470
+ # Sets app's current evolution number.
471
+ def evolution_number=(version)
472
+ load unless @@options
473
+ evolution_number_table.update(:version => version)
474
+ end
475
+
476
+ # Check whether there exists a file in evolution_dir whose number is greater than app's
477
+ # current evolution number. If so, raise an error to indicate need to apply new evolutions.
478
+ def check_evolution_number
479
+ version = evolution_number
480
+ if Kiss.directory_exists?(@@evolution_dir) &&
481
+ Dir.entries(@@evolution_dir).select { |f| f =~ /\A0*#{version+1}_/ }.size > 0
482
+ raise "current evolution #{version} is outdated; apply evolutions or update evolution number"
483
+ end
484
+ end
485
+ end # end class methods
486
+
487
+ ### Instance Methods
488
+
489
+ # Creates a new controller instance, and also configures the application with the
490
+ # specified options.
491
+ def initialize(options = nil)
492
+ if @@options
493
+ self.class.merge_options(options) if options
494
+ else
495
+ self.class.load(options)
496
+ end
469
497
 
498
+ @exception_cache = {}
499
+ @debug_messages = []
500
+ @benchmarks = []
470
501
  @files_cached_this_request = {}
502
+ end
503
+
504
+ # Caches the specified file and return its contents. See Kiss.file_cache above.
505
+ def file_cache(path, fnf_file_type = nil, fnf_exception_class = Kiss::FileNotFound, &block)
506
+ return @@file_cache[path] if @files_cached_this_request[path]
507
+ @files_cached_this_request[path] = true
471
508
 
472
- get_request(env)
473
-
474
- @response = Rack::Response.new
509
+ self.class.file_cache(path, fnf_file_type, fnf_exception_class, &block)
510
+ end
511
+
512
+ # Processes and responds to a request received via Rack. Should only be called once
513
+ # for each controller instance (i.e. each controller instance should handle at most
514
+ # one request, then be discarded). Returns array of response code, headers, and body.
515
+ def call(env)
516
+ if @@rack_file && (
517
+ (env["PATH_INFO"] == '/favicon.ico') ||
518
+ (env["PATH_INFO"].sub!(Regexp.new("\\A#{@@asset_uri}"),''))
519
+ )
520
+ return @@rack_file.call(env)
521
+ end
475
522
 
476
- begin
477
- parse_action_path(@path)
523
+ # catch and report exceptions in this block
478
524
 
479
- setup_session
480
- if login_session_valid?
481
- load_from_login_session
482
- elsif @options[:authenticate_all]
483
- if (!@options[:authenticate_exclude].is_a?(Array) ||
484
- @options[:authenticate_exclude].select {|action| action == @action}.size == 0)
485
- authenticate
525
+ code, headers, body = begin
526
+ get_request(env)
527
+ @response = Rack::Response.new
528
+
529
+ catch :kiss_action_done do
530
+ parse_action_path(@path)
531
+ env['kiss.parsed_action'] = @action
532
+ env['kiss.parsed_args'] = @args.inspect
533
+
534
+ setup_session
535
+ if login_session_valid?
536
+ load_from_login_session
537
+ elsif @@options[:authenticate_all]
538
+ if (!@@options[:authenticate_exclude].is_a?(Array) ||
539
+ @@options[:authenticate_exclude].select {|action| action == @action}.size == 0)
540
+ authenticate
541
+ end
486
542
  end
543
+
544
+ env['kiss.processed_action'] = @action
545
+ process.render
487
546
  end
488
- process.render
489
- rescue Kiss::ActionDone
547
+ finalize_session if @session
548
+
549
+ @response.finish
550
+ rescue StandardError, LoadError, SyntaxError => e
551
+ body = Kiss::ExceptionReport.generate(e, env, @exception_cache, @last_sql)
552
+ if @@exception_log_file
553
+ @@exception_log_file.print(body + "\n--- End of exception report --- \n\n")
554
+ end
555
+ [500, {
556
+ "Content-Type" => "text/html",
557
+ "Content-Length" => body.length.to_s,
558
+ "X-Kiss-Error-Type" => e.class.name,
559
+ "X-Kiss-Error-Message" => e.message.sub(/\n.*/m,'')
560
+ }, body]
561
+ end
562
+
563
+ if @debug_messages.size > 0
564
+ extend Kiss::Debug
565
+ body = prepend_debug(body)
566
+ headers['Content-Length'] = body.length.to_s
490
567
  end
491
- finalize_session if @session
492
568
 
493
- @response.finish
569
+ if @benchmarks.size > 0
570
+ stop_benchmark
571
+ extend Kiss::Bench
572
+ body = prepend_benchmarks(body)
573
+ headers['Content-Length'] = body.length.to_s
574
+ end
575
+
576
+ [code,headers,body]
577
+ end
578
+
579
+ # Adds debug message to inspect object. Debug messages will be shown at top of
580
+ # application response body.
581
+ def debug(object, context = Kernel.caller[0])
582
+ @debug_messages.push( [object.inspect, context] )
583
+ object
584
+ end
585
+
586
+ # Starts a new benchmark timer, with optional label. Benchmark results will be shown
587
+ # at top of application response body.
588
+ def bench(label = nil, context = Kernel.caller[0])
589
+ stop_benchmark(context)
590
+ @benchmarks.push(
591
+ :label => label,
592
+ :start_time => Time.now,
593
+ :start_context => context
594
+ )
595
+ end
596
+
597
+ # Stops last benchmark timer, if still running.
598
+ def stop_benchmark(end_context = nil)
599
+ if @benchmarks[-1] && !@benchmarks[-1][:end_time]
600
+ @benchmarks[-1][:end_time] = Time.now
601
+ @benchmarks[-1][:end_context] = end_context
602
+ end
494
603
  end
495
604
 
496
605
  # Sets up request-specified variables based in request information received from Rack.
497
606
  def get_request(env)
498
607
  @request = Rack::Request.new(env)
499
608
 
500
- @app_host = @options[:app_host] ? ('http://' + @options[:app_host]) : @request.server
501
- @app_uri = @options[:app_uri] || @request.script_name || ''
609
+ @app_host = @@options[:app_host] ? ('http://' + @@options[:app_host]) : @request.server rescue ''
610
+ @app_uri = @@options[:app_uri] || @request.script_name || ''
502
611
  @app_url = @app_host + @app_uri
503
612
 
504
613
  @path = @request.path_info || '/'
505
614
  @params = @request.params
506
615
 
507
- @host ||= @request.host
616
+ @host ||= @request.host rescue ''
508
617
  @protocol = env['HTTPS'] == 'on' ? 'https' : 'http'
509
618
  end
510
619
 
511
- # Returns Sequel dataset to evolution_number table, which specifies app's current evolution number.
512
- # Creates evolution_number table if it does not exist.
513
- def evolution_number_table
514
- unless db.table_exists?(:evolution_number)
515
- db.create_table :evolution_number do
516
- column :version, :integer, :null=> false
517
- end
518
- db[:evolution_number].insert(:version => 0)
519
- end
520
- db[:evolution_number]
521
- end
522
-
523
- # Returns app's current evolution number.
524
- def evolution_number
525
- evolution_number_table.first.version
526
- end
527
-
528
- # Sets app's current evolution number.
529
- def evolution_number=(version)
530
- evolution_number_table.update(:version => version)
531
- end
532
-
533
- # Check whether there exists a file in evolution_dir whose number is greater than app's
534
- # current evolution number. If so, raise an error to indicate need to apply new evolutions.
535
- def check_evolution_number
536
- version = evolution_number
537
- if directory_exists?(@evolution_dir) &&
538
- Dir.entries(@evolution_dir).select { |f| f =~ /\A0*#{version+1}_/ }.size > 0
539
- raise "current evolution #{version} is outdated; apply evolutions or update evolution number"
540
- end
620
+ # Returns URL/URI of app root (corresponding to top level of action_dir).
621
+ def app_url(suffix = nil)
622
+ suffix ? @app_url + suffix : @app_url
541
623
  end
542
624
 
543
625
  # Loads session from session store (specified by session_class).
544
626
  def setup_session
545
627
  @login = {}
546
- @session = @session_class ? begin
547
- session = @session_class.persist(@request.cookies[@cookie_name])
548
- @_fingerprint = Marshal.dump(session.data).hash
628
+ @session = @@session_class ? begin
629
+ session = @@session_class.persist(@request.cookies[@@cookie_name])
630
+ @session_fingerprint = Marshal.dump(session.data).hash
549
631
 
550
632
  cookie_vars = {
551
633
  :value => session.values[:session_id],
552
- :path => @options[:cookie_path] || @app_uri,
553
- :domain => @options[:cookie_domain] || @request.host
634
+ :path => @@options[:cookie_path] || @app_uri,
635
+ :domain => @@options[:cookie_domain] || @request.host
554
636
  }
555
- cookie_vars[:expires] = Time.now + @options[:cookie_lifespan] if @options[:cookie_lifespan]
637
+ cookie_vars[:expires] = Time.now + @@options[:cookie_lifespan] if @@options[:cookie_lifespan]
556
638
 
557
639
  # set_cookie here or at render time
558
- @response.set_cookie @cookie_name, cookie_vars
559
- @login.merge!(session[:login]) if session[:login]
640
+ @response.set_cookie @@cookie_name, cookie_vars
641
+ @login.merge!(session[:login]) if session[:login] && session[:login][:expires_at] &&
642
+ session[:login][:expires_at] > Time.now
560
643
 
561
644
  session
562
645
  end : {}
@@ -564,11 +647,7 @@ class Kiss
564
647
 
565
648
  # Saves session to session store, if session data has changed since load.
566
649
  def finalize_session
567
- @session.save if @_fingerprint != Marshal.dump(@session.data).hash
568
- end
569
-
570
- def session
571
- @session
650
+ @session.save if @session_fingerprint != Marshal.dump(@session.data).hash
572
651
  end
573
652
 
574
653
  ##### LOGIN SESSION #####
@@ -609,16 +688,16 @@ class Kiss
609
688
 
610
689
  # Calls login action's load_from_session method to populate request login hash.
611
690
  def load_from_login_session
612
- klass = action_class('login')
691
+ klass = action_class('/login')
613
692
  raise 'load_from_login_session called, but no login action found' unless klass
614
693
 
615
- action_handler = klass.new
694
+ action_handler = klass.new(self)
616
695
  action_handler.load_from_session
617
696
  end
618
697
 
619
698
  # Returns path to login action.
620
699
  def login_path
621
- @action_dir + '/login.rb'
700
+ @@action_dir + '/login.rb'
622
701
  end
623
702
 
624
703
  # If valid login session exists, loads login action to populate request login hash data.
@@ -627,7 +706,7 @@ class Kiss
627
706
  if login_session_valid?
628
707
  load_from_login_session
629
708
  else
630
- klass = action_class('login')
709
+ klass = action_class('/login')
631
710
  raise 'authenticate called, but no login action found' unless klass
632
711
  old_extension = @extension
633
712
  @extension = 'rhtml'
@@ -642,18 +721,14 @@ class Kiss
642
721
 
643
722
  ##### ACTION METHODS #####
644
723
 
645
- def action_dir
646
- @action_dir
647
- end
648
-
649
724
  # Creates and caches anonymous class with which to invoke specified (or current) action.
650
725
  def action_class(action = @action)
651
- action_path = @action_dir + '/' + action.to_s + '.rb'
726
+ action_path = @@action_dir + action.to_s + '.rb'
652
727
  return nil unless action_path.is_a?(String) && action_path.length > 0
653
728
 
654
729
  file_cache(action_path) do |src|
655
730
  # create new action class, subclass of shared action parent class
656
- klass = Class.new(@action_root_class)
731
+ klass = Class.new(@@action_root_class)
657
732
  klass.class_eval(src,action_path) if src
658
733
  klass
659
734
  end
@@ -664,17 +739,17 @@ class Kiss
664
739
  @action_subdir = ''
665
740
  @action = nil
666
741
 
667
- redirect_url(app + '/') if path == ''
742
+ redirect_url(app_url + '/') if path == ''
668
743
 
669
- path += @default_action if path =~ /\/\Z/
744
+ path += @@default_action if path =~ /\/\Z/
670
745
 
671
746
  parts = path.sub(/^\/*/,'').split('/')
672
747
 
673
748
  while part = parts.shift
674
749
  raise 'bad action' if part !~ /\A[a-z0-9][\w\-\.]*\Z/i
675
750
 
676
- test_path = @action_dir + @action_subdir + '/' + part
677
- if directory_exists?(test_path)
751
+ test_path = @@action_dir + @action_subdir + '/' + part
752
+ if Kiss.directory_exists?(test_path)
678
753
  @action_subdir += '/' + part
679
754
  next
680
755
  end
@@ -692,9 +767,9 @@ class Kiss
692
767
 
693
768
  # if no action, must have traversed all parts to a directory
694
769
  # add a trailing slash and try again
695
- redirect_url(app + '/' + path + '/') unless @action
770
+ redirect_url(app_url + '/' + path + '/') unless @action
696
771
 
697
- @action_path = @action_dir + '/' + @action + '.rb'
772
+ @action_path = @@action_dir + '/' + @action + '.rb'
698
773
 
699
774
  # keep rest of path_info in args
700
775
  @args = parts
@@ -703,28 +778,34 @@ class Kiss
703
778
  # Processes specified (or current) action, by instantiating its anonymous
704
779
  # action class and invoking `call' method on the instance.
705
780
  def process(klass = action_class, action_path = @action_path)
706
- action_handler = klass.new
781
+ action_handler = klass.new(self)
707
782
  action_handler.call
708
783
 
709
784
  # return handler to follow with render
710
785
  action_handler
711
786
  end
712
787
 
713
- ##### RACK OUTPUT METHODS #####
788
+ # Outputs a Kiss::StaticFile object as response to Rack.
789
+ # Used to return static files efficiently.
790
+ def send_file(path, mime_type = nil)
791
+ @response = Kiss::StaticFile.new(path,mime_type)
792
+
793
+ throw :kiss_action_done
794
+ end
714
795
 
715
- # Prepares Rack::Response object to be returned to Rack.
796
+ # Prepares Rack::Response object to return application response to Rack.
716
797
  # Raises Kiss::ActionDone exception to bypass caller stack and return directly
717
798
  # to Kiss#call.
718
799
  def send_response(output = '',options = {})
719
- content_type = options[:content_type] || @content_type ||
720
- (extension ? Rack::File::MIME_TYPES[extension] || @@mime_types[extension] : nil)
800
+ content_type = options[:content_type] ||
801
+ (extension ? Kiss.mime_type(extension) : nil)
721
802
  document_encoding ||= 'utf-8'
722
803
 
723
804
  @response['Content-Type'] = "#{content_type}; #{document_encoding}" if content_type
724
805
  @response['Content-Length'] = output.length.to_s
725
806
  @response.body = output
726
807
 
727
- raise Kiss::ActionDone
808
+ throw :kiss_action_done
728
809
 
729
810
  # back to Kiss#call, which finalizes session and returns @response
730
811
  # (throws exception if no @response set)
@@ -738,7 +819,25 @@ class Kiss
738
819
  end
739
820
 
740
821
  # Returns new Kiss::Mailer object using specified options.
741
- def new_email(*options)
742
- Kiss::Mailer.new(*options)
822
+ def new_email(options = {})
823
+ Kiss::Mailer.new(options)
824
+ end
825
+
826
+ # Kiss Model cache, used to invoke and store Kiss database models.
827
+ #
828
+ # Example:
829
+ # models[:users] : database model for `users' table
830
+ def dbm
831
+ @dbm ||= Kiss::ModelCache.new(self)
832
+ end
833
+ alias_method :models, :dbm
834
+
835
+ def h(*args)
836
+ self.class.h(*args)
837
+ end
838
+
839
+ # Adds data to be displayed in "Cache" section of Kiss exception reports.
840
+ def set_exception_cache(data)
841
+ @exception_cache.merge!(data)
743
842
  end
744
843
  end