brut 0.0.1
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 +7 -0
- data/.gitignore +7 -0
- data/CODE_OF_CONDUCT.txt +99 -0
- data/Dockerfile.dx +32 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +133 -0
- data/LICENSE.txt +370 -0
- data/README.md +21 -0
- data/Rakefile +1 -0
- data/bin/bin_kit.rb +39 -0
- data/bin/rake +27 -0
- data/bin/setup +145 -0
- data/brut.gemspec +60 -0
- data/docker-compose.dx.yml +16 -0
- data/dx/build +26 -0
- data/dx/docker-compose.env +22 -0
- data/dx/dx.sh.lib +24 -0
- data/dx/exec +58 -0
- data/dx/prune +19 -0
- data/dx/setupkit.sh.lib +144 -0
- data/dx/show-help-in-app-container-then-wait.sh +38 -0
- data/dx/start +30 -0
- data/dx/stop +23 -0
- data/lib/brut/back_end/action.rb +3 -0
- data/lib/brut/back_end/result.rb +46 -0
- data/lib/brut/back_end/seed_data.rb +24 -0
- data/lib/brut/back_end/validator.rb +3 -0
- data/lib/brut/back_end/validators/form_validator.rb +37 -0
- data/lib/brut/cli/app.rb +130 -0
- data/lib/brut/cli/app_runner.rb +219 -0
- data/lib/brut/cli/apps/build_assets.rb +123 -0
- data/lib/brut/cli/apps/db.rb +279 -0
- data/lib/brut/cli/apps/scaffold.rb +256 -0
- data/lib/brut/cli/apps/test.rb +200 -0
- data/lib/brut/cli/command.rb +130 -0
- data/lib/brut/cli/error.rb +12 -0
- data/lib/brut/cli/execution_results.rb +81 -0
- data/lib/brut/cli/executor.rb +37 -0
- data/lib/brut/cli/options.rb +46 -0
- data/lib/brut/cli/output.rb +30 -0
- data/lib/brut/cli.rb +24 -0
- data/lib/brut/factory_bot.rb +20 -0
- data/lib/brut/framework/app.rb +55 -0
- data/lib/brut/framework/config.rb +415 -0
- data/lib/brut/framework/container.rb +190 -0
- data/lib/brut/framework/errors/abstract_method.rb +9 -0
- data/lib/brut/framework/errors/bug.rb +14 -0
- data/lib/brut/framework/errors/not_found.rb +10 -0
- data/lib/brut/framework/errors.rb +14 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +50 -0
- data/lib/brut/framework/mcp.rb +215 -0
- data/lib/brut/framework/project_environment.rb +18 -0
- data/lib/brut/framework.rb +13 -0
- data/lib/brut/front_end/asset_metadata.rb +76 -0
- data/lib/brut/front_end/component.rb +213 -0
- data/lib/brut/front_end/components/form_tag.rb +71 -0
- data/lib/brut/front_end/components/i18n_translations.rb +36 -0
- data/lib/brut/front_end/components/input.rb +13 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +8 -0
- data/lib/brut/front_end/components/inputs/select.rb +100 -0
- data/lib/brut/front_end/components/inputs/text_field.rb +63 -0
- data/lib/brut/front_end/components/inputs/textarea.rb +51 -0
- data/lib/brut/front_end/components/locale_detection.rb +25 -0
- data/lib/brut/front_end/components/page_identifier.rb +13 -0
- data/lib/brut/front_end/components/timestamp.rb +33 -0
- data/lib/brut/front_end/download.rb +23 -0
- data/lib/brut/front_end/flash.rb +57 -0
- data/lib/brut/front_end/form.rb +171 -0
- data/lib/brut/front_end/forms/constraint_violation.rb +39 -0
- data/lib/brut/front_end/forms/input.rb +119 -0
- data/lib/brut/front_end/forms/input_definition.rb +100 -0
- data/lib/brut/front_end/forms/validity_state.rb +36 -0
- data/lib/brut/front_end/handler.rb +48 -0
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +11 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +22 -0
- data/lib/brut/front_end/handling_results.rb +14 -0
- data/lib/brut/front_end/http_method.rb +33 -0
- data/lib/brut/front_end/http_status.rb +16 -0
- data/lib/brut/front_end/middleware.rb +7 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +31 -0
- data/lib/brut/front_end/page.rb +47 -0
- data/lib/brut/front_end/request_context.rb +82 -0
- data/lib/brut/front_end/route_hook.rb +15 -0
- data/lib/brut/front_end/route_hooks/age_flash.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +17 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +46 -0
- data/lib/brut/front_end/route_hooks/locale_detection.rb +24 -0
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +11 -0
- data/lib/brut/front_end/routing.rb +236 -0
- data/lib/brut/front_end/session.rb +56 -0
- data/lib/brut/front_end/template.rb +32 -0
- data/lib/brut/front_end/templates/block_filter.rb +60 -0
- data/lib/brut/front_end/templates/erb_engine.rb +26 -0
- data/lib/brut/front_end/templates/erb_parser.rb +84 -0
- data/lib/brut/front_end/templates/escapable_filter.rb +18 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +40 -0
- data/lib/brut/i18n/base_methods.rb +168 -0
- data/lib/brut/i18n/for_cli.rb +4 -0
- data/lib/brut/i18n/for_html.rb +4 -0
- data/lib/brut/i18n/http_accept_language.rb +68 -0
- data/lib/brut/i18n.rb +6 -0
- data/lib/brut/instrumentation/basic.rb +66 -0
- data/lib/brut/instrumentation/event.rb +19 -0
- data/lib/brut/instrumentation/http_event.rb +5 -0
- data/lib/brut/instrumentation/subscriber.rb +41 -0
- data/lib/brut/instrumentation.rb +11 -0
- data/lib/brut/junk_drawer.rb +88 -0
- data/lib/brut/sinatra_helpers.rb +183 -0
- data/lib/brut/spec_support/component_support.rb +49 -0
- data/lib/brut/spec_support/flash_support.rb +7 -0
- data/lib/brut/spec_support/general_support.rb +18 -0
- data/lib/brut/spec_support/handler_support.rb +7 -0
- data/lib/brut/spec_support/matcher.rb +9 -0
- data/lib/brut/spec_support/matchers/be_a_bug.rb +14 -0
- data/lib/brut/spec_support/matchers/be_page_for.rb +14 -0
- data/lib/brut/spec_support/matchers/be_routing_for.rb +11 -0
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +56 -0
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +69 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +20 -0
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +27 -0
- data/lib/brut/spec_support/session_support.rb +3 -0
- data/lib/brut/spec_support.rb +12 -0
- data/lib/brut/version.rb +3 -0
- data/lib/brut.rb +38 -0
- data/lib/sequel/extensions/brut_instrumentation.rb +37 -0
- data/lib/sequel/extensions/brut_migrations.rb +98 -0
- data/lib/sequel/plugins/created_at.rb +14 -0
- data/lib/sequel/plugins/external_id.rb +45 -0
- data/lib/sequel/plugins/find_bang.rb +13 -0
- data/lib/sequel/plugins.rb +3 -0
- metadata +484 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# An InputDefinition captures metadata used to create an Input. Think of this
|
|
2
|
+
# as a template for creating inputs. An Input has state, such as values and thus validity.
|
|
3
|
+
# An InputDefinition is immutable and defines inputs.
|
|
4
|
+
class Brut::FrontEnd::Forms::InputDefinition
|
|
5
|
+
include Brut::Framework::FussyTypeEnforcement
|
|
6
|
+
attr_reader :max,
|
|
7
|
+
:maxlength,
|
|
8
|
+
:min,
|
|
9
|
+
:minlength,
|
|
10
|
+
:name,
|
|
11
|
+
:pattern,
|
|
12
|
+
:required,
|
|
13
|
+
:step,
|
|
14
|
+
:type
|
|
15
|
+
|
|
16
|
+
INPUT_TYPES_TO_CLASS = {
|
|
17
|
+
"checkbox" => String,
|
|
18
|
+
"color" => String,
|
|
19
|
+
"date" => String,
|
|
20
|
+
"datetime-local" => String,
|
|
21
|
+
"email" => String,
|
|
22
|
+
"file" => String,
|
|
23
|
+
"hidden" => String,
|
|
24
|
+
"month" => String,
|
|
25
|
+
"number" => Numeric,
|
|
26
|
+
"password" => String,
|
|
27
|
+
"radio" => String,
|
|
28
|
+
"range" => String,
|
|
29
|
+
"search" => String,
|
|
30
|
+
"tel" => String,
|
|
31
|
+
"text" => String,
|
|
32
|
+
"time" => String,
|
|
33
|
+
"url" => String,
|
|
34
|
+
"week" => String,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Create an InputDefinition. This should very closely mirror
|
|
38
|
+
# the attributes used in an <INPUT> element in HTML.
|
|
39
|
+
def initialize(
|
|
40
|
+
max: nil,
|
|
41
|
+
maxlength: nil,
|
|
42
|
+
min: nil,
|
|
43
|
+
minlength: nil,
|
|
44
|
+
name: nil,
|
|
45
|
+
pattern: nil,
|
|
46
|
+
required: :based_on_type,
|
|
47
|
+
step: nil,
|
|
48
|
+
type: nil
|
|
49
|
+
)
|
|
50
|
+
name = name.to_s
|
|
51
|
+
type = if type.nil?
|
|
52
|
+
case name
|
|
53
|
+
when "email" then "email"
|
|
54
|
+
when "password" then "password"
|
|
55
|
+
else
|
|
56
|
+
"text"
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
type
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
type = type.to_s
|
|
63
|
+
if required == :based_on_type
|
|
64
|
+
required = type != "checkbox"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
@max = type!( max , Numeric , "max")
|
|
68
|
+
@maxlength = type!( maxlength , Numeric , "maxlength")
|
|
69
|
+
@min = type!( min , Numeric , "min")
|
|
70
|
+
@minlength = type!( minlength , Numeric , "minlength")
|
|
71
|
+
@name = type!( name , String , "name")
|
|
72
|
+
@pattern = type!( pattern , String , "pattern")
|
|
73
|
+
@required = type!( required , [true, false] , "required", required: true)
|
|
74
|
+
@step = type!( step , Numeric , "step")
|
|
75
|
+
@type = type!( type , INPUT_TYPES_TO_CLASS.keys , "type", required: true)
|
|
76
|
+
|
|
77
|
+
if @pattern.nil? && type == "email"
|
|
78
|
+
@pattern = /^[^@]+@[^@]+\.[^@]+$/.source
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Create an Input based on this defitition, initializing it with the given value.
|
|
83
|
+
def make_input(value:)
|
|
84
|
+
Brut::FrontEnd::Forms::Input.new(input_definition: self, value: value)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
class Brut::FrontEnd::Forms::SelectInputDefinition
|
|
88
|
+
include Brut::Framework::FussyTypeEnforcement
|
|
89
|
+
attr_reader :required, :name
|
|
90
|
+
def initialize(name:, required: true)
|
|
91
|
+
name = name.to_s
|
|
92
|
+
@name = type!( name , String , "name")
|
|
93
|
+
@required = type!( required , [true, false] , "required", required:true)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Create an Input based on this defitition, initializing it with the given value.
|
|
97
|
+
def make_input(value:)
|
|
98
|
+
Brut::FrontEnd::Forms::SelectInput.new(input_definition: self, value: value)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Mirrors a web browser's ValidityState API. Captures the overall state
|
|
2
|
+
# of validity of an input. This can accomodate server-side constraint violations
|
|
3
|
+
# that are essentially arbitrary. This means that an instance of this class should
|
|
4
|
+
# fully capture all constraint violations for a given field. You can
|
|
5
|
+
# iterate over all the violations with #each, which will yield one `ConstraintViolation` for
|
|
6
|
+
# each failure. You can query the constraint to determine if it is a client side constraint or not.
|
|
7
|
+
class Brut::FrontEnd::Forms::ValidityState
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def initialize(constraint_violations={})
|
|
11
|
+
@constraint_violations = constraint_violations.map { |key,value|
|
|
12
|
+
if value
|
|
13
|
+
Brut::FrontEnd::Forms::ConstraintViolation.new(key: key, context: {})
|
|
14
|
+
else
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
}.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns true if there are no validation errors
|
|
21
|
+
def valid? = @constraint_violations.empty?
|
|
22
|
+
|
|
23
|
+
# Set a server-side constraint violation. This is essentially arbitrary and dependent
|
|
24
|
+
# on your use-case.
|
|
25
|
+
def server_side_constraint_violation(key:,context:)
|
|
26
|
+
@constraint_violations << Brut::FrontEnd::Forms::ConstraintViolation.new(key: key, context: context, server_side: true)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def each(&block)
|
|
30
|
+
@constraint_violations.each do |constraint|
|
|
31
|
+
block.call(constraint)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
end
|
|
36
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# A handler responds to all HTTP requests other than those that render a page. It will be given any data it needs
|
|
2
|
+
# to handle the request to its handle method. You define this method to accept the parameters you expect.
|
|
3
|
+
#
|
|
4
|
+
# You may also define before_handle which will be given any subset of those parameters and can perform logic before
|
|
5
|
+
# handle is called. This is most useful in a base class to check for permissions or other cross-cutting concerns.
|
|
6
|
+
#
|
|
7
|
+
# Tests should call handle!
|
|
8
|
+
module Brut::FrontEnd
|
|
9
|
+
class Handler
|
|
10
|
+
include Brut::FrontEnd::HandlingResults
|
|
11
|
+
|
|
12
|
+
def handle(**)
|
|
13
|
+
raise Brut::Framework::Errors::AbstractMethod
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def handle!(**args)
|
|
17
|
+
result = nil
|
|
18
|
+
if self.respond_to?(:before_handle)
|
|
19
|
+
before_handle_args = self.method(:before_handle).parameters.map { |(type,name)|
|
|
20
|
+
if type == :keyreq
|
|
21
|
+
if args.key?(name)
|
|
22
|
+
[ name, args[name] ]
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError,"before_handle requires keyword arg '#{name}' but `handle` did not receive it. It must"
|
|
25
|
+
end
|
|
26
|
+
elsif type == :key
|
|
27
|
+
if args.key?(name)
|
|
28
|
+
[ name, args[name] ]
|
|
29
|
+
else
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
else
|
|
33
|
+
raise ArgumentError,"before_handle must only have keyword args. Got '#{name}' of type '#{type}'"
|
|
34
|
+
end
|
|
35
|
+
}.compact.to_h
|
|
36
|
+
result = self.before_handle(**before_handle_args)
|
|
37
|
+
end
|
|
38
|
+
if result.nil?
|
|
39
|
+
result = self.handle(**args)
|
|
40
|
+
end
|
|
41
|
+
result
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
module Handlers
|
|
45
|
+
autoload(:CspReportingHandler,"brut/front_end/handlers/csp_reporting_handler")
|
|
46
|
+
autoload(:LocaleDetectionHandler,"brut/front_end/handlers/locale_detection_handler")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class Brut::FrontEnd::Handlers::CspReportingHandler < Brut::FrontEnd::Handler
|
|
2
|
+
def handle(body:)
|
|
3
|
+
begin
|
|
4
|
+
parsed = JSON.parse(body.read)
|
|
5
|
+
SemanticLogger["brut:__brut/csp-reporting"].info(parsed)
|
|
6
|
+
rescue => ex
|
|
7
|
+
SemanticLogger["brut:__brut/locale"].warn("Got #{ex} from /__brut/locale instead of a parseable JSON object")
|
|
8
|
+
end
|
|
9
|
+
http_status(200)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class Brut::FrontEnd::Handlers::LocaleDetectionHandler < Brut::FrontEnd::Handler
|
|
2
|
+
def handle(body:,session:)
|
|
3
|
+
begin
|
|
4
|
+
parsed = JSON.parse(body.read)
|
|
5
|
+
SemanticLogger["brut:__brut/locale"].info("Got #{parsed.class}/#{parsed}")
|
|
6
|
+
if parsed.kind_of?(Hash)
|
|
7
|
+
locale = parsed["locale"]
|
|
8
|
+
timezone = parsed["timeZone"]
|
|
9
|
+
|
|
10
|
+
session.timezone_from_browser = timezone
|
|
11
|
+
if !session.http_accept_language.known?
|
|
12
|
+
session.http_accept_language = Brut::I18n::HTTPAcceptLanguage.from_browser(locale)
|
|
13
|
+
end
|
|
14
|
+
else
|
|
15
|
+
SemanticLogger["brut:__brut/locale"].warn("Got a #{parsed.class} from /__brut/locale instead of a hash")
|
|
16
|
+
end
|
|
17
|
+
rescue => ex
|
|
18
|
+
SemanticLogger["brut:__brut/locale"].warn("Got #{ex} from /__brut/locale instead of a parseable JSON object")
|
|
19
|
+
end
|
|
20
|
+
http_status(200)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Brut::FrontEnd::HandlingResults
|
|
2
|
+
# For use inside handle! or process! to indicate the user should be redirected to
|
|
3
|
+
# the route for the given class and query string parameters. If the route
|
|
4
|
+
# does not support GET, an exception is raised
|
|
5
|
+
def redirect_to(klass, **query_string_params)
|
|
6
|
+
if !klass.kind_of?(Class)
|
|
7
|
+
raise ArgumentError,"redirect_to should be given a Class, not a #{klass.class}"
|
|
8
|
+
end
|
|
9
|
+
Brut.container.routing.uri(klass,with_method: :get,**query_string_params)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# For use when an HTTP status code must be returned.
|
|
13
|
+
def http_status(number) = Brut::FrontEnd::HttpStatus.new(number)
|
|
14
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class Brut::FrontEnd::HttpMethod
|
|
2
|
+
def initialize(string)
|
|
3
|
+
normalized = string.to_s.downcase.to_sym
|
|
4
|
+
if !self.class.method_names.include?(normalized)
|
|
5
|
+
raise ArgumentError,"'#{string}' is not a known HTTP method"
|
|
6
|
+
end
|
|
7
|
+
@method = normalized
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_s = @method.to_s
|
|
11
|
+
def to_sym = @method.to_sym
|
|
12
|
+
alias to_str to_s
|
|
13
|
+
|
|
14
|
+
def ==(other)
|
|
15
|
+
self.class.name == other.class.name && self.to_s == other.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get? = self.to_sym == :get
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def self.method_names = [
|
|
23
|
+
:connect,
|
|
24
|
+
:delete,
|
|
25
|
+
:get,
|
|
26
|
+
:head,
|
|
27
|
+
:options,
|
|
28
|
+
:patch,
|
|
29
|
+
:post,
|
|
30
|
+
:put,
|
|
31
|
+
:trace,
|
|
32
|
+
].freeze
|
|
33
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class Brut::FrontEnd::HttpStatus
|
|
2
|
+
def initialize(number)
|
|
3
|
+
number = number.to_i
|
|
4
|
+
if ((number < 100) || (number > 599))
|
|
5
|
+
raise ArgumentError,"'#{number}' is not a known HTTP status code"
|
|
6
|
+
end
|
|
7
|
+
@number = number
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def to_i = @number
|
|
11
|
+
def to_s = to_i.to_s
|
|
12
|
+
|
|
13
|
+
def ==(other)
|
|
14
|
+
self.class == other.class && self.to_i == other.to_i
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class Brut::FrontEnd::Middlewares::ReloadApp < Brut::FrontEnd::Middleware
|
|
2
|
+
LOCK = Concurrent::ReadWriteLock.new
|
|
3
|
+
def initialize(app)
|
|
4
|
+
@app = app
|
|
5
|
+
end
|
|
6
|
+
def call(env)
|
|
7
|
+
Brut.container.instrumentation.instrument(Brut::Instrumentation::Event.new(category: "middleware", subcategory: self.class.name, name: "call")) do
|
|
8
|
+
# We can only have one thread reloading stuff at a time, per process.
|
|
9
|
+
# The ReadWriteLock achieves this.
|
|
10
|
+
#
|
|
11
|
+
# Here, if any thread is serving a request, THIS thread will wait here.
|
|
12
|
+
# Once no other thread is serving a request, the write lock is acquired and a reload happens.
|
|
13
|
+
LOCK.with_write_lock do
|
|
14
|
+
begin
|
|
15
|
+
Brut.container.zeitwerk_loader.reload
|
|
16
|
+
Brut.container.routing.reload
|
|
17
|
+
Brut.container.asset_path_resolver.reload
|
|
18
|
+
::I18n.reload!
|
|
19
|
+
rescue => ex
|
|
20
|
+
SemanticLogger[self.class].warn("Reload failed - your browser may not show you the latest code: #{ex.message}")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
# If another thread has a write lock, we wait here so that the reload can complete before serving
|
|
24
|
+
# the request. If no thread has a write lock, THIS thread may proceed to serve the request,
|
|
25
|
+
# as will any other thread that gets here.
|
|
26
|
+
LOCK.with_read_lock do
|
|
27
|
+
@app.call(env)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# A page is a component that has a layout and thus is intended to be
|
|
2
|
+
# an entire web page, not just a fragment.
|
|
3
|
+
class Brut::FrontEnd::Page < Brut::FrontEnd::Component
|
|
4
|
+
include Brut::FrontEnd::HandlingResults
|
|
5
|
+
using Brut::FrontEnd::Templates::HTMLSafeString::Refinement
|
|
6
|
+
|
|
7
|
+
def layout = "default"
|
|
8
|
+
|
|
9
|
+
def before_render = nil
|
|
10
|
+
|
|
11
|
+
def handle!
|
|
12
|
+
case before_render
|
|
13
|
+
in URI => uri
|
|
14
|
+
uri
|
|
15
|
+
in Brut::FrontEnd::HttpStatus => http_status
|
|
16
|
+
http_status
|
|
17
|
+
else
|
|
18
|
+
render
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Overrides component's render to add the concept of a layout.
|
|
23
|
+
# A layout is an HTML/ERB file that will contain this page's contents.
|
|
24
|
+
def render
|
|
25
|
+
Brut.container.layout_locator.locate(self.layout).
|
|
26
|
+
then { |layout_erb_file| Brut::FrontEnd::Template.new(layout_erb_file)
|
|
27
|
+
} => layout_template
|
|
28
|
+
|
|
29
|
+
Brut.container.page_locator.locate(self.template_name).
|
|
30
|
+
then { |erb_file| Brut::FrontEnd::Template.new(erb_file)
|
|
31
|
+
} => template
|
|
32
|
+
|
|
33
|
+
layout_template.render_template(self) do
|
|
34
|
+
template.render_template(self).html_safe!
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.page_name = self.name
|
|
39
|
+
def page_name = self.class.page_name
|
|
40
|
+
def component_name = raise Brut::Framework::Errors::Bug,"#{self.class} is not a component"
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def template_name = RichString.new(self.class.name).underscorized.to_s.gsub(/^pages\//,"")
|
|
45
|
+
|
|
46
|
+
end
|
|
47
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
class Brut::FrontEnd::RequestContext
|
|
2
|
+
def initialize(env:,session:,flash:,xhr:,body:)
|
|
3
|
+
@hash = {
|
|
4
|
+
env:,
|
|
5
|
+
session:,
|
|
6
|
+
flash:,
|
|
7
|
+
xhr:,
|
|
8
|
+
body:,
|
|
9
|
+
csrf_token: Rack::Protection::AuthenticityToken.token(env["rack.session"]),
|
|
10
|
+
clock: Clock.new(session.timezone_from_browser),
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def []=(key,value)
|
|
16
|
+
key = key.to_sym
|
|
17
|
+
@hash[key] = value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fetch(key)
|
|
21
|
+
if self.key?(key)
|
|
22
|
+
value = self[key]
|
|
23
|
+
if value
|
|
24
|
+
return value
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError,"No key '#{key}' in #{self.class}"
|
|
27
|
+
end
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError,"Key '#{key}' is nil in #{self.class}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def [](key)
|
|
34
|
+
@hash[key.to_sym]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def key?(key)
|
|
38
|
+
@hash.key?(key.to_sym)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns a hash suitable to passing into this class' constructor.
|
|
42
|
+
def as_constructor_args(klass, request_params:)
|
|
43
|
+
args_for_method(method: klass.instance_method(:initialize), request_params:, form: nil)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def as_method_args(object, method_name, request_params:,form:)
|
|
47
|
+
args_for_method(method: object.method(method_name), request_params:, form:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def args_for_method(method:, request_params:, form: )
|
|
53
|
+
args = {}
|
|
54
|
+
method.parameters.each do |(type,name)|
|
|
55
|
+
|
|
56
|
+
if name.to_s == "**" || name.to_s == "*"
|
|
57
|
+
raise ArgumentError,"#{method.class}##{method.name} accepts '#{name}' and not keyword args. Define it in your class to accept the keyword arguments your method needs"
|
|
58
|
+
end
|
|
59
|
+
if ![ :key,:keyreq ].include?(type)
|
|
60
|
+
raise ArgumentError,"#{name} is not a keyword arg, but is a #{type}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if self.key?(name)
|
|
64
|
+
args[name] = self[name]
|
|
65
|
+
elsif !form.nil? && name == :form
|
|
66
|
+
args[name] = form
|
|
67
|
+
elsif !request_params.nil? && (request_params[name.to_s] || request_params[name.to_sym])
|
|
68
|
+
args[name] = request_params[name.to_s] || request_params[name.to_sym]
|
|
69
|
+
elsif type == :keyreq
|
|
70
|
+
request_params_message = if request_params.nil?
|
|
71
|
+
"no request params provied"
|
|
72
|
+
else
|
|
73
|
+
"request_params: #{request_params.keys.map(&:to_s).join(", ")}"
|
|
74
|
+
end
|
|
75
|
+
raise ArgumentError,"#{method} argument '#{name}' is required, but there is no value in the current request context (keys: #{@hash.keys.map(&:to_s).join(", ")}, #{request_params_message}, form: #{form.class}). Either set this value in the request context or set a default value in the initializer"
|
|
76
|
+
else
|
|
77
|
+
# this keyword arg has a default value which will be used
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
args
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Brut::FrontEnd
|
|
2
|
+
class RouteHook
|
|
3
|
+
include Brut::FrontEnd::HandlingResults
|
|
4
|
+
# Return this to continue the hook
|
|
5
|
+
def continue = true
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
module RouteHooks
|
|
9
|
+
autoload(:LocaleDetection, "brut/front_end/route_hooks/locale_detection")
|
|
10
|
+
autoload(:SetupRequestContext, "brut/front_end/route_hooks/setup_request_context")
|
|
11
|
+
autoload(:AgeFlash, "brut/front_end/route_hooks/age_flash")
|
|
12
|
+
autoload(:CSPNoInlineStylesOrScripts,"brut/front_end/route_hooks/csp_no_inline_styles_or_scripts")
|
|
13
|
+
autoload(:CSPNoInlineScripts,"brut/front_end/route_hooks/csp_no_inline_scripts")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class Brut::FrontEnd::RouteHooks::CSPNoInlineScripts < Brut::FrontEnd::RouteHook
|
|
2
|
+
def after(response:)
|
|
3
|
+
response.headers["Content-Security-Policy"] = header_value
|
|
4
|
+
continue
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def header_value
|
|
8
|
+
[
|
|
9
|
+
"default-src 'self'",
|
|
10
|
+
"script-src-elem 'self'",
|
|
11
|
+
"script-src-attr 'none'",
|
|
12
|
+
"style-src-elem 'self'",
|
|
13
|
+
"style-src-attr 'self'",
|
|
14
|
+
].join("; ")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class Brut::FrontEnd::RouteHooks::CSPNoInlineStylesOrScripts < Brut::FrontEnd::RouteHook
|
|
2
|
+
def after(response:)
|
|
3
|
+
response.headers["Content-Security-Policy"] = header_value
|
|
4
|
+
continue
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def header_value
|
|
8
|
+
[
|
|
9
|
+
"default-src 'self'",
|
|
10
|
+
"script-src-elem 'self'",
|
|
11
|
+
"script-src-attr 'none'",
|
|
12
|
+
"style-src-elem 'self'",
|
|
13
|
+
"style-src-attr 'none'",
|
|
14
|
+
].join("; ")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class ReportOnly < Brut::FrontEnd::RouteHooks::CSPNoInlineStylesOrScripts
|
|
18
|
+
def after(response:,request:)
|
|
19
|
+
csp_reporting_path = uri(Brut::FrontEnd::Handlers::CspReportingHandler.routing,request:)
|
|
20
|
+
reporting_directives = "report-to csp_reporting;report-uri #{csp_reporting_path}"
|
|
21
|
+
|
|
22
|
+
response.headers["Content-Security-Policy-Report-Only"] = header_value + ";" + reporting_directives
|
|
23
|
+
response.headers["Reporting-Endpoints"] = "csp_reporting='#{csp_reporting_path}'"
|
|
24
|
+
|
|
25
|
+
continue
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def uri(path,request:)
|
|
32
|
+
# Adapted from Sinatra's innards
|
|
33
|
+
host = "http#{'s' if request.secure?}://"
|
|
34
|
+
if request.forwarded? || (request.port != (request.secure? ? 443 : 80))
|
|
35
|
+
host << request.host_with_port
|
|
36
|
+
else
|
|
37
|
+
host << request.host
|
|
38
|
+
end
|
|
39
|
+
uri_parts = [
|
|
40
|
+
host,
|
|
41
|
+
request.script_name.to_s,
|
|
42
|
+
path,
|
|
43
|
+
]
|
|
44
|
+
File.join(uri_parts)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class Brut::FrontEnd::RouteHooks::LocaleDetection < Brut::FrontEnd::RouteHook
|
|
2
|
+
def before(app_session:,env:)
|
|
3
|
+
http_accept_language = Brut::I18n::HTTPAcceptLanguage.from_header(env["HTTP_ACCEPT_LANGUAGE"])
|
|
4
|
+
if !app_session.http_accept_language.known?
|
|
5
|
+
app_session.http_accept_language = http_accept_language
|
|
6
|
+
end
|
|
7
|
+
best_locale = nil
|
|
8
|
+
app_session.http_accept_language.weighted_locales.each do |weighted_locale|
|
|
9
|
+
if ::I18n.available_locales.include?(weighted_locale.locale.to_sym)
|
|
10
|
+
best_locale = weighted_locale.locale.to_sym
|
|
11
|
+
break
|
|
12
|
+
elsif ::I18n.available_locales.include?(weighted_locale.primary_only.locale.to_sym)
|
|
13
|
+
best_locale = weighted_locale.primary_only.locale.to_sym
|
|
14
|
+
break
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
if best_locale
|
|
18
|
+
::I18n.locale = best_locale
|
|
19
|
+
else
|
|
20
|
+
SemanticLogger["Brut"].warn("None of the user's locales are available: #{app_session.http_accept_language}")
|
|
21
|
+
end
|
|
22
|
+
continue
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class Brut::FrontEnd::RouteHooks::SetupRequestContext < Brut::FrontEnd::RouteHook
|
|
2
|
+
def before(app_session:,request:,env:)
|
|
3
|
+
flash = app_session.flash
|
|
4
|
+
app_session[:_flash] ||= flash
|
|
5
|
+
Thread.current.thread_variable_set(
|
|
6
|
+
:request_context,
|
|
7
|
+
Brut::FrontEnd::RequestContext.new(env:,session:app_session,flash:,xhr: request.xhr?,body: request.body)
|
|
8
|
+
)
|
|
9
|
+
continue
|
|
10
|
+
end
|
|
11
|
+
end
|