landline 0.9.2

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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/HACKING.md +30 -0
  3. data/LAYOUT.md +59 -0
  4. data/LICENSE.md +660 -0
  5. data/README.md +159 -0
  6. data/lib/landline/dsl/constructors_path.rb +107 -0
  7. data/lib/landline/dsl/constructors_probe.rb +28 -0
  8. data/lib/landline/dsl/methods_common.rb +28 -0
  9. data/lib/landline/dsl/methods_path.rb +75 -0
  10. data/lib/landline/dsl/methods_probe.rb +129 -0
  11. data/lib/landline/dsl/methods_template.rb +16 -0
  12. data/lib/landline/node.rb +87 -0
  13. data/lib/landline/path.rb +157 -0
  14. data/lib/landline/pattern_matching/glob.rb +168 -0
  15. data/lib/landline/pattern_matching/rematch.rb +49 -0
  16. data/lib/landline/pattern_matching/util.rb +15 -0
  17. data/lib/landline/pattern_matching.rb +75 -0
  18. data/lib/landline/probe/handler.rb +56 -0
  19. data/lib/landline/probe/http_method.rb +74 -0
  20. data/lib/landline/probe/serve_handler.rb +39 -0
  21. data/lib/landline/probe.rb +62 -0
  22. data/lib/landline/request.rb +135 -0
  23. data/lib/landline/response.rb +140 -0
  24. data/lib/landline/server.rb +49 -0
  25. data/lib/landline/template/erb.rb +27 -0
  26. data/lib/landline/template/erubi.rb +36 -0
  27. data/lib/landline/template.rb +95 -0
  28. data/lib/landline/util/cookie.rb +150 -0
  29. data/lib/landline/util/errors.rb +11 -0
  30. data/lib/landline/util/html.rb +119 -0
  31. data/lib/landline/util/lookup.rb +37 -0
  32. data/lib/landline/util/mime.rb +1276 -0
  33. data/lib/landline/util/multipart.rb +175 -0
  34. data/lib/landline/util/parsesorting.rb +37 -0
  35. data/lib/landline/util/parseutils.rb +111 -0
  36. data/lib/landline/util/query.rb +66 -0
  37. data/lib/landline.rb +20 -0
  38. metadata +85 -0
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require_relative 'util/query'
5
+ require_relative 'util/cookie'
6
+
7
+ module Landline
8
+ # Request wrapper for Rack protocol
9
+ class Request
10
+ # @param env [Array]
11
+ def initialize(env)
12
+ # Should not be used under regular circumstances or depended upon.
13
+ @_original_env = env
14
+ # Rack environment variable bindings. Should be public and frozen.
15
+ init_request_params(env)
16
+ # Cookie hash
17
+ @cookies = Landline::Cookie.from_cookie_string(@headers['cookie'])
18
+ # Query parsing
19
+ @query = Landline::Util::Query.new(@query_string)
20
+ # Pattern matching parameters. Public, readable, unfrozen.
21
+ @param = {}
22
+ @splat = []
23
+ # Traversal route. Public and writable.
24
+ @path = URI.decode_www_form_component(env["PATH_INFO"].dup)
25
+ # File serving path. Public and writable.
26
+ @filepath = "/"
27
+ # Encapsulates all rack variables. Should not be public.
28
+ @rack = init_rack_vars(env)
29
+ # Internal navigation states. Private.
30
+ @states = []
31
+ # Postprocessors for current request
32
+ @postprocessors = []
33
+ end
34
+
35
+ # Run postprocessors
36
+ # @param response [Landline::Response]
37
+ def run_postprocessors(response)
38
+ @postprocessors.each do |postproc|
39
+ postproc.call(self, response)
40
+ end
41
+ end
42
+
43
+ # Returns request body (if POST data exists)
44
+ # @return [nil, String]
45
+ def body
46
+ @body ||= @rack.input&.read
47
+ end
48
+
49
+ # Returns raw Rack input object
50
+ # @return [IO] (May not entirely be compatible with IO, see Rack/SPEC.rdoc)
51
+ def input
52
+ @rack.input
53
+ end
54
+
55
+ # Push current navigation state (path, splat, param) onto state stack
56
+ def push_state
57
+ @states.push([@path, @param.dup, @splat.dup, @filepath.dup])
58
+ end
59
+
60
+ # Load last navigation state (path, splat, param) from state stack
61
+ def pop_state
62
+ @path, @param, @splat, @filepath = @states.pop
63
+ end
64
+
65
+ attr_reader :request_method, :script_name, :path_info, :server_name,
66
+ :server_port, :server_protocol, :headers, :param, :splat,
67
+ :postprocessors, :query, :cookies
68
+ attr_accessor :path, :filepath
69
+
70
+ private
71
+
72
+ # Initialize basic rack request parameters
73
+ # @param env [Hash]
74
+ def init_request_params(env)
75
+ @request_method = env["REQUEST_METHOD"]
76
+ @script_name = env["SCRIPT_NAME"]
77
+ @path_info = env["PATH_INFO"]
78
+ @query_string = env["QUERY_STRING"]
79
+ @server_name = env["SERVER_NAME"]
80
+ @server_port = env["SERVER_PORT"]
81
+ @server_protocol = env["SERVER_PROTOCOL"]
82
+ @headers = init_headers(env)
83
+ end
84
+
85
+ # Initialize rack parameters struct
86
+ # @param env [Hash]
87
+ # @return Object
88
+ def init_rack_vars(env)
89
+ rack_vars = env.filter_map do |k, v|
90
+ [k.delete_prefix("rack."), v] if k.start_with? "rack."
91
+ end.to_h
92
+ return if rack_vars.empty?
93
+
94
+ rack_vars["multipart"] = init_multipart_vars(env)
95
+ rack_keys = rack_vars.keys
96
+ rack_keys_sym = rack_keys.map(&:to_sym)
97
+ Struct.new(*rack_keys_sym)
98
+ .new(*rack_vars.values_at(*rack_keys))
99
+ .freeze
100
+ end
101
+
102
+ # Initialize multipart parameters struct
103
+ # @param env [Hash]
104
+ # @return Object
105
+ def init_multipart_vars(env)
106
+ multipart_vars = env.filter_map do |k, v|
107
+ if k.start_with? "rack.multipart"
108
+ [k.delete_prefix("rack.multipart."), v]
109
+ end
110
+ end.to_h
111
+ return if multipart_vars.empty?
112
+
113
+ multipart_keys = multipart_vars.keys
114
+ multipart_keys_sym = multipart_keys.map(&:to_sym)
115
+ Struct.new(*multipart_keys_sym)
116
+ .new(*multipart_vars.values_at(*multipart_keys))
117
+ .freeze
118
+ end
119
+
120
+ # Iniitalize headers hash
121
+ # @param env [Hash]
122
+ # @return Hash
123
+ def init_headers(env)
124
+ headers = env.filter_map do |name, value|
125
+ [name.delete_prefix("HTTP_"), value] if name.start_with?("HTTP_")
126
+ end.to_h
127
+ headers.merge!({ "CONTENT-TYPE" => env["CONTENT_TYPE"],
128
+ "CONTENT-LENGTH" => env["CONTENT_LENGTH"],
129
+ "REMOTE_ADDR" => env["REMOTE_ADDR"] })
130
+ headers.transform_keys do |x|
131
+ x.downcase.gsub("_", "-") if x.is_a? String
132
+ end.freeze
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Landline
4
+ # Rack protocol response wrapper.
5
+ class Response
6
+ @chunk_size = 1024
7
+
8
+ self.class.attr_accessor :chunk_size
9
+
10
+ # @param response [Array(Integer, Hash, Array), nil]
11
+ def initialize(response = nil)
12
+ @cookies = {}
13
+ if response
14
+ @status = response[0]
15
+ @headers = response[1]
16
+ @body = response[2]
17
+ else
18
+ @status = 200
19
+ @headers = {}
20
+ @body = []
21
+ end
22
+ end
23
+
24
+ # Return internal representation of Rack response
25
+ # @return [Array(Integer,Hash,Array)]
26
+ def finalize
27
+ @cookies.each do |_, cookie_array|
28
+ cookie_array.each do |cookie|
29
+ add_header("set-cookie", cookie.finalize)
30
+ end
31
+ end
32
+ [@status, @headers, @body]
33
+ end
34
+
35
+ # Make internal representation conformant
36
+ # @return [Landline::Response]
37
+ def validate
38
+ if [204, 304].include?(@status) or (100..199).include?(@status)
39
+ @headers.delete "content-length"
40
+ @headers.delete "content-type"
41
+ @body = []
42
+ elsif @headers.empty?
43
+ @headers = {
44
+ "content-length" => content_size,
45
+ "content-type" => "text/html"
46
+ }
47
+ end
48
+ @body = self.class.chunk_body(@body) if @body.is_a? String
49
+ self
50
+ end
51
+
52
+ # Add a cookie to the response
53
+ # @param cookie [Landline::Cookie]
54
+ def add_cookie(cookie)
55
+ if @cookies[cookie.key]
56
+ @cookies[cookie.key].append(cookie)
57
+ else
58
+ @cookies[cookie.key] = [cookie]
59
+ end
60
+ end
61
+
62
+ # Delete a cookie
63
+ # If no value is provided, deletes all cookies with the same key
64
+ # @param key [String] cookie key
65
+ # @param value [String, nil] cookie value
66
+ def delete_cookie(key, value)
67
+ if value
68
+ @cookies[key].delete(value)
69
+ else
70
+ @cookies.delete(key)
71
+ end
72
+ end
73
+
74
+ # Add a header to the headers hash
75
+ # @param key [String] header name
76
+ # @param value [String] header value
77
+ def add_header(key, value)
78
+ if @headers[key].is_a? String
79
+ @headers[key] = [@headers[key], value]
80
+ elsif @headers[key].is_a? Array
81
+ @headers[key].append(value)
82
+ else
83
+ @headers[key] = value
84
+ end
85
+ end
86
+
87
+ # Delete a header value from the headers hash
88
+ # If no value is provided, deletes all key entries
89
+ # @param key [String] header name
90
+ # @param value [String, nil] header value
91
+ def delete_header(key, value = nil)
92
+ if value and @headers[key].is_a? Array
93
+ @headers[key].delete(value)
94
+ else
95
+ @headers.delete(key)
96
+ end
97
+ end
98
+
99
+ attr_accessor :status, :headers, :body
100
+
101
+ # Ensure response correctness
102
+ # @param obj [String, Array, Landline::Response]
103
+ # @return Response
104
+ def self.convert(obj)
105
+ case obj
106
+ when Response
107
+ obj.validate
108
+ when Array
109
+ Response.new(obj).validate
110
+ when String, File, IO
111
+ Response.new([200,
112
+ {
113
+ "content-type" => "text/html"
114
+ },
115
+ obj]).validate
116
+ else
117
+ Response.new([404, {}, []])
118
+ end
119
+ end
120
+
121
+ # Turn body into array of chunks
122
+ # @param text [String]
123
+ # @return [Array(String)]
124
+ def self.chunk_body(text)
125
+ text.chars.each_slice(@chunk_size).map(&:join)
126
+ end
127
+
128
+ private
129
+
130
+ # Try to figure out content length
131
+ # @return [Integer, nil]
132
+ def content_size
133
+ case @body
134
+ when String then @body.bytesize
135
+ when Array then @body.join.bytesize
136
+ when File then @body.size
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'path'
4
+ require_relative 'request'
5
+ require_relative 'util/html'
6
+
7
+ module Landline
8
+ class ServerContext < Landline::PathContext
9
+ end
10
+
11
+ # A specialized path that can be used directly as a Rack application.
12
+ class Server < Landline::Path
13
+ Context = ServerContext
14
+
15
+ # @param parent [Landline::Node, nil] Parent object to inherit properties to
16
+ # @param setup [#call] Setup block
17
+ def initialize(parent: nil, **args, &setup)
18
+ super("", parent: nil, **args, &setup)
19
+ return if parent
20
+
21
+ {
22
+ "index" => [],
23
+ "handle.default" => proc do |code, backtrace: nil|
24
+ page = Landline::Util.default_error_page(code, backtrace)
25
+ headers = {
26
+ "content-length": page.bytesize,
27
+ "content-type": "text/html"
28
+ }
29
+ [headers, page]
30
+ end,
31
+ "path" => "/"
32
+ }.each { |k, v| @properties[k] = v unless @properties[k] }
33
+ end
34
+
35
+ # Rack ingress point.
36
+ # This should not be called under any circumstances twice in the same application,
37
+ # although server nesting for the purpose of creating virtual hosts is allowed.
38
+ # @param env [Hash]
39
+ # @return [Array(Integer,Hash,Array)]
40
+ def call(env)
41
+ request = Landline::Request.new(env)
42
+ response = catch(:finish) do
43
+ go(request)
44
+ end
45
+ request.run_postprocessors(response)
46
+ Response.convert(response).finalize
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require_relative '../template'
5
+
6
+ module Landline
7
+ module Templates
8
+ # ERB Template language adapter
9
+ class ERB < Landline::Template
10
+ # @see {Landline::Template#new}
11
+ def initialize(input, vars = nil, parent:)
12
+ super
13
+ varname = "_part_#{SecureRandom.hex(10)}".to_sym
14
+ while @binding.local_variable_defined? varname
15
+ varname = "_part_#{SecureRandom.hex(10)}".to_sym
16
+ end
17
+ @template = ::ERB.new(@template, eoutvar: varname)
18
+ @template.filename = input.is_a?(File) ? input.path : "(Inline)"
19
+ end
20
+
21
+ # Run the template.
22
+ def run
23
+ @template.result @binding
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erubi'
4
+ require_relative '../template'
5
+
6
+ module Landline
7
+ module Templates
8
+ # Erubi (ERB) template language adapter
9
+ class Erubi < Landline::Template
10
+ # @see {Landline::Template#new}
11
+ def initialize(input,
12
+ vars = nil,
13
+ parent:,
14
+ freeze: true,
15
+ capture: false)
16
+ super(input, vars, parent: parent)
17
+ varname = "_part_#{SecureRandom.hex(10)}"
18
+ while @binding.local_variable_defined? varname.to_sym
19
+ varname = "_part_#{SecureRandom.hex(10)}"
20
+ end
21
+ properties = {
22
+ filename: input.is_a?(File) ? input.path : "(Inline)",
23
+ bufvar: varname,
24
+ freeze: freeze
25
+ }
26
+ engine = capture ? ::Erubi::CaptureEndEngine : ::Erubi::Engine
27
+ @template = engine.new(@template, properties)
28
+ end
29
+
30
+ # Run the template.
31
+ def run
32
+ @binding.eval(@template.src)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'dsl/constructors_probe'
4
+ require_relative 'dsl/methods_common'
5
+ require_relative 'dsl/methods_probe'
6
+ require_relative 'dsl/methods_template'
7
+
8
+ module Landline
9
+ # All template engine adapters subclassed from Template
10
+ module Templates
11
+ autoload :ERB, "landline/template/erb"
12
+ autoload :Erubi, "landline/template/erubi"
13
+ end
14
+
15
+ # Context for template engines
16
+ class TemplateContext
17
+ include Landline::DSL::ProbeConstructors
18
+ include Landline::DSL::ProbeMethods
19
+ include Landline::DSL::CommonMethods
20
+ include Landline::DSL::TemplateMethods
21
+
22
+ # @return [Binding]
23
+ def binding
24
+ Kernel.binding
25
+ end
26
+
27
+ def initialize(parent, parent_template)
28
+ @origin = parent
29
+ @parent_template = parent_template
30
+ end
31
+ end
32
+
33
+ # Interface for Template engines
34
+ # @abstract does not represent any actual template engine.
35
+ class Template
36
+ # @param input [String, File] template text
37
+ # @param vars [Hash] local variables for tempalte
38
+ # @param parent [Landline::Node] parent node
39
+ def initialize(input, vars = {}, parent:)
40
+ @template = input.is_a?(File) ? input.read : input
41
+ @context = TemplateContext.new(parent, self)
42
+ @parent = parent
43
+ input.close if input.is_a? File
44
+ @binding = @context.binding
45
+ vars.each do |k, v|
46
+ @binding.local_variable_set(k, v)
47
+ end
48
+ end
49
+
50
+ # Set local variable
51
+ # @param key [Symbol]
52
+ # @param value [Object]
53
+ def local_variable_set(key, value)
54
+ @binding.local_variable_set(key, value)
55
+ end
56
+
57
+ # Get local variable
58
+ # @param key [Symbol]
59
+ # @return [Object]
60
+ def local_variable_get(key)
61
+ @binding.local_variable_get(key)
62
+ end
63
+
64
+ # Get an array of defined local variables
65
+ # @return [Array(Symbol)]
66
+ def local_variables
67
+ @binding.local_variables
68
+ end
69
+
70
+ # Override binding variables.
71
+ # @param vars [Hash{Symbol => Object}]
72
+ def override_locals(vars)
73
+ vars.each do |k, v|
74
+ @binding.local_variable_set(k, v)
75
+ end
76
+ end
77
+
78
+ # Run the template
79
+ # @note This method is a stub.
80
+ def run
81
+ # ... (stub)
82
+ end
83
+
84
+ # Import a template from within current template
85
+ def import(filepath)
86
+ newtemp = self.class.new(filepath, {}, parent: @parent)
87
+ newtemp.binding = @binding
88
+ newtemp
89
+ end
90
+
91
+ protected
92
+
93
+ attr_accessor :binding
94
+ end
95
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'parseutils'
4
+ require_relative 'errors'
5
+ require 'date'
6
+ require 'openssl'
7
+ HeaderRegexp = Landline::Util::HeaderRegexp
8
+ ParserCommon = Landline::Util::ParserCommon
9
+
10
+ module Landline
11
+ # Utility class for handling cookies
12
+ class Cookie
13
+ # @param key [String] cookie name
14
+ # @param value [String] cookie value
15
+ # @param params [Hash] cookie parameters
16
+ # @option params [String] "domain"
17
+ # @option params [String] "path"
18
+ # @option params [boolean, nil] "secure" (false)
19
+ # @option params [boolean, nil] "httponly" (false)
20
+ # @option params [String] "samesite"
21
+ # @option params [String, Integer] "max-age"
22
+ # @option params [String, Date] "expires"
23
+ # @raise Landline::ParsingError invalid cookie parameters
24
+ def initialize(key, value, params = {})
25
+ unless key.match? HeaderRegexp::COOKIE_NAME
26
+ raise Landline::ParsingError, "invalid cookie key: #{key}"
27
+ end
28
+
29
+ unless value.match? HeaderRegexp::COOKIE_VALUE
30
+ raise Landline::ParsingError, "invalid cookie value: #{value}"
31
+ end
32
+
33
+ # Make param keys strings
34
+ params.transform_keys!(&:to_s)
35
+
36
+ # Primary cookie parameters
37
+ @key = key
38
+ @value = value
39
+ setup_params(params)
40
+
41
+ # Cookie signing parameters
42
+ setup_hmac(params)
43
+ end
44
+
45
+ # Convert cookie to "Set-Cookie: " string representation.
46
+ # @return [String]
47
+ def finalize
48
+ sign(@hmac, algorithm: @algorithm, sep: @sep) if @hmac
49
+ ParserCommon.make_value(
50
+ "#{key.to_s.strip}=#{value.to_s.strip}",
51
+ {
52
+ "Domain" => @domain,
53
+ "Path" => @path,
54
+ "Expires" => @expires,
55
+ "Max-Age" => @maxage,
56
+ "SameSite" => @samesite,
57
+ "Secure" => @secure,
58
+ "HttpOnly" => @httponly
59
+ }
60
+ )
61
+ end
62
+
63
+ # Convert cookie to "Cookie: " string representation (no params)
64
+ # @return [String]
65
+ def finalize_short
66
+ sign(@hmac, algorithm: @algorithm, sep: @sep) if @hmac
67
+ "#{key.to_s.strip}=#{value.to_s.strip}"
68
+ end
69
+
70
+ # Sign the cookie value with HMAC
71
+ # @param key [String] HMAC signing key
72
+ # @param algorithm [String] Hash algorithm to use
73
+ # @param sep [String] Hash separator
74
+ def sign(key, algorithm: "sha256", sep: "&")
75
+ @value += sep + ::OpenSSL::HMAC.base64digest(algorithm, key, @value)
76
+ end
77
+
78
+ # Verify HMAC signature
79
+ # @param key [String] HMAC signing key
80
+ # @param algorithm [String] Hash algorithm
81
+ # @param sep [String] Hash separator
82
+ # @return [Boolean] whether value is signed and valid
83
+ def verify(key, algorithm: "sha256", sep: "&")
84
+ val, sig = @value.match(/\A(.*)#{sep}([A-Za-z0-9+\/=]+)\Z/).to_a[1..]
85
+ return false unless val and sig
86
+
87
+ sig == ::OpenSSL::HMAC.base64digest(algorithm, key, val)
88
+ end
89
+
90
+ attr_accessor :key, :value
91
+ attr_reader :domain, :path, :expires, :maxage, :samesite, :secure, :httponly
92
+
93
+ # Create cookie from a "Set-Cookie: " format
94
+ # @param data [String] value part of "Set-Cookie: " header
95
+ # @return [Cookie]
96
+ def self.from_setcookie_string(data)
97
+ kvpair, params = parse_value(data, regexp: HeaderRegexp::COOKIE_PARAM)
98
+ key, value = kvpair.match(/([^=]+)=?(.*)/).to_a[1..].map(&:strip)
99
+ Cookie.new(key, value, params)
100
+ end
101
+
102
+ # Create cookie(s) from a "Cookie: " format
103
+ # @param data [String] value part of "Cookie: " header
104
+ # @return [Hash{String => Cookie}]
105
+ def self.from_cookie_string(data)
106
+ hash = {}
107
+ return hash if data.nil?
108
+
109
+ data.split(";").map do |cookiestr|
110
+ key, value = cookiestr.match(/([^=]+)=?(.*)/).to_a[1..].map(&:strip)
111
+ cookie = Cookie.new(key, value)
112
+ if hash[cookie.key]
113
+ hash[cookie.key].append(cookie)
114
+ else
115
+ hash[cookie.key] = [cookie]
116
+ end
117
+ end
118
+ hash
119
+ end
120
+
121
+ private
122
+
123
+ def setup_hmac(params)
124
+ @hmac = params['hmac']
125
+ @algorithm = (params['algorithm'] or "sha256")
126
+ @sep = (params['sep'] or "&")
127
+ end
128
+
129
+ def setup_params(params)
130
+ # Extended cookie params
131
+ params.transform_keys!(&:downcase)
132
+ convert_date(params)
133
+ @domain = params['domain']&.remove_prefix(".")
134
+ @path = params['path']
135
+ @secure = !params['secure'].nil?
136
+ @httponly = !params['httponly'].nil?
137
+ @samesite = params['samesite'] or "None"
138
+ end
139
+
140
+ def convert_date(params)
141
+ maxage = params['max-age']
142
+ expires = params['expires']
143
+ @maxage = maxage.to_i if maxage&.match?(/\A\d+\z/)
144
+ @expires = case expires
145
+ when Date then date.ctime(HeaderRegexp::RFC1123_DATE)
146
+ when String then expires if Date.httpdate(expires)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Landline
4
+ # Generic error class, as recommended by Ruby documentation.
5
+ class Error < ::StandardError
6
+ end
7
+
8
+ # Error class raised by landline/util/parseutils module.
9
+ class ParsingError < Error
10
+ end
11
+ end