landline 0.10.0 → 0.12.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.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Landline
6
+ # Module for controlling session signing secrets
7
+ module Session
8
+ # Set hmac secret
9
+ # @param secret [String]
10
+ def self.hmac_secret=(secret)
11
+ @hmac_secret = secret
12
+ end
13
+
14
+ # Get hmac secret
15
+ def self.hmac_secret
16
+ unless @hmac_secret or ENV['HMAC_SECRET']
17
+ warn <<~MSG
18
+ warn: hmac secret not supplied, using randomized one
19
+ warn: provide hmac secret with $HMAC_SECRET or Landline::Session.hmac_secret
20
+ MSG
21
+ end
22
+ @hmac_secret ||= ENV.fetch('HMAC_SECRET', SecureRandom.base64(80))
23
+ end
24
+
25
+ # Class for representing session errors
26
+ class SessionError < ::StandardError
27
+ end
28
+
29
+ # Class for representing session storage
30
+ class Session
31
+ def initialize(cookie, cookies_callback)
32
+ @data = if cookie
33
+ Landline::Util::JWT.from_string(
34
+ cookie,
35
+ Landline::Session.hmac_secret
36
+ )
37
+ else
38
+ Landline::Util::JWT.new({})
39
+ end
40
+ @valid = !@data.nil?
41
+ @cookies_callback = cookies_callback
42
+ end
43
+
44
+ # Retrieve data from session storage
45
+ # @param key [String, Symbol] serializable key
46
+ def [](key)
47
+ raise Landline::Session::SessionError, "session not valid" unless @valid
48
+
49
+ unless key.is_a? String or key.is_a? Symbol
50
+ raise StandardError, "key not serializable"
51
+ end
52
+
53
+ @data.data[key]
54
+ end
55
+
56
+ # Set data to session storage
57
+ # @param key [String, Symbol] serializable key
58
+ # @param value [Object] serializable data
59
+ def []=(key, value)
60
+ raise Landline::Session::SessionError, "session not valid" unless @valid
61
+
62
+ unless key.is_a? String or key.is_a? Symbol
63
+ raise StandardError, "key not serializable"
64
+ end
65
+
66
+ @data.data[key] = value
67
+ @cookies_callback.call(@data.make(Landline::Session.hmac_secret))
68
+ end
69
+
70
+ attr_reader :valid
71
+ end
72
+ end
73
+ end
74
+
75
+ module Landline
76
+ module DSL
77
+ module ProbeMethods
78
+ # (in Landline::Probe context)
79
+ # Return session storage hash
80
+ # @return [Landline::Session::Session]
81
+ def session
82
+ return @session if @session
83
+
84
+ @session = Landline::Session::Session.new(
85
+ request.cookies.dig('session', 0)&.value,
86
+ proc do |value|
87
+ delete_cookie("session", value)
88
+ cookie("session", value)
89
+ end
90
+ )
91
+ request.postprocessors.append(proc do
92
+ @session = nil
93
+ end)
94
+ @session
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'websocket'
4
+ module Landline
5
+ # Module that holds websocket primitives
6
+ module WebSocket
7
+ # Event system
8
+ module Eventifier
9
+ # Attach event listener
10
+ # @param event [Symbol]
11
+ # @param listener [#call]
12
+ def on(event, &listener)
13
+ @__listeners ||= {}
14
+ @__listeners[event] ||= []
15
+ @__listeners[event].append(listener)
16
+ listener
17
+ end
18
+
19
+ # Attach event listener
20
+ # @param event [Symbol]
21
+ # @param listener [#call]
22
+ def off(event, listener)
23
+ @__listeners ||= {}
24
+ @__listeners[event]&.delete(listener)
25
+ end
26
+
27
+ # Await for an event
28
+ # @param event [Symbol, Array<Symbol>] event or array of events to wait for
29
+ # @return [Array]
30
+ # @sg-ignore
31
+ def await(event)
32
+ blocking = true
33
+ output = nil
34
+ listener = proc do |*data|
35
+ output = data
36
+ blocking = false
37
+ end
38
+ if event.is_a? Array
39
+ event.each { |x| on(x, &listener) }
40
+ else
41
+ on(event, &listener)
42
+ end
43
+ while blocking; end
44
+ return output[0] if output.is_a? Array and output.length == 1
45
+
46
+ output
47
+ end
48
+
49
+ private
50
+
51
+ # Trigger the queue clearing process
52
+ # @return [void]
53
+ def _process
54
+ return if @processing
55
+
56
+ @__processing = true
57
+ @__queue ||= []
58
+ @__listeners ||= {}
59
+ until @__queue.empty?
60
+ event, msg = @__queue.shift
61
+ if @__listeners.include? event
62
+ @__listeners[event].each { |x| x.call(*msg) }
63
+ end
64
+ end
65
+ @processing = false
66
+ end
67
+
68
+ # Send internal event
69
+ # @param event [Symbol]
70
+ # @param data [Array]
71
+ # @return [void]
72
+ def _emit(event, *data)
73
+ return unless @__listeners
74
+
75
+ return unless @__listeners.include? event
76
+
77
+ @__queue ||= []
78
+ @__queue.push([event, data])
79
+ _process
80
+ end
81
+ end
82
+
83
+ # Socket-like object representing websocket interface
84
+ class WSockWrapper
85
+ include Eventifier
86
+
87
+ class WebSocketError < StandardError
88
+ end
89
+
90
+ def initialize(io, version: 7)
91
+ @io = io
92
+ @version = version
93
+ @frame_parser = ::WebSocket::Frame::Incoming::Server.new(
94
+ version: version
95
+ )
96
+ @readable = true
97
+ @writable = true
98
+ @data = Queue.new
99
+ on :message do |msg|
100
+ @data.enq(msg)
101
+ end
102
+ end
103
+
104
+ # Start the main loop for the eventifier
105
+ # @return [void]
106
+ def ready
107
+ return if @ready
108
+
109
+ _loop
110
+ @ready = true
111
+ end
112
+
113
+ # Send data through websocket
114
+ # @param data [String] binary data
115
+ # @return [void]
116
+ def write(data, type: :text)
117
+ unless @writable
118
+ raise self.class::WebSocketError,
119
+ "socket closed for writing"
120
+ end
121
+
122
+ frame = ::WebSocket::Frame::Outgoing::Server.new(
123
+ version: @version,
124
+ data: data,
125
+ type: type
126
+ )
127
+ @io.write(frame.to_s)
128
+ end
129
+
130
+ # Read data from socket synchronously
131
+ # @return [String, nil] nil returned if socket closes
132
+ def read
133
+ unless @readable
134
+ raise self.class::WebSocketError,
135
+ "socket closed for reading"
136
+ end
137
+
138
+ @data.deq
139
+ end
140
+
141
+ # Close the socket for reading
142
+ # @return [void]
143
+ def close_read
144
+ _emit :close
145
+ @readable = false
146
+ @io.close_read
147
+ end
148
+
149
+ # Close the socket for writing
150
+ def close_write
151
+ @writable = false
152
+ @io.close_write
153
+ end
154
+
155
+ # Establish a connection through handshake
156
+ # @return [self]
157
+ def self.handshake(request, version: 7, **opts)
158
+ raise StandardError, "stream cannot be hijacked" unless request.hijack
159
+
160
+ handshake = create_handshake(request, version: version, **opts)
161
+ return nil unless handshake
162
+
163
+ io = request.hijack.call
164
+ io.sendmsg(handshake.to_s)
165
+ new(io, version: version)
166
+ end
167
+
168
+ # Initiate a handshake
169
+ def self.create_handshake(request, **opts)
170
+ handshake = ::WebSocket::Handshake::Server.new(**opts)
171
+ handshake.from_hash({
172
+ headers: request.headers,
173
+ path: request.path_info,
174
+ query: request.query.query,
175
+ body: request.body
176
+ })
177
+ return nil unless handshake.finished? and handshake.valid?
178
+
179
+ handshake
180
+ end
181
+
182
+ # Close the socket
183
+ # @return [void]
184
+ def close
185
+ _close
186
+ @writable = false
187
+ @readable = false
188
+ end
189
+
190
+ private
191
+
192
+ # Event reading loop
193
+ # @return [void]
194
+ def _loop
195
+ @thread = Thread.new do
196
+ loop do
197
+ msg = _read
198
+ if msg and [:text, :binary].include? msg.type
199
+ _emit :message, msg
200
+ elsif msg and msg.type == :close
201
+ _emit :__close, msg
202
+ break
203
+ end
204
+ end
205
+ rescue IOError => e
206
+ @writable = false
207
+ _emit :error, e
208
+ close
209
+ ensure
210
+ close_read
211
+ end
212
+ end
213
+
214
+ # Receive data through websocket
215
+ # @return [String] output from frame
216
+ def _read
217
+ while (char = @io.getc)
218
+ @frame_parser << char
219
+ frame = @frame_parser.next
220
+ return frame if frame
221
+ end
222
+ end
223
+
224
+ # Close the websocket
225
+ # @return [void]
226
+ def _close
227
+ frame = ::WebSocket::Frame::Outgoing::Server.new(
228
+ version: @version,
229
+ type: :close
230
+ )
231
+ @io.write(frame.to_s) if @writable
232
+ sleep 0.1
233
+ @io.close
234
+ end
235
+ end
236
+ end
237
+ end
238
+
239
+ module Landline
240
+ module Handlers
241
+ # Web socket server handler
242
+ class WebSockServer < Landline::Probe
243
+ # @param path [Object]
244
+ # @param parent [Landline::Node]
245
+ # @param params [Hash] options hash
246
+ # @param callback [#call] callback to process request within
247
+ # @option params [Integer] :version protocol version
248
+ # @option params [Array<String>] :protocols array of supported sub-protocols
249
+ # @option params [Boolean] :secure true if the server will use wss:// protocol
250
+ def initialize(path, parent:, **params, &callback)
251
+ nodeparams = params.dup
252
+ nodeparams.delete(:version)
253
+ nodeparams.delete(:protocols)
254
+ nodeparams.delete(:secure)
255
+ super(path, parent: parent, **nodeparams)
256
+ @callback = callback
257
+ @params = params
258
+ end
259
+
260
+ # Method callback on successful request navigation
261
+ # Creates a websocket from a given request
262
+ # @param request [Landline::Request]
263
+ def process(request)
264
+ @callback.call(
265
+ Landline::WebSocket::WSockWrapper.handshake(
266
+ request,
267
+ **@params
268
+ )
269
+ )
270
+ end
271
+ end
272
+ end
273
+
274
+ module DSL
275
+ module PathConstructors
276
+ # (in Landline::Path context)
277
+ # Create a new websocket handler
278
+ def websocket(path, **args, &setup)
279
+ register(Landline::Handlers::WebSockServer.new(path,
280
+ parent: @origin,
281
+ **args,
282
+ &setup))
283
+ end
284
+ end
285
+ end
286
+ end
data/lib/landline/path.rb CHANGED
@@ -31,8 +31,20 @@ module Landline
31
31
  end
32
32
  end
33
33
 
34
+ # Ephemeral proxy class to which callback execution binds
35
+ class PathExecutionOrigin
36
+ def initialize(request, properties)
37
+ @request = request
38
+ @properties = Landline::Util::LookupROProxy.new(properties)
39
+ end
40
+
41
+ attr_accessor :response
42
+ attr_reader :request, :properties
43
+ end
44
+
34
45
  # Primary building block of request navigation.
35
46
  class Path < Landline::Node
47
+ ExecutionOrigin = Landline::PathExecutionOrigin
36
48
  ProcContext = Landline::ProcessorContext
37
49
  Context = Landline::PathContext
38
50
 
@@ -50,7 +62,6 @@ module Landline
50
62
  # Contexts setup
51
63
  context = self.class::Context.new(self)
52
64
  context.instance_exec(&setup)
53
- @proccontext = self.class::ProcContext.new(self)
54
65
  end
55
66
 
56
67
  # Method callback on successful request navigation.
@@ -89,18 +100,25 @@ module Landline
89
100
  @filters.append(block)
90
101
  end
91
102
 
92
- attr_reader :children, :properties, :request
103
+ attr_reader :children, :properties
93
104
 
94
105
  attr_accessor :bounce, :pipeline
95
106
 
96
107
  private
97
108
 
109
+ # Create an execution context for in-path processing blocks
110
+ def get_context(request)
111
+ exec_origin = self.class::ExecutionOrigin.new(request, @properties)
112
+ self.class::ProcContext.new(exec_origin)
113
+ end
114
+
98
115
  # Sequentially run through all filters and drop request if one is false
99
116
  # @param request [Landline::Request]
100
117
  # @return [Boolean] true if request passed all filters
101
118
  def run_filters(request)
119
+ proccontext = get_context(request)
102
120
  @filters.each do |filter|
103
- return false unless @proccontext.instance_exec(request, &filter)
121
+ return false unless proccontext.instance_exec(request, &filter)
104
122
  end
105
123
  true
106
124
  end
@@ -108,8 +126,9 @@ module Landline
108
126
  # Sequentially run all preprocessors on a request
109
127
  # @param request [Landline::Request]
110
128
  def run_preprocessors(request)
129
+ proccontext = get_context(request)
111
130
  @preprocessors.each do |preproc|
112
- @proccontext.instance_exec(request, &preproc)
131
+ proccontext.instance_exec(request, &preproc)
113
132
  end
114
133
  end
115
134
 
@@ -125,23 +144,27 @@ module Landline
125
144
  # @return [Boolean] true if further navigation will be done
126
145
  # @raise [UncaughtThrowError] by default throws :response if no matches found.
127
146
  def process_wrapped(request)
128
- @request = request
129
147
  return false unless run_filters(request)
130
148
 
131
149
  run_preprocessors(request)
132
150
  enqueue_postprocessors(request)
133
151
  @children.each do |x|
134
152
  value = x.go(request)
135
- return value if value
153
+ return exit_stack(request, value) if value
136
154
  end
137
155
  value = index(request)
138
- return value if value
156
+ return exit_stack(request, value) if value
139
157
 
140
- @bounce ? false : _die(404)
158
+ @bounce ? exit_stack(request) : _die(404)
141
159
  rescue StandardError => e
142
- _die(500, backtrace: [e.to_s] + e.backtrace)
143
- ensure
144
- @request = nil
160
+ _die(request, 500, backtrace: [e.to_s] + e.backtrace)
161
+ end
162
+
163
+ # Run enqueued postprocessors on navigation failure
164
+ # @param request [Landline::Request]
165
+ def exit_stack(request, response = nil)
166
+ request.run_postprocessors(response)
167
+ response
145
168
  end
146
169
 
147
170
  # Try to perform indexing on the path if possible
@@ -165,9 +188,10 @@ module Landline
165
188
  # @param errorcode [Integer]
166
189
  # @param backtrace [Array(String), nil]
167
190
  # @raise [UncaughtThrowError] throws :finish to stop processing
168
- def _die(errorcode, backtrace: nil)
191
+ def _die(request, errorcode, backtrace: nil)
192
+ proccontext = get_context(request)
169
193
  throw :finish, [errorcode].append(
170
- *@proccontext.instance_exec(
194
+ *proccontext.instance_exec(
171
195
  errorcode,
172
196
  backtrace: backtrace,
173
197
  &(@properties["handle.#{errorcode}"] or
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../probe"
4
+
5
+ module Landline
6
+ module Handlers
7
+ # Probe that sends files from a location
8
+ class Link < Landline::Probe
9
+ # @param path [Object]
10
+ # @param parent [Landline::Node]
11
+ def initialize(path, application, parent:)
12
+ @application = application
13
+ super(path, parent: parent, filepath: true)
14
+ end
15
+
16
+ # Method callback on successful request navigation.
17
+ # Sends request over to another rack app, stripping the part of the path that was not navigated
18
+ # @param request [Landline::Request]
19
+ # @return [Array(Integer, Host{String => Object}, Object)]
20
+ def process(request)
21
+ throw :finish, @application.call(request.env)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -12,13 +12,8 @@ module Landline
12
12
  def initialize(path, **args, &exec)
13
13
  super(path, **args)
14
14
  @callback = exec
15
- @context = Landline::ProbeContext.new(self)
16
- @response = nil
17
15
  end
18
16
 
19
- attr_accessor :response
20
- attr_reader :request
21
-
22
17
  # Method callback on successful request navigation.
23
18
  # Runs block supplied with object initialization.
24
19
  # Request's #splat and #param are passed to block.
@@ -34,20 +29,21 @@ module Landline
34
29
  # @return [Boolean] true if further navigation is possible
35
30
  # @raise [UncaughtThrowError] may raise if die() is called.
36
31
  def process(request)
37
- @response = nil
32
+ origin = Landline::ProbeExecutionOrigin.new(request, @properties)
33
+ context = Landline::ProbeContext.new(origin)
38
34
  return reject(request) unless request.path.match?(/^\/?$/)
39
-
40
- @request = request
35
+
41
36
  response = catch(:break) do
42
- @context.instance_exec(*request.splat,
43
- **request.param,
44
- &@callback)
37
+ context.instance_exec(*request.splat,
38
+ **request.param,
39
+ &@callback)
45
40
  end
46
41
  return false unless response
47
-
48
- if @response and [String, File, IO].include? response.class
49
- @response.body = response
50
- throw :finish, @response
42
+
43
+ oresponse = origin.response
44
+ if oresponse and [String, File, IO].include? response.class
45
+ oresponse.body = response
46
+ throw :finish, oresponse
51
47
  end
52
48
  throw :finish, response
53
49
  end
@@ -21,6 +21,7 @@ module Landline
21
21
  autoload :TRACE, "landline/probe/http_method"
22
22
  autoload :PATCH, "landline/probe/http_method"
23
23
  autoload :Serve, "landline/probe/serve_handler"
24
+ autoload :Link, "landline/probe/crosscall_handler"
24
25
  end
25
26
 
26
27
  # Context that provides execution context for Probes.
@@ -34,6 +35,17 @@ module Landline
34
35
  end
35
36
  end
36
37
 
38
+ # Ephemeral proxy class to which callback execution binds
39
+ class ProbeExecutionOrigin
40
+ def initialize(request, properties)
41
+ @request = request
42
+ @properties = Landline::Util::LookupROProxy.new(properties)
43
+ end
44
+
45
+ attr_accessor :response
46
+ attr_reader :request, :properties
47
+ end
48
+
37
49
  # Test probe. Also base for all "reactive" nodes.
38
50
  class Probe < Landline::Node
39
51
  # @param path [Object]
@@ -21,10 +21,10 @@ module Landline
21
21
  @param = {}
22
22
  @splat = []
23
23
  # Traversal route. Public and writable.
24
- @path = URI.decode_www_form_component(env["PATH_INFO"].dup)
24
+ @path = URI.decode_www_form_component(env["PATH_INFO"])
25
25
  # File serving path. Public and writable.
26
26
  @filepath = "/"
27
- # Encapsulates all rack variables. Should not be public.
27
+ # Encapsulates all rack variables. Is no longer private, but usually should not be used directly
28
28
  @rack = init_rack_vars(env)
29
29
  # Internal navigation states. Private.
30
30
  @states = []
@@ -35,18 +35,21 @@ module Landline
35
35
  # Run postprocessors
36
36
  # @param response [Landline::Response]
37
37
  def run_postprocessors(response)
38
- @postprocessors.each do |postproc|
38
+ @postprocessors.reverse_each do |postproc|
39
39
  postproc.call(self, response)
40
40
  end
41
+ @postprocessors = []
41
42
  end
42
43
 
43
44
  # Returns request body (if POST data exists)
45
+ # @note reads data from rack.input, which is not rewindable. .body data is memoized.
44
46
  # @return [nil, String]
45
47
  def body
46
48
  @body ||= @rack.input&.read
47
49
  end
48
50
 
49
51
  # Returns raw Rack input object
52
+ # @note Rack IO is not always rewindable - if it is read once, the data is gone (i.e. request.body will return nothing).
50
53
  # @return [IO] (May not entirely be compatible with IO, see Rack/SPEC.rdoc)
51
54
  def input
52
55
  @rack.input
@@ -62,10 +65,33 @@ module Landline
62
65
  @path, @param, @splat, @filepath = @states.pop
63
66
  end
64
67
 
68
+ # Checks if response stream can be partially hijacked
69
+ def hijack?
70
+ @_original_env['rack.hijack?']
71
+ end
72
+
73
+ # Returns full hijack callback
74
+ def hijack
75
+ @_original_env['rack.hijack']
76
+ end
77
+
78
+ # Reconstructs rack env after modification
79
+ def env
80
+ path = @path
81
+ @_original_env.merge(reconstruct_headers)
82
+ .merge({
83
+ 'PATH_INFO' => path,
84
+ 'REQUEST_PATH' => path,
85
+ 'QUERY_STRING' => query.query,
86
+ 'REQUEST_URI' => "#{path}?#{query.query}"
87
+ })
88
+ .merge(reconstruct_cookie)
89
+ end
90
+
65
91
  attr_reader :request_method, :script_name, :path_info, :server_name,
66
92
  :server_port, :server_protocol, :headers, :param, :splat,
67
- :postprocessors, :query, :cookies
68
- attr_accessor :path, :filepath
93
+ :postprocessors, :cookies, :rack
94
+ attr_accessor :path, :filepath, :query
69
95
 
70
96
  private
71
97
 
@@ -117,7 +143,7 @@ module Landline
117
143
  .freeze
118
144
  end
119
145
 
120
- # Iniitalize headers hash
146
+ # Initialize headers hash
121
147
  # @param env [Hash]
122
148
  # @return Hash
123
149
  def init_headers(env)
@@ -131,5 +157,30 @@ module Landline
131
157
  x.downcase.gsub("_", "-") if x.is_a? String
132
158
  end.freeze
133
159
  end
160
+
161
+ # Reconstruct headers
162
+ def reconstruct_headers
163
+ @headers.filter_map do |k, v|
164
+ next unless v
165
+
166
+ if !['content-type', 'content-length',
167
+ 'remote-addr'].include?(k) && (k.is_a? String)
168
+ k = "http_#{k}"
169
+ end
170
+ k = k.upcase.gsub("-", "_")
171
+ [k, v]
172
+ end.to_h
173
+ end
174
+
175
+ # Reconstruct cookie string
176
+ def reconstruct_cookie
177
+ return {} if @cookies.empty?
178
+
179
+ {
180
+ "HTTP_COOKIE" => @cookies.map do |_, v|
181
+ v.finalize_short
182
+ end.join(";")
183
+ }
184
+ end
134
185
  end
135
186
  end