From b79d8ae03339e2957c50111f0eae405ca1440674 Mon Sep 17 00:00:00 2001 From: jessib Date: Thu, 5 Sep 2013 13:10:23 -0700 Subject: Move handle method to Email model and have it work for local and non-local emails. --- users/app/models/email.rb | 4 ++++ users/app/models/local_email.rb | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/users/app/models/email.rb b/users/app/models/email.rb index 1bcff1c..89c31bb 100644 --- a/users/app/models/email.rb +++ b/users/app/models/email.rb @@ -19,4 +19,8 @@ class Email < String self end + def handle + self.split('@').first + end + end diff --git a/users/app/models/local_email.rb b/users/app/models/local_email.rb index c1f7c11..6303bb6 100644 --- a/users/app/models/local_email.rb +++ b/users/app/models/local_email.rb @@ -20,10 +20,6 @@ class LocalEmail < Email [handle] end - def handle - gsub(/@#{domain}/i, '') - end - def domain LocalEmail.domain end -- cgit v1.2.3 From 59adb0892f443e1fe1bdd4201c4e0db1b036e0af Mon Sep 17 00:00:00 2001 From: jessib Date: Thu, 5 Sep 2013 13:12:23 -0700 Subject: Test of failing to add non-local email address as an identity's address. --- users/test/unit/identity_test.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/users/test/unit/identity_test.rb b/users/test/unit/identity_test.rb index fa88315..a77613a 100644 --- a/users/test/unit/identity_test.rb +++ b/users/test/unit/identity_test.rb @@ -70,6 +70,13 @@ class IdentityTest < ActiveSupport::TestCase id.destroy end + test "fail to end non-local email address as identity address" do + id = Identity.for @user, address: 'blah@sdlfksjdfljk.com' + assert !id.valid? + assert_match /needs to end in/, id.errors[:address].first + end + + def alias_name @alias_name ||= Faker::Internet.user_name end -- cgit v1.2.3 From 8e8f5ddda08a883842a8c3e2ffa994e12b25dd39 Mon Sep 17 00:00:00 2001 From: jessib Date: Thu, 5 Sep 2013 13:56:02 -0700 Subject: Ensure that address in identity really is a LocalEmail. --- users/app/models/identity.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/users/app/models/identity.rb b/users/app/models/identity.rb index 355f67a..e197c9c 100644 --- a/users/app/models/identity.rb +++ b/users/app/models/identity.rb @@ -10,6 +10,7 @@ class Identity < CouchRest::Model::Base validate :unique_forward validate :alias_available + validate :address_local_email design do view :by_user_id @@ -79,4 +80,9 @@ class Identity < CouchRest::Model::Base end end + def address_local_email + return if address.valid? #this ensures it is LocalEmail + self.errors.add(:address, address.errors.messages[:email].first) #assumes only one error + end + end -- cgit v1.2.3 From 3ef22b5a856e1f576fb0a6a589b6b7ab41e1dd18 Mon Sep 17 00:00:00 2001 From: jessib Date: Thu, 5 Sep 2013 14:00:50 -0700 Subject: For moment, have identity's address handle aliased from login so we can use LoginFormatValidation. However, this is not how we will want it eventually. One issue is that the errors messages are set on login, rather than the appropriate field. --- users/app/models/identity.rb | 6 ++++++ users/app/models/login_format_validation.rb | 8 +++++--- users/test/unit/identity_test.rb | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/users/app/models/identity.rb b/users/app/models/identity.rb index e197c9c..91345a0 100644 --- a/users/app/models/identity.rb +++ b/users/app/models/identity.rb @@ -1,4 +1,5 @@ class Identity < CouchRest::Model::Base + include LoginFormatValidation use_database :identities @@ -64,6 +65,11 @@ class Identity < CouchRest::Model::Base write_attribute('keys', keys.merge(type => value)) end + # for LoginFormatValidation + def login + self.address.handle + end + protected def unique_forward diff --git a/users/app/models/login_format_validation.rb b/users/app/models/login_format_validation.rb index 1d02bd1..c1fcf70 100644 --- a/users/app/models/login_format_validation.rb +++ b/users/app/models/login_format_validation.rb @@ -1,19 +1,21 @@ module LoginFormatValidation extend ActiveSupport::Concern + #TODO: Probably will replace this. Playing with using it for aliases too, but won't want it connected to login field. + included do # Have multiple regular expression validations so we can get specific error messages: validates :login, :format => { :with => /\A.{2,}\z/, - :message => "Login must have at least two characters"} + :message => "Must have at least two characters"} validates :login, :format => { :with => /\A[a-z\d_\.-]+\z/, :message => "Only lowercase letters, digits, . - and _ allowed."} validates :login, :format => { :with => /\A[a-z].*\z/, - :message => "Login must begin with a lowercase letter"} + :message => "Must begin with a lowercase letter"} validates :login, :format => { :with => /\A.*[a-z\d]\z/, - :message => "Login must end with a letter or digit"} + :message => "Must end with a letter or digit"} end end diff --git a/users/test/unit/identity_test.rb b/users/test/unit/identity_test.rb index a77613a..b3918f1 100644 --- a/users/test/unit/identity_test.rb +++ b/users/test/unit/identity_test.rb @@ -76,6 +76,14 @@ class IdentityTest < ActiveSupport::TestCase assert_match /needs to end in/, id.errors[:address].first end + test "only lowercase alias" do + id = Identity.create_for @user, address: alias_name.capitalize + assert !id.valid? + #hacky way to do this, but okay for now: + assert id.errors.messages.flatten(2).include? "Must begin with a lowercase letter" + assert id.errors.messages.flatten(2).include? "Only lowercase letters, digits, . - and _ allowed." + end + def alias_name @alias_name ||= Faker::Internet.user_name -- cgit v1.2.3 From 80bcb7d273395af614730024e21a92a1c568228d Mon Sep 17 00:00:00 2001 From: Azul Date: Mon, 23 Sep 2013 10:20:02 +0200 Subject: security fix: clear srp data from db asap (#3686) This is a quick fix for iSEC issue #13. --- users/lib/warden/strategies/secure_remote_password.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/users/lib/warden/strategies/secure_remote_password.rb b/users/lib/warden/strategies/secure_remote_password.rb index 4688fcd..2c334c6 100644 --- a/users/lib/warden/strategies/secure_remote_password.rb +++ b/users/lib/warden/strategies/secure_remote_password.rb @@ -31,6 +31,7 @@ module Warden Rails.logger.warn "Login attempt failed." Rails.logger.debug debug_info Rails.logger.debug "Received: #{params['client_auth']}" + session.delete(:handshake) fail!(:base => "invalid_user_pass") end end -- cgit v1.2.3 From a9c68ba0bbba7a95e9b4a3ff24554d1b0af6cbc5 Mon Sep 17 00:00:00 2001 From: jessib Date: Mon, 23 Sep 2013 12:23:08 -0700 Subject: This ensures that email addresses contain only lowercase letters, and that an identity's destination is a valid Email. --- users/app/models/email.rb | 8 +++++++- users/app/models/identity.rb | 6 ++++++ users/test/unit/identity_test.rb | 17 ++++++++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/users/app/models/email.rb b/users/app/models/email.rb index 89c31bb..f38f2f5 100644 --- a/users/app/models/email.rb +++ b/users/app/models/email.rb @@ -3,10 +3,16 @@ class Email < String validates :email, :format => { - :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/, + :with => /\A([^@\s]+)@((?:[-a-zA-Z0-9]+\.)+[a-zA-Z]{2,})\Z/, #checks format, but allows lowercase :message => "needs to be a valid email address" } + validates :email, + :format => { + :with => /\A[^A-Z]*\Z/, #forbids uppercase characters + :message => "letters must be lowercase" + } + def to_partial_path "emails/email" end diff --git a/users/app/models/identity.rb b/users/app/models/identity.rb index 91345a0..e0a24e9 100644 --- a/users/app/models/identity.rb +++ b/users/app/models/identity.rb @@ -12,6 +12,7 @@ class Identity < CouchRest::Model::Base validate :unique_forward validate :alias_available validate :address_local_email + validate :destination_email design do view :by_user_id @@ -91,4 +92,9 @@ class Identity < CouchRest::Model::Base self.errors.add(:address, address.errors.messages[:email].first) #assumes only one error end + def destination_email + return if destination.valid? #this ensures it is Email + self.errors.add(:destination, destination.errors.messages[:email].first) #assumes only one error #TODO + end + end diff --git a/users/test/unit/identity_test.rb b/users/test/unit/identity_test.rb index b3918f1..02f14c0 100644 --- a/users/test/unit/identity_test.rb +++ b/users/test/unit/identity_test.rb @@ -70,13 +70,13 @@ class IdentityTest < ActiveSupport::TestCase id.destroy end - test "fail to end non-local email address as identity address" do - id = Identity.for @user, address: 'blah@sdlfksjdfljk.com' + test "fail to add non-local email address as identity address" do + id = Identity.for @user, address: forward_address assert !id.valid? assert_match /needs to end in/, id.errors[:address].first end - test "only lowercase alias" do + test "alias must meet some conditions as login" do id = Identity.create_for @user, address: alias_name.capitalize assert !id.valid? #hacky way to do this, but okay for now: @@ -84,6 +84,17 @@ class IdentityTest < ActiveSupport::TestCase assert id.errors.messages.flatten(2).include? "Only lowercase letters, digits, . - and _ allowed." end + test "destination must be valid email address" do + id = Identity.create_for @user, address: @user.email_address, destination: 'ASKJDLFJD' + assert !id.valid? + assert id.errors.messages[:destination].include? "needs to be a valid email address" + end + + test "only lowercase destination" do + id = Identity.create_for @user, address: @user.email_address, destination: forward_address.capitalize + assert !id.valid? + assert id.errors.messages[:destination].include? "letters must be lowercase" + end def alias_name @alias_name ||= Faker::Internet.user_name -- cgit v1.2.3 From 9fa07d19ab9ad8dea15b1fcb8b2c739d79a36d8f Mon Sep 17 00:00:00 2001 From: Azul Date: Tue, 24 Sep 2013 10:31:04 +0200 Subject: fix syslogger, log_tags are called on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit log_tags was causing errors that could not be logged or caught. We don't need them yet anyway. config.log_tags accepts a list of methods that respond to request object. This makes it easy to tag log lines with debug information like subdomain and request id — both very helpful in debugging multi-user production applications. http://guides.rubyonrails.org/configuring.html --- config/application.rb | 2 +- config/environments/production.rb | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/config/application.rb b/config/application.rb index e8bb2f4..8587ffc 100644 --- a/config/application.rb +++ b/config/application.rb @@ -54,7 +54,7 @@ module LeapWeb # Configure sensitive parameters which will be filtered from the log file. config.filter_parameters += [:password] - if APP_CONFIG[:logfile] + if APP_CONFIG[:logfile].present? config.logger = Logger.new(APP_CONFIG[:logfile]) end diff --git a/config/environments/production.rb b/config/environments/production.rb index 32b4558..7acca75 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -33,11 +33,11 @@ LeapWeb::Application.configure do # See everything in the log (default is :info) # config.log_level = :debug - # Prepend all log lines with the following tags - # config.log_tags = [ :subdomain, :uuid ] - - # Use a different logger for distributed setups - # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) + # Use syslog if no file has been specified + if APP_CONFIG[:logfile].blank? + require 'syslog/logger' + config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new('webapp')) + end # Use a different cache store in production # config.cache_store = :mem_cache_store -- cgit v1.2.3 From 193bf6446b384dce1699e8fb82be6f16cb8cb5f6 Mon Sep 17 00:00:00 2001 From: Azul Date: Mon, 23 Sep 2013 19:55:22 +0200 Subject: use token auth when accessing the api from webapp One failing integration test still needs to be fixed --- users/app/assets/javascripts/srp | 2 +- users/app/assets/javascripts/users.js | 20 ++++++++++++++- users/app/controllers/v1/sessions_controller.rb | 1 + users/app/views/users/_edit.html.haml | 5 ++-- users/test/integration/browser/account_test.rb | 33 +++++++++++++++++++++++++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/users/app/assets/javascripts/srp b/users/app/assets/javascripts/srp index 9c61d52..d22bf3b 160000 --- a/users/app/assets/javascripts/srp +++ b/users/app/assets/javascripts/srp @@ -1 +1 @@ -Subproject commit 9c61d52f1f975ec0eefe5b4a0b71ac529300cbe7 +Subproject commit d22bf3b9fe2fd31192e1e1b358e97e5a0f3f90b3 diff --git a/users/app/assets/javascripts/users.js b/users/app/assets/javascripts/users.js index 4c9b510..9d1a0f0 100644 --- a/users/app/assets/javascripts/users.js +++ b/users/app/assets/javascripts/users.js @@ -3,7 +3,12 @@ // LOCAL FUNCTIONS // - var poll_users, prevent_default, form_failed, form_passed, clear_errors; + var poll_users, + prevent_default, + form_failed, + form_passed, + clear_errors, + update_user; prevent_default = function(event) { return event.preventDefault(); @@ -19,6 +24,17 @@ return $('#messages').empty(); }; + update_user = function(submitEvent) { + var form = submitEvent.target; + var token = form.dataset.token; + var url = form.action; + return $.ajax({ + url: url, + type: 'PUT', + headers: { Authorization: 'Token token="' + token + '"' }, + data: $(form).serialize() + }); + }; // // PUBLIC FUNCTIONS @@ -76,6 +92,8 @@ $('#new_session').submit(srp.login); $('#update_login_and_password').submit(prevent_default); $('#update_login_and_password').submit(srp.update); + $('#update_pgp_key').submit(prevent_default); + $('#update_pgp_key').submit(update_user); return $('#user-typeahead').typeahead({ source: poll_users }); diff --git a/users/app/controllers/v1/sessions_controller.rb b/users/app/controllers/v1/sessions_controller.rb index 1b20a82..eb6c322 100644 --- a/users/app/controllers/v1/sessions_controller.rb +++ b/users/app/controllers/v1/sessions_controller.rb @@ -24,6 +24,7 @@ module V1 def update authenticate! @token = Token.create(:user_id => current_user.id) + session[:token] = @token.id render :json => login_response end diff --git a/users/app/views/users/_edit.html.haml b/users/app/views/users/_edit.html.haml index 5f74d32..ae3f32d 100644 --- a/users/app/views/users/_edit.html.haml +++ b/users/app/views/users/_edit.html.haml @@ -10,7 +10,8 @@ -# however, we don't want the user to change their login without generating a new key, so we hide the ui for this -# (although it works perfectly fine to change username if the field was visible). -# -- form_options = {:url => '/not-used', :html => {:class => user_form_class('form-horizontal'), :id => 'update_login_and_password'}, :validate => true} + +- form_options = {:url => '/not-used', :html => {:class => user_form_class('form-horizontal'), :id => 'update_login_and_password', :data => {token: session[:token]}}, :validate => true} = simple_form_for @user, form_options do |f| %legend= t(:change_password) = hidden_field_tag 'user_param', @user.to_param @@ -28,7 +29,7 @@ -# this will be replaced by a identities controller/view at some point -# -- form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_pgp_key'}, :validate => true} +- form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_pgp_key', :data => {token: session[:token]}}, :validate => true} = simple_form_for [:api, @user], form_options do |f| %legend= t(:advanced_options) = f.input :public_key, :as => :text, :hint => t(:use_ascii_key), :input_html => {:class => "full-width", :rows => 4} diff --git a/users/test/integration/browser/account_test.rb b/users/test/integration/browser/account_test.rb index 8c2c997..3434557 100644 --- a/users/test/integration/browser/account_test.rb +++ b/users/test/integration/browser/account_test.rb @@ -24,8 +24,41 @@ class AccountTest < BrowserIntegrationTest fill_in 'Password', with: password click_on 'Log In' assert page.has_content?("Welcome #{username}") + User.find_by_login(username).account.destroy end + test "change password" do + username, password = submit_signup + click_on "Account Settings" + within('#update_login_and_password') do + fill_in 'Password', with: "other password" + fill_in 'Password confirmation', with: "other password" + click_on 'Save' + end + click_on 'Logout' + click_on 'Log In' + fill_in 'Username', with: username + fill_in 'Password', with: "other password" + click_on 'Log In' + assert page.has_content?("Welcome #{username}") + User.find_by_login(username).account.destroy + end + + test "change pgp key" do + pgp_key = "My PGP Key Stub" + username, password = submit_signup + click_on "Account Settings" + within('#update_pgp_key') do + fill_in 'Public key', with: pgp_key + click_on 'Save' + end + debugger + assert user = User.find_by_login(username) + assert_equal pgp_key, user.public_key + user.account.destroy + end + + # trying to seed an invalid A for srp login test "detects attempt to circumvent SRP" do user = FactoryGirl.create :user -- cgit v1.2.3 From 4f8414298750193b6de3daff08364ec745a6a761 Mon Sep 17 00:00:00 2001 From: Azul Date: Wed, 25 Sep 2013 10:12:08 +0200 Subject: visual feedback when submitting forms (#3164) This also helps with the failing integration test. We needed a way to tell the ajax request was back. Observing the button state now works for that. --- users/app/assets/javascripts/users.js | 13 ++++++++++++- users/app/views/users/_edit.html.haml | 2 +- users/test/integration/browser/account_test.rb | 7 +++++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/users/app/assets/javascripts/users.js b/users/app/assets/javascripts/users.js index 9d1a0f0..aaeba6e 100644 --- a/users/app/assets/javascripts/users.js +++ b/users/app/assets/javascripts/users.js @@ -28,12 +28,22 @@ var form = submitEvent.target; var token = form.dataset.token; var url = form.action; - return $.ajax({ + var req = $.ajax({ url: url, type: 'PUT', headers: { Authorization: 'Token token="' + token + '"' }, data: $(form).serialize() }); + req.done( function() { + $(form).find('input[type="submit"]').button('reset'); + }); + }; + + markAsSubmitted = function(submitEvent) { + var form = submitEvent.target; + $(form).addClass('submitted') + // bootstrap loading state: + $(form).find('input[type="submit"]').button('loading'); }; // @@ -86,6 +96,7 @@ // $(document).ready(function() { + $('form').submit(markAsSubmitted); $('#new_user').submit(prevent_default); $('#new_user').submit(srp.signup); $('#new_session').submit(prevent_default); diff --git a/users/app/views/users/_edit.html.haml b/users/app/views/users/_edit.html.haml index ae3f32d..9d2473b 100644 --- a/users/app/views/users/_edit.html.haml +++ b/users/app/views/users/_edit.html.haml @@ -35,7 +35,7 @@ = f.input :public_key, :as => :text, :hint => t(:use_ascii_key), :input_html => {:class => "full-width", :rows => 4} .control-group .controls - = f.submit t(:save), :class => 'btn' + = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."} -# -# DESTROY ACCOUNT diff --git a/users/test/integration/browser/account_test.rb b/users/test/integration/browser/account_test.rb index 3434557..1deda45 100644 --- a/users/test/integration/browser/account_test.rb +++ b/users/test/integration/browser/account_test.rb @@ -52,8 +52,11 @@ class AccountTest < BrowserIntegrationTest fill_in 'Public key', with: pgp_key click_on 'Save' end - debugger - assert user = User.find_by_login(username) + page.assert_selector 'input[value="Saving..."]' + # at some point we're done: + page.assert_no_selector 'input[value="Saving..."]' + assert page.has_field? 'Public key', with: pgp_key + user = User.find_by_login(username) assert_equal pgp_key, user.public_key user.account.destroy end -- cgit v1.2.3 From af9d843d646cf500306de0ad20896c05ecaccd78 Mon Sep 17 00:00:00 2001 From: jessib Date: Thu, 26 Sep 2013 12:06:25 -0700 Subject: Since local part of email is case sensitive, want to allow remote email addresses with uppercase letters in local part. --- users/app/models/email.rb | 8 +------- users/test/unit/identity_test.rb | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/users/app/models/email.rb b/users/app/models/email.rb index f38f2f5..a9a503f 100644 --- a/users/app/models/email.rb +++ b/users/app/models/email.rb @@ -3,16 +3,10 @@ class Email < String validates :email, :format => { - :with => /\A([^@\s]+)@((?:[-a-zA-Z0-9]+\.)+[a-zA-Z]{2,})\Z/, #checks format, but allows lowercase + :with => /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/, #local part of email is case-sensitive, so allow uppercase letter. :message => "needs to be a valid email address" } - validates :email, - :format => { - :with => /\A[^A-Z]*\Z/, #forbids uppercase characters - :message => "letters must be lowercase" - } - def to_partial_path "emails/email" end diff --git a/users/test/unit/identity_test.rb b/users/test/unit/identity_test.rb index 02f14c0..0842a77 100644 --- a/users/test/unit/identity_test.rb +++ b/users/test/unit/identity_test.rb @@ -76,7 +76,7 @@ class IdentityTest < ActiveSupport::TestCase assert_match /needs to end in/, id.errors[:address].first end - test "alias must meet some conditions as login" do + test "alias must meet same conditions as login" do id = Identity.create_for @user, address: alias_name.capitalize assert !id.valid? #hacky way to do this, but okay for now: @@ -90,12 +90,6 @@ class IdentityTest < ActiveSupport::TestCase assert id.errors.messages[:destination].include? "needs to be a valid email address" end - test "only lowercase destination" do - id = Identity.create_for @user, address: @user.email_address, destination: forward_address.capitalize - assert !id.valid? - assert id.errors.messages[:destination].include? "letters must be lowercase" - end - def alias_name @alias_name ||= Faker::Internet.user_name end -- cgit v1.2.3 From eaedf19e2e54ccb9933caa8dc21df13e48609b18 Mon Sep 17 00:00:00 2001 From: jessib Date: Mon, 7 Oct 2013 09:44:16 -0700 Subject: Updates to billing/README file. --- billing/README.rdoc | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/billing/README.rdoc b/billing/README.rdoc index 357c02e..30ca0d6 100644 --- a/billing/README.rdoc +++ b/billing/README.rdoc @@ -2,18 +2,19 @@ This project rocks and uses MIT-LICENSE. +The gem leap_web_billing will need to be included in whatever environment you are running, and billing will also need to be included in the configuration for that environment. You can set billing to be included in config/defaults.yml, by making sure the payment key is set to an array including billing (by default it will be set for the test environment, so you can look at that.) + To set up your own Braintree Sandbox, create an account at: https://www.braintreepayments.com/get-started -Login. +Login to the Braintree Sandbox. In the top right, navigate to your username, and then 'My User' -> 'API Keys' Click the button to generate a new API key, and then click the 'View' link to the right of the key. -There is a section to copy a snippet of code. Select 'Ruby' in the dropdown, and then the button to the right to copy this code to your clipboard. -Then, paste the contents of the clipboard into config/initializers/braintree.rb - -You should not check the private key into version control. +There is a section to copy a snippet of code. The simplest way to get this working is to select 'Ruby' in the dropdown, and then the button to the right to copy this code to your clipboard, and then paste the contents of the clipboard into billing/config/initializers/braintree.rb +However, you should not check the private key into version control, so you should not check in this file. +The better way to do this is to leave billing/config/initializers/braintree.rb as is, and instead set the braintree variables for the appropriate environment in config/config.yml, which is excluded from version control. Now, you should be able to add charges to your own Sandbox when you run the webapp locally. -- cgit v1.2.3