landline 0.9.3 → 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/node.rb CHANGED
@@ -35,7 +35,7 @@ module Landline
35
35
  end
36
36
 
37
37
  # Try to navigate the path. Run method callback in response.
38
- # @param [Landline::Request]
38
+ # @param request [Landline::Request]
39
39
  # @return [Boolean]
40
40
  def go(request)
41
41
  # rejected at pattern
@@ -43,7 +43,8 @@ module Landline
43
43
 
44
44
  request.push_state
45
45
  path, splat, param = @pattern.match(request.path)
46
- do_filepath(request, request.path.delete_suffix(path))
46
+ do_filepath(request, request.path.delete_suffix('/')
47
+ .delete_suffix(path))
47
48
  request.path = path
48
49
  request.splat.append(*splat)
49
50
  request.param.merge!(param)
data/lib/landline/path.rb CHANGED
@@ -5,12 +5,16 @@ require_relative 'node'
5
5
  require_relative 'dsl/constructors_path'
6
6
  require_relative 'dsl/methods_path'
7
7
  require_relative 'dsl/methods_common'
8
+ require_relative 'dsl/methods_probe'
9
+ require_relative 'dsl/constructors_probe'
8
10
  require_relative 'util/lookup'
9
11
 
10
12
  module Landline
11
13
  # Execution context for filters and preprocessors.
12
14
  class ProcessorContext
13
15
  include Landline::DSL::CommonMethods
16
+ include Landline::DSL::ProbeMethods
17
+ include Landline::DSL::ProbeConstructors
14
18
 
15
19
  def initialize(path)
16
20
  @origin = path
@@ -27,8 +31,20 @@ module Landline
27
31
  end
28
32
  end
29
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
+
30
45
  # Primary building block of request navigation.
31
46
  class Path < Landline::Node
47
+ ExecutionOrigin = Landline::PathExecutionOrigin
32
48
  ProcContext = Landline::ProcessorContext
33
49
  Context = Landline::PathContext
34
50
 
@@ -46,7 +62,6 @@ module Landline
46
62
  # Contexts setup
47
63
  context = self.class::Context.new(self)
48
64
  context.instance_exec(&setup)
49
- @proccontext = self.class::ProcContext.new(self)
50
65
  end
51
66
 
52
67
  # Method callback on successful request navigation.
@@ -54,21 +69,11 @@ module Landline
54
69
  # @return [Boolean] true if further navigation will be done
55
70
  # @raise [UncaughtThrowError] by default throws :response if no matches found.
56
71
  def process(request)
57
- return false unless run_filters(request)
58
-
59
- run_preprocessors(request)
60
- enqueue_postprocessors(request)
61
- @children.each do |x|
62
- if (value = x.go(request))
63
- return value
64
- end
72
+ if @pipeline
73
+ @pipeline.call(request) { |inner_req| process_wrapped(inner_req) }
74
+ else
75
+ process_wrapped(request)
65
76
  end
66
- value = index(request)
67
- return value if value
68
-
69
- _die(404)
70
- rescue StandardError => e
71
- _die(500, backtrace: [e.to_s] + e.backtrace)
72
77
  end
73
78
 
74
79
  # Add a preprocessor to the path.
@@ -97,14 +102,23 @@ module Landline
97
102
 
98
103
  attr_reader :children, :properties
99
104
 
105
+ attr_accessor :bounce, :pipeline
106
+
100
107
  private
101
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
+
102
115
  # Sequentially run through all filters and drop request if one is false
103
116
  # @param request [Landline::Request]
104
117
  # @return [Boolean] true if request passed all filters
105
118
  def run_filters(request)
119
+ proccontext = get_context(request)
106
120
  @filters.each do |filter|
107
- return false unless @proccontext.instance_exec(request, &filter)
121
+ return false unless proccontext.instance_exec(request, &filter)
108
122
  end
109
123
  true
110
124
  end
@@ -112,8 +126,9 @@ module Landline
112
126
  # Sequentially run all preprocessors on a request
113
127
  # @param request [Landline::Request]
114
128
  def run_preprocessors(request)
129
+ proccontext = get_context(request)
115
130
  @preprocessors.each do |preproc|
116
- @proccontext.instance_exec(request, &preproc)
131
+ proccontext.instance_exec(request, &preproc)
117
132
  end
118
133
  end
119
134
 
@@ -123,6 +138,35 @@ module Landline
123
138
  request.postprocessors.append(*@postprocessors)
124
139
  end
125
140
 
141
+ # Method callback on successful request navigation.
142
+ # Finds the next appropriate path to go to.
143
+ # (inner pipeline-wrapped handler)
144
+ # @return [Boolean] true if further navigation will be done
145
+ # @raise [UncaughtThrowError] by default throws :response if no matches found.
146
+ def process_wrapped(request)
147
+ return false unless run_filters(request)
148
+
149
+ run_preprocessors(request)
150
+ enqueue_postprocessors(request)
151
+ @children.each do |x|
152
+ value = x.go(request)
153
+ return exit_stack(request, value) if value
154
+ end
155
+ value = index(request)
156
+ return exit_stack(request, value) if value
157
+
158
+ @bounce ? exit_stack(request) : _die(404)
159
+ rescue StandardError => e
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
168
+ end
169
+
126
170
  # Try to perform indexing on the path if possible
127
171
  # @param request [Landline::Request]
128
172
  # @return [Boolean] true if indexing succeeded
@@ -144,13 +188,15 @@ module Landline
144
188
  # @param errorcode [Integer]
145
189
  # @param backtrace [Array(String), nil]
146
190
  # @raise [UncaughtThrowError] throws :finish to stop processing
147
- def _die(errorcode, backtrace: nil)
191
+ def _die(request, errorcode, backtrace: nil)
192
+ proccontext = get_context(request)
148
193
  throw :finish, [errorcode].append(
149
- *(@properties["handle.#{errorcode}"] or
150
- @properties["handle.default"]).call(
151
- errorcode,
152
- backtrace: backtrace
153
- )
194
+ *proccontext.instance_exec(
195
+ errorcode,
196
+ backtrace: backtrace,
197
+ &(@properties["handle.#{errorcode}"] or
198
+ @properties["handle.default"])
199
+ )
154
200
  )
155
201
  end
156
202
  end
@@ -52,7 +52,7 @@ module Landline
52
52
  # - underscores
53
53
  # - dashes
54
54
  class Glob
55
- # @param input [String] Glob pattern
55
+ # @param pattern [String] Glob pattern
56
56
  def initialize(pattern)
57
57
  pattern = Landline::PatternMatching.canonicalize(pattern)
58
58
  pieces = pattern.split(TOKENS)
@@ -39,7 +39,7 @@ module Landline
39
39
  end
40
40
 
41
41
  # Test if input is convertible and if it should be converted.
42
- # @param input
42
+ # @param string [String] input to test conversion on
43
43
  # @return [Boolean] Input can be safely converted to Glob
44
44
  def self.can_convert?(string)
45
45
  string.is_a? Regexp
@@ -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
@@ -7,18 +7,13 @@ module Landline
7
7
  # Probe that executes a callback on request
8
8
  class Handler < Landline::Probe
9
9
  # @param path [Object]
10
- # @param parent [Landline::Node]
10
+ # @param args [Hash] hashed parameters to passthrough to Probe
11
11
  # @param exec [#call]
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
-
19
- attr_accessor :response
20
- attr_reader :request
21
-
16
+
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
@@ -9,7 +9,6 @@ module Landline
9
9
  class Serve < Landline::Probe
10
10
  # @param path [Object]
11
11
  # @param parent [Landline::Node]
12
- # @param exec [#call]
13
12
  def initialize(path, parent:)
14
13
  super(path, parent: parent, filepath: true)
15
14
  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]