diff --git a/Gemfile.lock b/Gemfile.lock index 8c18499..6d65546 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - unpoly-rails (2.4.1) + unpoly-rails (2.7.2.2) actionpack (>= 3.2) activesupport (>= 3.2) memoized @@ -68,7 +68,7 @@ GEM mail (2.7.1) mini_mime (>= 0.1.1) marcel (1.0.1) - memoized (1.0.2) + memoized (1.1.1) method_source (1.0.0) mini_mime (1.1.0) mini_portile2 (2.4.0) diff --git a/Rakefile b/Rakefile index 64c4aee..c603938 100644 --- a/Rakefile +++ b/Rakefile @@ -5,21 +5,37 @@ module Unpoly module Rails class Release class << self - def npm_version + def npm_version(extension: true) package_json_path = 'assets/unpoly-dev/package.json' package_json_content = File.read(package_json_path) package_info = JSON.parse(package_json_content) - package_info['version'].presence or raise Error, "Cannot parse { version } from #{package_json_path}" + version = package_info['version'].presence + version or raise Error, "Cannot parse { version } from #{package_json_path}" + process_extension(version, extension) end - def gem_version + def gem_version(extension: true) require_relative 'lib/unpoly/rails/version' - Unpoly::Rails::VERSION + version = Unpoly::Rails::VERSION + process_extension(version, extension) end def pre_release? version =~ /rc|beta|pre|alpha/ end + + def process_extension(version, extension) + if version + if extension + version + elsif version =~ /^\d+\.\d+\.\d+/ + Regexp.last_match(0) + else + raise "Cannot parse Semver version: #{version.inspect}" + end + end + end + end end end @@ -35,7 +51,7 @@ namespace :gem do puts puts "Before continuing, make sure the following tasks are done:" puts - puts "- The files in ../unpoly/dist are the latest build" + puts "- The files in ../unpoly/dist are the latest build for version #{Unpoly::Rails::Release.gem_version(extension: false)}" puts "- You have released a new version of the unpoly npm package" puts "- You have bumped the version in lib/unpoly/rails/version.rb to match that of Unpoly's package.json" puts "- You have committed and pushed the changes" @@ -53,11 +69,11 @@ namespace :gem do desc 'Ensure that package.js and version.rb have the same version' task :ensure_synced_versions do - gem_version = Unpoly::Rails::Release.gem_version - npm_version = Unpoly::Rails::Release.npm_version + unextended_gem_version = Unpoly::Rails::Release.gem_version(extension: false) + unextended_npm_version = Unpoly::Rails::Release.npm_version(extension: false) - unless gem_version == npm_version - raise "Gem version (#{gem_version}) does not match npm version (#{npm_version})" + unless unextended_gem_version == unextended_npm_version + raise "Gem version (#{unextended_gem_version}) does not match npm version (#{unextended_npm_version})" end end Rake::Task['gem:build'].enhance ['gem:ensure_synced_versions'] diff --git a/lib/unpoly-rails.rb b/lib/unpoly-rails.rb index ecf0b40..c423595 100644 --- a/lib/unpoly-rails.rb +++ b/lib/unpoly-rails.rb @@ -1,6 +1,7 @@ require 'memoized' require_relative 'unpoly/rails/version' require_relative 'unpoly/rails/error' +require_relative 'unpoly/rails/util' require_relative 'unpoly/rails/engine' require_relative 'unpoly/rails/request_echo_headers' require_relative 'unpoly/rails/request_method_cookie' diff --git a/lib/unpoly/rails/change.rb b/lib/unpoly/rails/change.rb index c196484..e645ea5 100644 --- a/lib/unpoly/rails/change.rb +++ b/lib/unpoly/rails/change.rb @@ -265,14 +265,18 @@ def request_url_without_up_params # Parse the URL to extract the ?query part below. uri = URI.parse(original_url) - # This parses the query as a flat list of key/value pairs. - params = Rack::Utils.parse_query(uri.query) + # Split at & + query_parts = uri.query.split('&') # We only used the up[...] params to transport headers, but we don't # want them to appear in a history URL. - non_up_params = params.reject { |key, _value| key.starts_with?(Field::PARAM_PREFIX) } + non_up_query_parts = query_parts.reject { |query_part| query_part.start_with?(Field::PARAM_PREFIX) } - append_params_to_url(uri.path, non_up_params) + if non_up_query_parts.empty? + uri.path + else + "#{uri.path}?#{non_up_query_parts.join('&')}" + end end memoize def layer diff --git a/lib/unpoly/rails/change/field.rb b/lib/unpoly/rails/change/field.rb index b1d10b3..14dd177 100644 --- a/lib/unpoly/rails/change/field.rb +++ b/lib/unpoly/rails/change/field.rb @@ -50,7 +50,7 @@ def parse(raw) end def stringify(value) - value.to_json + Util.safe_json_encode(value) end end @@ -75,7 +75,7 @@ class Hash < Field def parse(raw) if raw.present? - result = ActiveSupport::JSON.decode(raw) + result = Util.json_decode(raw) else result = {} end @@ -88,7 +88,7 @@ def parse(raw) end def stringify(value) - ActiveSupport::JSON.encode(value) + Util.safe_json_encode(value) end end @@ -97,7 +97,7 @@ class Array < Field def parse(raw) if raw.present? - result = ActiveSupport::JSON.decode(raw) + result = Util.json_decode(raw) else result = [] end @@ -106,7 +106,7 @@ def parse(raw) end def stringify(value) - ActiveSupport::JSON.encode(value) + Util.safe_json_encode(value) end end diff --git a/lib/unpoly/rails/change/layer.rb b/lib/unpoly/rails/change/layer.rb index 16a16f7..e2942ee 100644 --- a/lib/unpoly/rails/change/layer.rb +++ b/lib/unpoly/rails/change/layer.rb @@ -40,14 +40,14 @@ def emit(type, options = {}) # TODO: Docs def accept(value = nil) overlay? or raise CannotClose, 'Cannot accept the root layer' - change.response.headers['X-Up-Accept-Layer'] = value.to_json + change.response.headers['X-Up-Accept-Layer'] = Util.safe_json_encode(value) end ## # TODO: Docs def dismiss(value = nil) overlay? or raise CannotClose, 'Cannot dismiss the root layer' - change.response.headers['X-Up-Dismiss-Layer'] = value.to_json + change.response.headers['X-Up-Dismiss-Layer'] = Util.safe_json_encode(value) end private @@ -57,4 +57,4 @@ def dismiss(value = nil) end end end -end \ No newline at end of file +end diff --git a/lib/unpoly/rails/request_echo_headers.rb b/lib/unpoly/rails/request_echo_headers.rb index 44958c2..b682469 100644 --- a/lib/unpoly/rails/request_echo_headers.rb +++ b/lib/unpoly/rails/request_echo_headers.rb @@ -18,9 +18,13 @@ def self.included(base) end private - + def set_up_request_echo_headers - response.headers['X-Up-Location'] = up.request_url_without_up_params + request_url_without_up_params = up.request_url_without_up_params + unless request_url_without_up_params == request.original_url + response.headers['X-Up-Location'] = up.request_url_without_up_params + end + response.headers['X-Up-Method'] = request.method end diff --git a/lib/unpoly/rails/util.rb b/lib/unpoly/rails/util.rb new file mode 100644 index 0000000..2e3d22a --- /dev/null +++ b/lib/unpoly/rails/util.rb @@ -0,0 +1,25 @@ +module Unpoly + module Rails + class Util + class << self + + def json_decode(string) + ActiveSupport::JSON.decode(string) + end + + # We build a lot of JSON that goes into HTTP header. + # High-ascii characters are not safe to transport over HTTP, but we + # can use JSON escape sequences (\u0012) to make them low-ascii. + def safe_json_encode(value) + json = ActiveSupport::JSON.encode(value) + escape_non_ascii(json) + end + + def escape_non_ascii(unicode_string) + unicode_string.gsub(/[[:^ascii:]]/) { |char| "\\u" + char.ord.to_s(16).rjust(4, "0") } + end + + end + end + end +end diff --git a/lib/unpoly/rails/version.rb b/lib/unpoly/rails/version.rb index 743db06..6f72d65 100644 --- a/lib/unpoly/rails/version.rb +++ b/lib/unpoly/rails/version.rb @@ -4,6 +4,6 @@ module Rails # The current version of the unpoly-rails gem. # This version number is also used for releases of the Unpoly # frontend code. - VERSION = '2.4.1' + VERSION = '2.7.2.2' end end diff --git a/spec/unpoly/rails/controller_spec.rb b/spec/unpoly/rails/controller_spec.rb index eb72c75..9e9591e 100644 --- a/spec/unpoly/rails/controller_spec.rb +++ b/spec/unpoly/rails/controller_spec.rb @@ -455,6 +455,14 @@ def controller_eval(headers: {}, &expression) expect(response.headers['X-Up-Context']).to match_json(bar: 'barValue') end + it 'escapes high-ASCII characters in the header value, so we can transport it over HTTP' do + controller_eval(headers: { 'X-Up-Context': { 'foo': 'fooValue' }.to_json }) do + up.context[:bar] = 'xäy' + end + + expect(response.headers['X-Up-Context']).to match_json('{"bar": "x\\u00e4y"}') + end + it 'changes the value for subsequent calls of up.context[]' do value = controller_eval do up.context[:bar] = 'barValue' @@ -479,7 +487,7 @@ def controller_eval(headers: {}, &expression) expect(response.headers['X-Up-Context']).to be_nil end - + it 'sends mutated sub-arrays as an X-Up-Context response header' do controller_eval(headers: { 'X-Up-Context': { foo: [1, 2, 3] }.to_json }) do up.context[:foo] << 4 @@ -644,6 +652,18 @@ def controller_eval(headers: {}, &expression) ]) end + it 'escapes high-ASCII characters in the header value, so we can transport it over HTTP' do + controller_eval(headers: { 'X-Up-Mode': 'modal' }) do + up.layer.accept('xäy') + end + + controller_eval do + up.emit('my:event', { 'foo' => 'xäy' }) + end + + expect(response.headers['X-Up-Events']).to eq('[{"foo":"x\\u00e4y","type":"my:event"}]') + end + end describe 'up.layer.emit' do @@ -767,6 +787,14 @@ def controller_eval(headers: {}, &expression) expect(accept_root).to raise_error(/cannot accept/i) end + it 'escapes high-ASCII characters in the header value, so we can transport it over HTTP' do + controller_eval(headers: { 'X-Up-Mode': 'modal' }) do + up.layer.accept('xäy') + end + + expect(response.headers['X-Up-Accept-Layer']).to eq('"x\\u00e4y"') + end + end describe 'up.layer.dismiss' do @@ -797,6 +825,14 @@ def controller_eval(headers: {}, &expression) expect(dismiss_root).to raise_error(/cannot dismiss/i) end + it 'escapes high-ASCII characters in the header value, so we can transport it over HTTP' do + controller_eval(headers: { 'X-Up-Mode': 'modal' }) do + up.layer.dismiss('xäy') + end + + expect(response.headers['X-Up-Dismiss-Layer']).to eq('"x\\u00e4y"') + end + end describe 'up.fail_layer.mode' do @@ -959,21 +995,28 @@ def controller_eval(headers: {}, &expression) describe 'echoing of the request location' do - it 'echoes the current path in an X-Up-Location response header' do + it 'does not echo the current path in an X-Up-Location response header to prevent the user-controlled request URL from exceeding the maximum response header size' do get '/binding_test/text' - expect(response.headers['X-Up-Location']).to end_with('/binding_test/text') + expect(response.headers['X-Up-Location']).to be_nil end - it 'echoes the current path after a redirect' do - get '/binding_test/redirect1' - expect(response).to be_redirect - follow_redirect! - expect(response.headers['X-Up-Location']).to end_with('/binding_test/redirect2') - end + describe 'when the request URL contains query params prefixed with "_up-"' do + + it 'removes params prefixed with "_up-"' do + get '/binding_test/text?_up_1&_up_2=y' + expect(response.headers['X-Up-Location']).to end_with('/binding_test/text') + end + + it 'keeps params not prefixed with "_up-"' do + get '/binding_test/text?_up_1=x&foo=bar&_up_2=y' + expect(response.headers['X-Up-Location']).to end_with('/binding_test/text?foo=bar') + end + + it 'does not mangle array params (BUGFIX)' do + get '/binding_test/text?_up_1=x&foo%5B%5D=bar&foo%5B%5D=qux&_up_location=up_location' + expect(response.headers['X-Up-Location']).to end_with('/binding_test/text?foo%5B%5D=bar&foo%5B%5D=qux') + end - it 'echoes the current path with query params' do - get '/binding_test/text?foo=bar' - expect(response.headers['X-Up-Location']).to end_with('/binding_test/text?foo=bar') end end