diff --git a/examples/longpolling.ru b/examples/longpolling.ru new file mode 100644 index 0000000..8cd3b68 --- /dev/null +++ b/examples/longpolling.ru @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift("#{File.dirname(__FILE__)}/lib") +require_relative '../lib/landline' + +class Propogator + def initialize + @queue = [] + end + + # Append a client to the queue + def append(client) + @queue.append(client) + end + + # Push data to all clients, releasing all of them at once + def push(data) + @queue.each do |client| + client << data + client.flush + client.close + end + @queue = [] + end +end + +# @!parse include ::Landline::DSL::PathConstructors +# @!parse include ::Landline::DSL::PathMethods + +longpoll_queue = Propogator.new + +app = Landline::Server.new do + post "/push_event" do + longpoll_queue.push(request.body) + next request.body + end + + get "/await_event" do + next "No long polling for you :(" unless request.hijack? + + partial_hijack do |stream| + longpoll_queue.append(stream) + end + + next '' + end + + get "/ping" do + "pong" + end +end + +run app diff --git a/examples/partial_hijacking.ru b/examples/partial_hijacking.ru new file mode 100644 index 0000000..d7697d6 --- /dev/null +++ b/examples/partial_hijacking.ru @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift("#{File.dirname(__FILE__)}/lib") +require_relative '../lib/landline' + +# @!parse include ::Landline::DSL::PathConstructors +# @!parse include ::Landline::DSL::PathMethods + +app = Landline::Server.new do + postprocess do |request, response| + puts response.class + end + + get "/hijack" do + # @!parse include ::Landline::DSL::ProbeMethods + if request.hijack? + partial_hijack do |stream| + sec = (rand * 20).floor + sleep(sec) + stream << <<~DOC + + +
+Imagine getting delayed for like, what, #{sec} seconds? LMAO
+ + + DOC + stream.flush + stream.close + end + next '' + else + header "content-type", "text/plain" + next 'No partial hijacking for you :(' + end + end +end + +run app diff --git a/examples/rack_app.ru b/examples/rack_app.ru new file mode 100644 index 0000000..4d502cf --- /dev/null +++ b/examples/rack_app.ru @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'landline' + +# Example rack middleware +class TimerMiddleware + def initialize(app) + @app = app + end + + def call(*data) + puts("Request accepted") + before = Time.now + output = @app.call(*data) + puts("Time elapsed: #{(Time.now - before) * 1000}ms") + output + end +end + +# Example Landline application as rack middleware +class HelloServer < Landline::App + setup do + get "/test2" do + "Hello world from #{self}!" + end + handle do |status, backtrace: nil| + page = ([Landline::Util::HTTP_STATUS[status]] + + (backtrace || [""])).join("\n") + [ + { + "content-length": page.bytesize, + "content-type": "text/plain", + "x-cascade": true + }, + page + ] + end + end +end + +# Example Landline app as rack application +class CrossCallServer < Landline::App + setup do + get "/inner_test" do + "Hello world, through crosscall!" + end + end +end + +# Example Landline app as rack application +class Server < Landline::App + use TimerMiddleware + use HelloServer + + setup do + crosscall_server = CrossCallServer.new + + get "/test" do + "Hello from #{self}!" + end + + # Cross-callable application included as a subpath + link "/outer", crosscall_server + + # Cross calling an application in a probe context + get "/crosscall" do + request.path = "/inner_test" + call(crosscall_server) + end + end +end + +run Server.new diff --git a/examples/session.ru b/examples/session.ru new file mode 100644 index 0000000..08ccb14 --- /dev/null +++ b/examples/session.ru @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'landline/extensions/session' +require 'landline' + +Landline::Session.hmac_secret = "Your secure signing secret here" + +app = Landline::Server.new do + get "/make_cookie" do + session["random_number"] = Random.random_number(100) + text = <<~HTML + + + +Go check it at this link!
+ + + HTML + sleep(20) + text.gsub("#RAND#", request.to_s) + end + + get "/check_cookie" do + if session["random_number"] + <<~HTML + + + +#{session['random_number']}! Enjoy your random magic number!
+ + + HTML + else + <<~HTML + + + +Go get your magic number at this link!
+ + + HTML + end + end +end + +run app diff --git a/landline.gemspec b/landline.gemspec index b44afe2..666d510 100644 --- a/landline.gemspec +++ b/landline.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |spec| It is usable for many menial tasks, and as long as it continues to be fun, it will keep growing. DESC spec.authors = ["Yessiest"] - spec.license = "AGPL-3.0" + spec.license = "AGPL-3.0-or-later" spec.email = "yessiest@text.512mb.org" spec.homepage = "https://adastra7.net/git/Yessiest/landline" spec.files = Dir["lib/**/*"] diff --git a/lib/landline.rb b/lib/landline.rb index 4254449..a2c51c5 100644 --- a/lib/landline.rb +++ b/lib/landline.rb @@ -7,6 +7,7 @@ require_relative 'landline/probe' require_relative 'landline/request' require_relative 'landline/response' require_relative 'landline/template' +require_relative 'landline/app' # Landline is a hideously simple ruby web framework module Landline diff --git a/lib/landline/app.rb b/lib/landline/app.rb new file mode 100644 index 0000000..341e479 --- /dev/null +++ b/lib/landline/app.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Landline + # Rack application interface + class App + # TODO: fix this mess somehow (probably impossible) + + # @!parse include Landline::DSL::PathMethods + # @!parse include Landline::DSL::PathConstructors + # @!parse include Landline::DSL::ProbeConstructors + # @!parse include Landline::DSL::ProbeMethods + # @!parse include Landline::DSL::CommonMethods + class << self + # Duplicate used middleware for the subclassed app + def inherited(subclass) + super(subclass) + subclass.middleware = @middleware.dup + end + + # Include a middleware in application + # @param middleware [Class] + def use(middleware) + @middleware ||= [] + @middleware.append(middleware) + end + + # Setup block + # @param block [#call] + def setup(&block) + @setup_block = block + end + + attr_accessor :middleware, :setup_block + end + + def initialize(*args, **opts) + @app = ::Landline::Server.new(*args, **opts, &self.class.setup_block) + self.class.middleware&.reverse_each do |cls| + @app = cls.new(@app) + end + end + + # Rack ingress point. + # @param env [Hash] + # @return [Array(Integer,Hash,Array)] + def call(env) + @app.call(env) + end + end +end diff --git a/lib/landline/dsl/constructors_path.rb b/lib/landline/dsl/constructors_path.rb index 8c8d8da..ac3ae4c 100644 --- a/lib/landline/dsl/constructors_path.rb +++ b/lib/landline/dsl/constructors_path.rb @@ -5,6 +5,7 @@ module Landline module DSL # Path (and subclasses) DSL constructors module PathConstructors + # (in Landline::Path context) # Append a Node child object to the list of children def register(obj) unless obj.is_a? Landline::Node @@ -14,11 +15,13 @@ module Landline @origin.children.append(obj) end + # (in Landline::Path context) # Create a new {Landline::Path} object def path(path, **args, &setup) register(Landline::Path.new(path, parent: @origin, **args, &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::Probe} object def probe(path, **args, &_setup) register(Landline::Handlers::Probe.new(path, @@ -26,6 +29,7 @@ module Landline **args)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::GETHandler} object def get(path, **args, &setup) register(Landline::Handlers::GET.new(path, @@ -34,6 +38,7 @@ module Landline &setup)) end + # (in Landline::Path context) # create a new {Landline::Handlers::POSTHandler} object def post(path, **args, &setup) register(Landline::Handlers::POST.new(path, @@ -42,6 +47,7 @@ module Landline &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::PUTHandler} object def put(path, **args, &setup) register(Landline::Handlers::PUT.new(path, @@ -50,6 +56,7 @@ module Landline &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::HEADHandler} object def head(path, **args, &setup) register(Landline::Handlers::HEAD.new(path, @@ -58,6 +65,7 @@ module Landline &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::DELETEHandler} object def delete(path, **args, &setup) register(Landline::Handlers::DELETE.new(path, @@ -66,6 +74,7 @@ module Landline &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::CONNECTHandler} object def connect(path, **args, &setup) register(Landline::Handlers::CONNECT.new(path, @@ -74,6 +83,7 @@ module Landline &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::TRACEHandler} object def trace(path, **args, &setup) register(Landline::Handlers::TRACE.new(path, @@ -82,6 +92,7 @@ module Landline &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::PATCHHandler} object def patch(path, **args, &setup) register(Landline::Handlers::PATCH.new(path, @@ -90,6 +101,7 @@ module Landline &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::OPTIONSHandler} object def options(path, **args, &setup) register(Landline::Handlers::OPTIONS.new(path, @@ -98,10 +110,19 @@ module Landline &setup)) end + # (in Landline::Path context) # Create a new {Landline::Handlers::GETHandler} that serves static files def serve(path) register(Landline::Handlers::Serve.new(path, parent: @origin)) end + + # (in Landline::Path context) + # Create a new application crosscall link (acts like #call in probe context and strips its path from request) + def link(path, application) + register(Landline::Handlers::Link.new(path, + application, + parent: @origin)) + end end end end diff --git a/lib/landline/dsl/constructors_probe.rb b/lib/landline/dsl/constructors_probe.rb index 023aacb..e2fc790 100644 --- a/lib/landline/dsl/constructors_probe.rb +++ b/lib/landline/dsl/constructors_probe.rb @@ -4,6 +4,7 @@ module Landline module DSL # Probe (and subclasses) DSL construct module ProbeConstructors + # (in Landline::Probe context) # Create a new erb template # @see Landline::Template#new def erb(input, vars = {}) @@ -13,6 +14,7 @@ module Landline filename: caller_locations[0].path) end + # (in Landline::Probe context) # Create a new erb template using Erubi engine # @see Landline::Template#new # @param freeze [Boolean] whether to use frozen string literal diff --git a/lib/landline/dsl/methods_common.rb b/lib/landline/dsl/methods_common.rb index 63eca07..192a94c 100644 --- a/lib/landline/dsl/methods_common.rb +++ b/lib/landline/dsl/methods_common.rb @@ -4,6 +4,7 @@ module Landline module DSL # Methods shared by probes, preprocessors and filters. module CommonMethods + # (in Landline::Probe context) # Stop execution and generate a boilerplate response with the given code # @param errorcode [Integer] # @param backtrace [Array(String), nil] @@ -18,6 +19,7 @@ module Landline ) end + # (in Landline::Probe context) # Bounce request to the next handler # @raise [UncaughtThrowError] throws :break to get out of the callback def bounce diff --git a/lib/landline/dsl/methods_path.rb b/lib/landline/dsl/methods_path.rb index 5b0d0ac..5211c8c 100644 --- a/lib/landline/dsl/methods_path.rb +++ b/lib/landline/dsl/methods_path.rb @@ -5,11 +5,19 @@ module Landline module DSL # Common path methods module PathMethods + # (in Landline::Path context) # Bounce request if no handler found instead of issuing 404 def bounce @origin.bounce = true end + # (in Landline::Path context) + # Unset bounce + def nobounce + @origin.bounce = false + end + + # (in Landline::Path context) # Create a status code handler on path. # Recursively applies to all paths unless overridden. # @param code [Integer, nil] Specify a status code to handle @@ -18,6 +26,7 @@ module Landline @origin.properties["handle.#{code || 'default'}"] = block end + # (in Landline::Path context) # Insert a pass-through pipeline into request processing # (i.e. for error handling purposes). # Passed block should yield request (and return yielded data back). @@ -26,6 +35,7 @@ module Landline @origin.pipeline = block end + # (in Landline::Path context) # Set path index # @param index [Array,String] def index(index) @@ -39,18 +49,21 @@ module Landline end end + # (in Landline::Path context) # Set root path (appends matched part of the path). # @param path [String] def root(path) @origin.root = File.expand_path(path) end + # (in Landline::Path context) # Set root path (without appending matched part). # @param path [String] def remap(path) @origin.remap = File.expand_path(path) end + # (in Landline::Path context) # Add a preprocessor to the path. # Does not modify path execution. # @param block [#call] @@ -60,6 +73,7 @@ module Landline block end + # (in Landline::Path context) # Add a postprocessor to the path. # @param block [#call] # @yieldparam request [Landline::Request] @@ -72,6 +86,7 @@ module Landline alias before preprocess alias after postprocess + # (in Landline::Path context) # Add a filter to the path. # Blocks path access if a filter returns false. # @param block [#call] @@ -81,7 +96,9 @@ module Landline block end + # (in Landline::Path context) # Include an application as a child of path. + # @deprecated this method is being deprecated due to strong dependency on the framework # @param filename [String] def plugin(filename) define_singleton_method(:run) do |object| diff --git a/lib/landline/dsl/methods_probe.rb b/lib/landline/dsl/methods_probe.rb index 4c332a3..a13050a 100644 --- a/lib/landline/dsl/methods_probe.rb +++ b/lib/landline/dsl/methods_probe.rb @@ -10,12 +10,14 @@ module Landline module DSL # Common methods for Probe objects module ProbeMethods + # (in Landline::Probe context) # Get the current request # @return [Landline::Request] def request @origin.request end + # (in Landline::Probe context) # Set response status (generate response if one doesn't exist yet) # @param status [Integer] http status code def status(status) @@ -23,8 +25,59 @@ module Landline @origin.response.status = status end + # (in Landline::Probe context) + # Add a finalizer callable to the response + # @param callable [#call] + def defer(&callable) + rack = @origin.request.rack + if rack.respond_to?(:response_finished) + rack.response_finished.append(callable) + end + # puma for some reason isn't compatible with the 3.0.0 spec on this + rack.after_reply.append(callable) if rack.respond_to?(:after_reply) + end + + # (in Landline::Probe context) + # Do serverside request redirection + # @note this essentially reprocesses the whole request - be mindful of processing time! + # @param path [String] + def jump(path) + @origin.request.path = path + throw(:break, [307, { "x-internal-jump": true }, []]) + end + + # (in Landline::Probe context) + # Do clientside request redirection via 302 code + # @param path [String] + def redirect(path) + throw(:break, [302, { "location": path }, []]) + end + + # (in Landline::Probe context) + # Do clientside request redirection via 307 code + # @param path [String] + def redirect_with_method(path) + throw(:break, [307, { "location": path }, []]) + end + alias code status + # (in Landline::Probe context) + # Set a partial hijack callback + # @param block [#call] Callable block + def partial_hijack(&block) + @origin.response ||= Landline::Response.new + @origin.response.add_header("rack.hijack", block) + end + + # (in Landline::Probe context) + # Fully hijack IO + # @return [IO] + def hijack + @origin.request.hijack.call + end + + # (in Landline::Probe context) # Set response header (generate response if one doesn't exist yet) # @param key [String] header name # @param value [String] header value @@ -40,10 +93,11 @@ module Landline end @origin.response = (@origin.response or Landline::Response.new) - key = key.downcase + key = key.downcase.to_s @origin.response.add_header(key, value) end + # (in Landline::Probe context) # Delete a header value from the headers hash # If no value is provided, deletes all key entries # @param key [String] header name @@ -61,9 +115,10 @@ module Landline raise ArgumentError, "value key has invalid characters" end - @origin.response.delete_header(key, value) + @origin.response.delete_header(key.to_s, value) end + # (in Landline::Probe context) # Set response cookie # @see Landline::Cookie.new def cookie(*params, **options) @@ -73,6 +128,7 @@ module Landline ) end + # (in Landline::Probe context) # Delete a cookie # If no value is provided, deletes all cookies with the same key # @param key [String] cookie key @@ -83,6 +139,7 @@ module Landline @origin.response.delete_cookie(key, value) end + # (in Landline::Probe context) # Checks if current request has multipart/form-data associated with it # @return [Boolean] def form? @@ -90,6 +147,7 @@ module Landline !!(value && opts && opts['boundary']) end + # (in Landline::Probe context) # Returns formdata # @note reads request.input - may nullify request.body. # @return [Hash{String=>(String,Landline::Util::FormPart)}] @@ -102,12 +160,14 @@ module Landline ).to_h end + # (in Landline::Probe context) # Checks if current request has urlencoded query string # @return [Boolean] def query? !!_verify_content_type("application/x-www-form-urlencode") end + # (in Landline::Probe context) # Returns parsed query hash # @note reads request.body - may nullify .input, .body data is memoized # @return [Hash{String => Object}] query data @@ -115,6 +175,7 @@ module Landline Landline::Util::Query.new(request.body).parse end + # (in Landline::Probe context) # Returns shallow parsed query hash # @note reads request.body - may nullify .input, .body data is memoized # @return [Hash{String => Object}] query data @@ -122,12 +183,14 @@ module Landline Landline::Util::Query.new(request.body).parse_shallow end + # (in Landline::Probe context) # Check if body is a JSON object # @return [Boolean] def json? !!_verify_content_type('application/json') end + # (in Landline::Probe context) # Return parse JSON object # @note reads request.input - may nullify request.body. # @return [Object] @@ -135,24 +198,35 @@ module Landline JSON.parse(request.input) end + # (in Landline::Probe context) # Open a file relative to current filepath # @see File.open def file(path, mode = "r", *all, &block) File.open("#{request.filepath}/#{path}", mode, *all, &block) end + # (in Landline::Probe context) # Escape HTML entities # @see Landline::Util.escape_html def escape_html(text) Landline::Util.escape_html(text) end + # (in Landline::Probe context) # Unescape HTML entities # @see Landline::Util.escape_html def unescape_html(text) Landline::Util.unescape_html(text) end + # (in Landline::Path context) + # Pass the requested environment to a different application + # @param application [#call] Rack application + # @return [Array(Integer, Hash{String => Object}, Object)] response + def call(application) + application.call(@origin.request.env) + end + private def _verify_content_type(type) diff --git a/lib/landline/dsl/methods_template.rb b/lib/landline/dsl/methods_template.rb index b9f8e3a..bfb501f 100644 --- a/lib/landline/dsl/methods_template.rb +++ b/lib/landline/dsl/methods_template.rb @@ -7,6 +7,7 @@ module Landline module DSL # Common methods for template contexts module TemplateMethods + # (in Landline::Template context) # Import a template part # @param filepath [String, File] path to the file (or the file itself) # @return [String] compiled template diff --git a/lib/landline/extensions/session.rb b/lib/landline/extensions/session.rb new file mode 100644 index 0000000..0bb95ee --- /dev/null +++ b/lib/landline/extensions/session.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Landline + # Module for controlling session signing secrets + module Session + # Set hmac secret + # @param secret [String] + def self.hmac_secret=(secret) + @hmac_secret = secret + end + + # Get hmac secret + def self.hmac_secret + unless @hmac_secret or ENV['HMAC_SECRET'] + warn <<~MSG + warn: hmac secret not supplied, using randomized one + warn: provide hmac secret with $HMAC_SECRET or Landline::Session.hmac_secret + MSG + end + @hmac_secret ||= ENV.fetch('HMAC_SECRET', SecureRandom.base64(80)) + end + + # Class for representing session storage + class Session + def initialize(cookie, cookies_callback) + @data = if cookie + Landline::Util::JWT.from_string( + cookie, + Landline::Session.hmac_secret + ) + else + Landline::Util::JWT.new({}) + end + @valid = !@data.nil? + @cookies_callback = cookies_callback + end + + # Retrieve data from session storage + # @param key [String, Symbol] serializable key + def [](key) + raise StandardError, "session not valid" unless @valid + + unless key.is_a? String or key.is_a? Symbol + raise StandardError, "key not serializable" + end + + @data.data[key] + end + + # Set data to session storage + # @param key [String, Symbol] serializable key + # @param value [Object] serializable data + def []=(key, value) + raise StandardError, "session not valid" unless @valid + + unless key.is_a? String or key.is_a? Symbol + raise StandardError, "key not serializable" + end + + @data.data[key] = value + @cookies_callback.call(@data.make(Landline::Session.hmac_secret)) + end + + attr_reader :valid + end + end +end + +module Landline + module DSL + module ProbeMethods + # TODO: If probe execution contexts are somehow shared between threads + # this could result in a session leakage through race condition. + + # (in Landline::Probe context) + # Return session storage hash + # @return [Landline::Session::Session] + def session + return @session if @session + + @session = Landline::Session::Session.new( + request.cookies.dig('session', 0)&.value, + proc do |value| + delete_cookie("session", value) + cookie("session", value) + end + ) + request.postprocessors.append(proc do + @session = nil + end) + @session + end + end + end +end diff --git a/lib/landline/path.rb b/lib/landline/path.rb index bc2a9af..faeceac 100644 --- a/lib/landline/path.rb +++ b/lib/landline/path.rb @@ -50,6 +50,7 @@ module Landline # Contexts setup context = self.class::Context.new(self) context.instance_exec(&setup) + # TODO: This isn't fine @proccontext = self.class::ProcContext.new(self) end @@ -132,18 +133,25 @@ module Landline enqueue_postprocessors(request) @children.each do |x| value = x.go(request) - return value if value + return exit_stack(request, value) if value end value = index(request) - return value if value + return exit_stack(request, value) if value - @bounce ? false : _die(404) + @bounce ? exit_stack(request) : _die(404) rescue StandardError => e _die(500, backtrace: [e.to_s] + e.backtrace) ensure @request = nil end + # Run enqueued postprocessors on navigation failure + # @param request [Landline::Request] + def exit_stack(request, response = nil) + request.run_postprocessors(response) + false + end + # Try to perform indexing on the path if possible # @param request [Landline::Request] # @return [Boolean] true if indexing succeeded diff --git a/lib/landline/probe.rb b/lib/landline/probe.rb index 777f8b6..d5d5ea0 100644 --- a/lib/landline/probe.rb +++ b/lib/landline/probe.rb @@ -21,6 +21,7 @@ module Landline autoload :TRACE, "landline/probe/http_method" autoload :PATCH, "landline/probe/http_method" autoload :Serve, "landline/probe/serve_handler" + autoload :Link, "landline/probe/crosscall_handler" end # Context that provides execution context for Probes. diff --git a/lib/landline/probe/crosscall_handler.rb b/lib/landline/probe/crosscall_handler.rb new file mode 100644 index 0000000..f55818b --- /dev/null +++ b/lib/landline/probe/crosscall_handler.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative "../probe" + +module Landline + module Handlers + # Probe that sends files from a location + class Link < Landline::Probe + # @param path [Object] + # @param parent [Landline::Node] + def initialize(path, application, parent:) + @application = application + super(path, parent: parent, filepath: true) + end + + # Method callback on successful request navigation. + # Sends request over to another rack app, stripping the part of the path that was not navigated + # @param request [Landline::Request] + # @return [Array(Integer, Host{String => Object}, Object)] + def process(request) + throw :finish, @application.call(request.env) + end + end + end +end diff --git a/lib/landline/request.rb b/lib/landline/request.rb index d7967cf..e93155d 100644 --- a/lib/landline/request.rb +++ b/lib/landline/request.rb @@ -21,10 +21,10 @@ module Landline @param = {} @splat = [] # Traversal route. Public and writable. - @path = URI.decode_www_form_component(env["PATH_INFO"].dup) + @path = URI.decode_www_form_component(env["PATH_INFO"]) # File serving path. Public and writable. @filepath = "/" - # Encapsulates all rack variables. Should not be public. + # Encapsulates all rack variables. Is no longer private, but usually should not be used directly @rack = init_rack_vars(env) # Internal navigation states. Private. @states = [] @@ -35,9 +35,10 @@ module Landline # Run postprocessors # @param response [Landline::Response] def run_postprocessors(response) - @postprocessors.each do |postproc| + @postprocessors.reverse_each do |postproc| postproc.call(self, response) end + @postprocessors = [] end # Returns request body (if POST data exists) @@ -64,10 +65,33 @@ module Landline @path, @param, @splat, @filepath = @states.pop end + # Checks if response stream can be partially hijacked + def hijack? + @_original_env['rack.hijack?'] + end + + # Returns full hijack callback + def hijack + @_original_env['rack.hijack'] + end + + # Reconstructs rack env after modification + def env + path = @path + @_original_env.merge(reconstruct_headers) + .merge({ + 'PATH_INFO' => path, + 'REQUEST_PATH' => path, + 'QUERY_STRING' => query.query, + 'REQUEST_URI' => "#{path}?#{query.query}" + }) + .merge(reconstruct_cookie) + end + attr_reader :request_method, :script_name, :path_info, :server_name, :server_port, :server_protocol, :headers, :param, :splat, - :postprocessors, :query, :cookies - attr_accessor :path, :filepath + :postprocessors, :cookies, :rack + attr_accessor :path, :filepath, :query private @@ -119,7 +143,7 @@ module Landline .freeze end - # Iniitalize headers hash + # Initialize headers hash # @param env [Hash] # @return Hash def init_headers(env) @@ -133,5 +157,30 @@ module Landline x.downcase.gsub("_", "-") if x.is_a? String end.freeze end + + # Reconstruct headers + def reconstruct_headers + @headers.filter_map do |k, v| + next unless v + + if !['content-type', 'content-length', + 'remote-addr'].include?(k) && (k.is_a? String) + k = "http_#{k}" + end + k = k.upcase.gsub("-", "_") + [k, v] + end.to_h + end + + # Reconstruct cookie string + def reconstruct_cookie + return {} if @cookies.empty? + + { + "HTTP_COOKIE" => @cookies.map do |_, v| + v.finalize_short + end.join(";") + } + end end end diff --git a/lib/landline/response.rb b/lib/landline/response.rb index 212d99c..249ecf3 100644 --- a/lib/landline/response.rb +++ b/lib/landline/response.rb @@ -96,7 +96,7 @@ module Landline end end - attr_accessor :status, :headers, :body + attr_accessor :status, :headers, :body, :cookies # Ensure response correctness # @param obj [String, Array, Landline::Response] diff --git a/lib/landline/server.rb b/lib/landline/server.rb index 3309227..174e8c3 100644 --- a/lib/landline/server.rb +++ b/lib/landline/server.rb @@ -11,39 +11,64 @@ module Landline # A specialized path that can be used directly as a Rack application. class Server < Landline::Path Context = ServerContext - # @param parent [Landline::Node, nil] Parent object to inherit properties to # @param setup [#call] Setup block - def initialize(parent: nil, **args, &setup) - super("", parent: nil, **args, &setup) + def initialize(passthrough = nil, parent: nil, **opts, &setup) + super("", parent: nil, **opts, &setup) return if parent + @passthrough = passthrough + setup_properties(parent: nil, **opts) + end + + # Rack ingress point. + # @param env [Hash] + # @return [Array(Integer,Hash,Array)] + def call(env) + request = Landline::Request.new(env) + + response = handle_jumps(request) + request.run_postprocessors(response) + resp = response.finalize + if resp[1][:"x-cascade"] and resp[0] == 404 and @passthrough + @passthrough.call(request.env) + else + resp + end + end + + private + + # Catch internal jumps + def handle_jumps(request) + response = Response.convert(catch(:finish) do + go(request) + end) + while response and + response.status == 307 and + response.headers.include? :"x-internal-jump" + response = Response.convert(catch(:finish) do + go(request) + end) + end + response + end + + # Inititalization block for property setup + def setup_properties(*_args, **_opts) { "index" => [], "handle.default" => proc do |code, backtrace: nil| page = Landline::Util.default_error_page(code, backtrace) headers = { "content-length": page.bytesize, - "content-type": "text/html" + "content-type": "text/html", + "x-cascade": true } [headers, page] end, "path" => "/" }.each { |k, v| @properties[k] = v unless @properties[k] } end - - # Rack ingress point. - # This should not be called under any circumstances twice in the same application, - # although server nesting for the purpose of creating virtual hosts is allowed. - # @param env [Hash] - # @return [Array(Integer,Hash,Array)] - def call(env) - request = Landline::Request.new(env) - response = catch(:finish) do - go(request) - end - request.run_postprocessors(response) - Response.convert(response).finalize - end end end diff --git a/lib/landline/util/jwt.rb b/lib/landline/util/jwt.rb index e329b2c..ca4bfac 100644 --- a/lib/landline/util/jwt.rb +++ b/lib/landline/util/jwt.rb @@ -14,10 +14,32 @@ module Landline module Util # JSON Web Token construction class class JWT + ALGO = { + "HS256" => proc do |data, secret| + Base64.urlsafe_encode64( + OpenSSL::HMAC.digest("SHA256", secret, data) + ).gsub('=', '') + end, + "HS384" => proc do |data, secret| + Base64.urlsafe_encode64( + OpenSSL::HMAC.digest("SHA384", secret, data) + ).gsub('=', '') + end, + "HS512" => proc do |data, secret| + Base64.urlsafe_encode64( + OpenSSL::HMAC.digest("SHA512", secret, data) + ).gsub('=', '') + end + }.freeze + # Create a new JWT token wrapper # @param data [Hash, Array] JSON-formattable data # @param halgo [String] Name of the hash algorithm to use - def initialize(data, halgo = "SHA256") + def initialize(data, halgo = "HS256") + unless ALGO.include? halgo + raise StandardError, "hash algorithm #{halgo} not supported" + end + @halgo = halgo @data = data end @@ -26,14 +48,13 @@ module Landline # @param key [String] # @return [String] def make(key) + jsonheader = { + "alg": @halgo, + "typ": "JWT" + }.to_json jsondata = @data.to_json - [ - { - "hash" => @halgo - }.to_json, - jsondata, - OpenSSL::HMAC.digest(@halgo, key, jsondata) - ].map(&Base64.method(:strict_encode64)).map(&:strip).join "&" + data = "#{base64(jsonheader)}.#{base64(jsondata)}" + "#{data}.#{ALGO[@halgo].call(data, key)}" end # Construct an object from string @@ -41,14 +62,21 @@ module Landline # @param key [String] # @return [JWT, nil] returns nil if verification couldn't complete def self.from_string(input, key) - halgoj, dataj, sig = input.split("&").map(&Base64.method(:strict_decode64)) - halgo = JSON.parse(halgoj)["hash"] - return nil if OpenSSL::HMAC.digest(halgo, key, dataj) != sig + halgoj, dataj, sig = input.split(".") + halgo = JSON.parse(Base64.urlsafe_decode64(halgoj))["alg"] + return nil unless ALGO.include? halgo + return nil if ALGO[halgo].call("#{halgoj}.#{dataj}", key) != sig - new(JSON.parse(dataj), halgo) + new(JSON.parse(Base64.urlsafe_decode64(dataj)), halgo) end attr_accessor :data + + private + + def base64(data) + Base64.urlsafe_encode64(data).gsub("=", "") + end end end end diff --git a/lib/landline/util/mime.rb b/lib/landline/util/mime.rb index 3e43667..3485ff7 100644 --- a/lib/landline/util/mime.rb +++ b/lib/landline/util/mime.rb @@ -1267,6 +1267,7 @@ module Landline }.freeze # Get MIME type by file extension + # @note This function does no checks on the file - simply renaming the file to a different extension will yield an invalid result. Do not use this to check uploaded files - preferably, use libmagic or proper mime type tools for Ruby. # @param file [String] filename # @return [String] MIME type, defaults to "application/octet-stream" def self.get_mime_type(file) diff --git a/lib/landline/util/query.rb b/lib/landline/util/query.rb index 190be05..7246ed7 100644 --- a/lib/landline/util/query.rb +++ b/lib/landline/util/query.rb @@ -8,6 +8,8 @@ module Landline # Query string parser class Query include Landline::Util::ParserSorting + attr_reader :query + # @param query [String] def initialize(query) @query = query