pakyow-routing 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Pakyow
6
+ module Routing
7
+ # Expands a route template.
8
+ #
9
+ # @api private
10
+ class Expansion
11
+ attr_reader :expander, :controller, :name
12
+
13
+ extend Forwardable
14
+ def_delegators :@expander, *%i(default template).concat(Controller::DEFINABLE_HTTP_METHODS)
15
+ def_delegators :@controller, :action
16
+
17
+ def initialize(template_name, controller, options, &template_block)
18
+ @controller = controller
19
+
20
+ # Create the controller that stores available routes, groups, and namespaces.
21
+ #
22
+ @expander = Controller.make(set_const: false)
23
+
24
+ # Evaluate the template to define available routes, groups, and namespaces.
25
+ #
26
+ instance_exec(**options, &template_block)
27
+
28
+ # Define helper methods for routes
29
+ #
30
+ local_expander = @expander
31
+ @expander.routes.each do |method, routes|
32
+ routes.each do |route|
33
+ unless @controller.singleton_class.instance_methods(false).include?(route.name)
34
+ @controller.define_singleton_method route.name do |*args, &block|
35
+ # Handle template parts named `new` by determining if we're calling `new` to expand
36
+ # part of a template, or if we're intending to create a new controller instance.
37
+ #
38
+ # If args are empty we can be sure that we're creating a route.
39
+ #
40
+ if args.any?
41
+ super(*args)
42
+ else
43
+ build_route(method, route.name, route.path || route.matcher, &block).tap do
44
+ # Make sure the route was inserted in the same order as found in the template.
45
+ #
46
+ index_of_last_insert = local_expander.routes[method].index { |expander_route|
47
+ expander_route.name == @routes[method].last.name
48
+ }
49
+
50
+ insert_before_this_index = @routes[method].select { |each_route|
51
+ local_expander.routes[method].any? { |expander_route|
52
+ each_route.name == expander_route.name
53
+ }
54
+ }.map { |each_route|
55
+ local_expander.routes[method].index { |expander_route|
56
+ expander_route.name == each_route.name
57
+ }
58
+ }.select { |index|
59
+ index > index_of_last_insert
60
+ }.first
61
+
62
+ if insert_before_this_index
63
+ @routes[method].insert(
64
+ @routes[method].index { |each_route|
65
+ each_route.name == local_expander.routes[method][insert_before_this_index].name
66
+ }, @routes[method].delete_at(index_of_last_insert)
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # Define helper methods for groups and namespaces
77
+ #
78
+ @expander.children.each do |child|
79
+ unless @controller.singleton_class.instance_methods(false).include?(child.__object_name.name)
80
+ @controller.define_singleton_method child.__object_name.name do |&block|
81
+ if child.path.nil?
82
+ group(child.__object_name.name, &block)
83
+ else
84
+ namespace(child.__object_name.name, child.path || child.matcher, &block)
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # Set the expansion on the controller.
91
+ #
92
+ @controller.expansions << template_name
93
+ end
94
+
95
+ def group(*args, **kwargs, &block)
96
+ @expander.send(:group, *args, set_const: false, **kwargs, &block)
97
+ end
98
+
99
+ def namespace(*args, **kwargs, &block)
100
+ @expander.send(:namespace, *args, set_const: false, **kwargs, &block)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Routing
7
+ module Extension
8
+ # An extension for defining RESTful Resources. For example:
9
+ #
10
+ # resource :posts, "/posts" do
11
+ # list do
12
+ # # list the posts
13
+ # end
14
+ # end
15
+ #
16
+ # +Resource+ is available in all controllers by default.
17
+ #
18
+ # = Supported Actions
19
+ #
20
+ # These actions are supported:
21
+ #
22
+ # - +list+ -- +GET /+
23
+ # - +new+ -- +GET /new+
24
+ # - +create+ -- +POST /+
25
+ # - +edit+ -- +GET /:resource_id/edit+
26
+ # - +update+ -- +PATCH /:resource_id+
27
+ # - +replace+ -- +PUT /:resource_id+
28
+ # - +delete+ -- +DELETE /:resource_id+
29
+ # - +show+ -- +GET /:resource_id+
30
+ #
31
+ # = Nested Resources
32
+ #
33
+ # Resources can be nested. For example:
34
+ #
35
+ # resource :posts, "/posts" do
36
+ # resource :comments, "/comments" do
37
+ # list do
38
+ # # available at GET /posts/:post_id/comments
39
+ # end
40
+ # end
41
+ # end
42
+ #
43
+ # = Collection Routes
44
+ #
45
+ # Routes can be defined for the collection. For example:
46
+ #
47
+ # resource :posts, "/posts" do
48
+ # collection do
49
+ # get "/foo" do
50
+ # # available at GET /posts/foo
51
+ # end
52
+ # end
53
+ # end
54
+ #
55
+ # = Member Routes
56
+ #
57
+ # Routes can be defined as members. For example:
58
+ #
59
+ # resource :posts, "/posts" do
60
+ # member do
61
+ # get "/foo" do
62
+ # # available at GET /posts/:post_id/foo
63
+ # end
64
+ # end
65
+ # end
66
+ #
67
+ module Resource
68
+ extend Support::Extension
69
+ restrict_extension Controller
70
+
71
+ DEFAULT_PARAM = :id
72
+
73
+ apply_extension do
74
+ template :resource do |param: DEFAULT_PARAM|
75
+ resource_id = ":#{param}"
76
+ nested_param = "#{Support.inflector.singularize(controller.__object_name.name)}_#{param}".to_sym
77
+ nested_resource_id = ":#{nested_param}"
78
+
79
+ action :update_request_path_for_show, only: [:show] do
80
+ connection.get(:__endpoint_path).gsub!(resource_id, "show")
81
+ end
82
+
83
+ controller.class_eval do
84
+ allow_params param
85
+
86
+ unless singleton_class.instance_methods(false).include?(:param)
87
+ define_singleton_method :param do
88
+ param
89
+ end
90
+ end
91
+
92
+ unless singleton_class.instance_methods(false).include?(:nested_param)
93
+ define_singleton_method :nested_param do
94
+ nested_param
95
+ end
96
+ end
97
+
98
+ unless instance_methods(false).include?(:update_request_path_for_show)
99
+ define_method :update_request_path_for_show do
100
+ connection.get(:__endpoint_path).gsub!(resource_id, "show")
101
+ end
102
+ end
103
+
104
+ NestedResource.define(self, nested_resource_id, nested_param)
105
+ end
106
+
107
+ get :list, "/"
108
+ get :new, "/new"
109
+ post :create, "/"
110
+ get :edit, "/#{resource_id}/edit"
111
+ patch :update, "/#{resource_id}"
112
+ put :replace, "/#{resource_id}"
113
+ delete :delete, "/#{resource_id}"
114
+ get :show, "/#{resource_id}"
115
+
116
+ group :collection
117
+ namespace :member, nested_resource_id
118
+ end
119
+ end
120
+
121
+ module NestedResource
122
+ # Nest resources as members of the current resource.
123
+ #
124
+ def self.define(controller, nested_resource_id, nested_param)
125
+ unless controller.singleton_class.instance_methods(false).include?(:namespace)
126
+ controller.define_singleton_method :namespace do |*args, &block|
127
+ super(*args, &block).tap do |namespace|
128
+ namespace.allow_params nested_param
129
+ namespace.action :update_request_path_for_parent do
130
+ connection.get(:__endpoint_path).gsub!("/#{nested_resource_id}", "")
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ unless controller.singleton_class.instance_methods(false).include?(:resource)
137
+ controller.define_singleton_method :resource do |name, matcher, param: DEFAULT_PARAM, &block|
138
+ if existing_resource = children.find { |child| child.expansions.include?(:resource) && child.__object_name.name == name }
139
+ existing_resource.instance_exec(&block); existing_resource
140
+ else
141
+ expand(:resource, name, File.join(nested_resource_id, matcher), param: param) do
142
+ allow_params nested_param
143
+
144
+ action :update_request_path_for_parent do
145
+ connection.get(:__endpoint_path).gsub!("/#{nested_resource_id}", "")
146
+ end
147
+
148
+ class_eval(&block)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/routing/extensions/resource"
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/framework"
4
+
5
+ require "pakyow/routing/controller"
6
+ require "pakyow/routing/extensions"
7
+ require "pakyow/routing/helpers/exposures"
8
+
9
+ require "pakyow/behavior/definition"
10
+
11
+ require "pakyow/security/behavior/config"
12
+ require "pakyow/security/behavior/disabling"
13
+ require "pakyow/security/behavior/helpers"
14
+ require "pakyow/security/behavior/insecure"
15
+ require "pakyow/security/behavior/pipeline"
16
+
17
+ module Pakyow
18
+ module Routing
19
+ class Framework < Pakyow::Framework(:routing)
20
+ def boot
21
+ object.class_eval do
22
+ include Pakyow::Behavior::Definition
23
+
24
+ isolate Controller do
25
+ include Extension::Resource
26
+ end
27
+
28
+ # Make controllers definable on the app.
29
+ #
30
+ stateful :controller, isolated(:Controller) do |args, _opts|
31
+ if self.ancestors.include?(Plugin)
32
+ # When using plugins, prefix controller paths with the mount path.
33
+ #
34
+ name, matcher = Controller.send(:parse_name_and_matcher_from_args, *args)
35
+ path = File.join(@mount_path, Controller.send(:path_from_matcher, matcher).to_s)
36
+ args.replace([name, path])
37
+ end
38
+ end
39
+
40
+ # Load controllers for the app.
41
+ #
42
+ aspect :controllers
43
+
44
+ # Load resources for the app.
45
+ #
46
+ aspect :resources
47
+
48
+ register_helper :active, Helpers::Exposures
49
+
50
+ # Include helpers into the controller class.
51
+ #
52
+ on "load" do
53
+ self.class.include_helpers :active, isolated(:Controller)
54
+ end
55
+
56
+ # Create the global controller instance.
57
+ #
58
+ after "initialize" do
59
+ @global_controller = isolated(:Controller).new(self)
60
+ end
61
+
62
+ # Expose the global controller for handling errors from other frameworks.
63
+ #
64
+ def controller_for_connection(connection)
65
+ @global_controller.dup.tap do |controller|
66
+ controller.instance_variable_set(:@connection, connection)
67
+ end
68
+ end
69
+
70
+ require "pakyow/support/message_verifier"
71
+ handle Support::MessageVerifier::TamperedMessage, as: :forbidden
72
+
73
+ include Security::Behavior::Config
74
+ include Security::Behavior::Disabling
75
+ include Security::Behavior::Helpers
76
+ include Security::Behavior::Insecure
77
+ include Security::Behavior::Pipeline
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pakyow
4
+ module Routing
5
+ module Helpers
6
+ module Exposures
7
+ # Expose a value by name.
8
+ #
9
+ def expose(name, default_value = default_omitted = true, &block)
10
+ value = if block_given?
11
+ yield
12
+ elsif default_omitted
13
+ __send__(name)
14
+ end
15
+
16
+ unless default_omitted
17
+ value ||= default_value
18
+ end
19
+
20
+ @connection.set(name, value)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/aargv"
4
+ require "pakyow/support/core_refinements/string/normalization"
5
+
6
+ module Pakyow
7
+ module Routing
8
+ # A {Controller} endpoint.
9
+ #
10
+ class Route
11
+ using Support::Refinements::String::Normalization
12
+
13
+ attr_reader :path, :method, :name, :block
14
+
15
+ # @api private
16
+ attr_accessor :pipeline
17
+
18
+ def initialize(path_or_matcher, name:, method:, &block)
19
+ @name, @method, @block = name, method, block
20
+
21
+ if path_or_matcher.is_a?(String)
22
+ @path = path_or_matcher.to_s
23
+ @matcher = create_matcher_from_path(@path)
24
+ else
25
+ @path = ""
26
+ @matcher = path_or_matcher
27
+ end
28
+ end
29
+
30
+ def match(path_to_match)
31
+ @matcher.match(path_to_match)
32
+ end
33
+
34
+ def call(context)
35
+ context.instance_exec(&@block) if @block
36
+ end
37
+
38
+ def build_path(path_to_self, **params)
39
+ working_path = String.normalize_path(File.join(path_to_self.to_s, @path))
40
+
41
+ params.each do |key, value|
42
+ working_path.sub!(":#{key}", value.to_s)
43
+ end
44
+
45
+ working_path.sub!("/#", "#")
46
+ working_path
47
+ end
48
+
49
+ private
50
+
51
+ def create_matcher_from_path(path)
52
+ converted_path = String.normalize_path(path.split("/").map { |segment|
53
+ if segment.include?(":")
54
+ "(?<#{segment[(segment.index(":") + 1)..-1]}>(\\w|[-~:@!$\\'\\(\\)\\*\\+,;])+)"
55
+ else
56
+ segment
57
+ end
58
+ }.join("/"))
59
+
60
+ Regexp.new("^#{converted_path}$")
61
+ end
62
+
63
+ class EndpointBuilder
64
+ attr_reader :params
65
+
66
+ def initialize(route:, path:)
67
+ @route, @path = route, path
68
+ @params = String.normalize_path(File.join(@path.to_s, @route.path)).split("/").select { |segment|
69
+ segment.start_with?(":")
70
+ }.map { |segment|
71
+ segment[1..-1].to_sym
72
+ }
73
+ end
74
+
75
+ def call(**params)
76
+ @route.build_path(@path, params)
77
+ end
78
+
79
+ def source_location
80
+ @route.block&.source_location || []
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow"
4
+ require "pakyow/support"
5
+
6
+ require "pakyow/validations"
7
+
8
+ require "pakyow/routing/framework"
9
+
10
+ require "pakyow/routing/actions/respond_missing"
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/hookable"
4
+
5
+ require "pakyow/security/errors"
6
+
7
+ module Pakyow
8
+ module Security
9
+ class Base
10
+ include Support::Hookable
11
+ events :reject
12
+
13
+ SAFE_HTTP_METHODS = %i(get head options trace).freeze
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ def call(connection)
20
+ unless safe?(connection) || allowed?(connection)
21
+ reject(connection)
22
+ end
23
+
24
+ connection
25
+ end
26
+
27
+ def reject(connection)
28
+ performing :reject do
29
+ connection.logger.warn "Request rejected by #{self.class}; connection: #{connection.inspect}"
30
+
31
+ connection.status = 403
32
+ connection.body = StringIO.new("Forbidden")
33
+
34
+ raise InsecureRequest
35
+ end
36
+ end
37
+
38
+ def safe?(connection)
39
+ SAFE_HTTP_METHODS.include? connection.method
40
+ end
41
+
42
+ def allowed?(_)
43
+ false
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Security
7
+ module Behavior
8
+ module Config
9
+ extend Support::Extension
10
+
11
+ apply_extension do
12
+ configurable :security do
13
+ configurable :csrf do
14
+ setting :protection, {}
15
+ setting :origin_whitelist, []
16
+ setting :param, :authenticity_token
17
+ end
18
+ end
19
+
20
+ require "pakyow/security/csrf/verify_same_origin"
21
+ require "pakyow/security/csrf/verify_authenticity_token"
22
+
23
+ config.security.csrf.protection = {
24
+ origin: CSRF::VerifySameOrigin.new(
25
+ origin_whitelist: config.security.csrf.origin_whitelist
26
+ ),
27
+
28
+ authenticity: CSRF::VerifyAuthenticityToken.new({}),
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Security
7
+ module Behavior
8
+ module Disabling
9
+ extend Support::Extension
10
+
11
+ apply_extension do
12
+ isolated :Controller do
13
+ def self.disable_protection(type, only: [], except: [])
14
+ if type.to_sym == :csrf
15
+ if only.any? || except.any?
16
+ Pipelines::CSRF.__pipeline.actions.each do |action|
17
+ if only.any?
18
+ skip action.target, only: only
19
+ end
20
+
21
+ if except.any?
22
+ action action.target, only: except
23
+ end
24
+ end
25
+ else
26
+ exclude_pipeline Pipelines::CSRF
27
+ end
28
+ else
29
+ raise ArgumentError, "Unknown protection type `#{type}'"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ require "pakyow/security/helpers/csrf"
6
+
7
+ module Pakyow
8
+ module Security
9
+ module Behavior
10
+ module Helpers
11
+ extend Support::Extension
12
+
13
+ apply_extension do
14
+ register_helper :passive, Security::Helpers::CSRF
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ require "pakyow/security/errors"
6
+
7
+ module Pakyow
8
+ module Security
9
+ module Behavior
10
+ module Insecure
11
+ extend Support::Extension
12
+
13
+ apply_extension do
14
+ handle InsecureRequest, as: 403 do
15
+ trigger(403)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Security
7
+ module Behavior
8
+ module Pipeline
9
+ extend Support::Extension
10
+
11
+ apply_extension do
12
+ require "pakyow/security/pipelines/csrf"
13
+
14
+ isolated :Controller do
15
+ include_pipeline Pipelines::CSRF
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/message_verifier"
4
+
5
+ require "pakyow/security/base"
6
+
7
+ module Pakyow
8
+ module Security
9
+ module CSRF
10
+ # Protects against Cross-Site Forgery Requests (CSRF).
11
+ # https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
12
+ #
13
+ # Requires a valid token be passed as a request parameter. The token consists
14
+ # of a client id (unique to the request) and a digest generated from the
15
+ # client id and the server id stored in the session.
16
+ #
17
+ # @see Pakyow::Support::MessageVerifier
18
+ #
19
+ class VerifyAuthenticityToken < Base
20
+ def allowed?(connection)
21
+ connection.verifier.verify(connection.params[connection.app.config.security.csrf.param])
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end