diff options
141 files changed, 3795 insertions, 848 deletions
@@ -1,9 +1,13 @@ +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + # Ignore bundler config /.bundle -bin # Ignore the default SQLite database. /db/*.sqlite3 +/db/*.sqlite3-journal # Ignore all logfiles and tempfiles. /log/*.log @@ -12,7 +16,6 @@ bin /pkg /*/pkg /log -.*.swp */Gemfile.lock test/dummy/log/* test/dummy/tmp/* @@ -4,12 +4,14 @@ require File.expand_path('../lib/gemfile_tools.rb', __FILE__) ## CORE # rake 11.x throws lots of warnings about rails 3.2 code -gem "rake", "~> 10.4" -gem "rails", "~> 3.2.21" -gem "couchrest", "~> 1.1.3" -gem "couchrest_model", "~> 2.0.0" +gem "rake" +gem "rails", "~> 4.2.6" +# TODO: drop this and the respond_with usage +gem 'responders', '~> 2.0' +gem "couchrest", "~> 2.0.0.rc3" +gem "couchrest_model", "~> 2.1.0.beta2" if ARGV.grep(/assets:precompile/).empty? - gem "couchrest_session_store", "= 0.3.1" + gem "couchrest_session_store", "~> 0.4.2" end ## AUTHENTICATION @@ -30,8 +32,7 @@ gem 'rails-i18n' # locale files for built-in validation messages and times gem 'common_languages', :path => 'vendor/gems/common_languages' ## VIEWS -gem 'kaminari', "0.13.0" # for pagination. trying 0.13.0 as there seem to be - # issues with 0.14.0 when using couchrest +gem 'kaminari' gem 'rdiscount' # for rendering .md templates ## ASSETS @@ -39,19 +40,12 @@ gem "jquery-rails" gem "simple_form" gem 'client_side_validations' gem 'client_side_validations-simple_form' -gem "haml-rails", "= 0.4.0" # The last version of haml-rails to support Rails 3. -gem "bootstrap-sass", "= 2.3.2.2" # The last 2.x version. Bootstrap-sass versions - # tracks the version of Bootstrap. We currently require - # Bootstrap v2 because client side validations is incompatible - # with Bootstrap v3. When upgrading to Rails 4, see - # https://github.com/twbs/bootstrap-sass -gem "sass-rails", "~> 3.2.5" # Only version supported by bootstrap-sass 2.3.2.2 -gem 'quiet_assets' # stops logging all the asset requests +gem "haml-rails" +gem "bootstrap-sass" +gem "sass-rails" group :production do - gem "uglifier", "~> 1.2.7" # javascript compression https://github.com/lautis/uglifier - # this must not be included in development mode, or js - # will get included twice. - gem 'therubyracer', "~> 0.12.2", :platforms => :ruby + gem "uglifier" + gem 'therubyracer', :platforms => :ruby # ^^ See https://github.com/sstephenson/execjs#readme # for list of supported runtimes. end @@ -68,7 +62,7 @@ group :test do gem 'phantomjs-binaries' # binaries specific to the os # moching and stubbing - gem 'mocha', '~> 0.13.0', :require => false + gem 'mocha', :require => false gem 'minitest-stub-const' # why? # generating test data @@ -83,7 +77,6 @@ group :test do end group :test, :development do - gem 'thin' gem 'i18n-missing_translations' gem 'pry' end @@ -93,17 +86,13 @@ group :production do end group :development do - gem "better_errors", '1.1.0' + gem "better_errors" gem "binding_of_caller" end group :test, :debug do # bundler on jessie doesn't support `:platforms => :ruby_21` - if RUBY_VERSION < "2.0" - gem 'debugger' - else - gem 'byebug' - end + gem 'byebug' end ## diff --git a/Gemfile.lock b/Gemfile.lock index af414c1..ff69a08 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,13 @@ PATH remote: engines/billing specs: - leap_web_billing (0.6.0) + leap_web_billing (0.8.0) braintree PATH remote: engines/support specs: - leap_web_help (0.6.0) + leap_web_help (0.8.0) PATH remote: vendor/gems/certificate_authority @@ -26,236 +26,269 @@ GEM remote: https://rubygems.org/ specs: SyslogLogger (2.0) - actionmailer (3.2.22.2) - actionpack (= 3.2.22.2) - mail (~> 2.5.4) - actionpack (3.2.22.2) - activemodel (= 3.2.22.2) - activesupport (= 3.2.22.2) - builder (~> 3.0.0) + actionmailer (4.2.6) + actionpack (= 4.2.6) + actionview (= 4.2.6) + activejob (= 4.2.6) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 1.0, >= 1.0.5) + actionpack (4.2.6) + actionview (= 4.2.6) + activesupport (= 4.2.6) + rack (~> 1.6) + rack-test (~> 0.6.2) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionview (4.2.6) + activesupport (= 4.2.6) + builder (~> 3.1) erubis (~> 2.7.0) - journey (~> 1.0.4) - rack (~> 1.4.5) - rack-cache (~> 1.2) - rack-test (~> 0.6.1) - sprockets (~> 2.2.1) - activemodel (3.2.22.2) - activesupport (= 3.2.22.2) - builder (~> 3.0.0) - activerecord (3.2.22.2) - activemodel (= 3.2.22.2) - activesupport (= 3.2.22.2) - arel (~> 3.0.2) - tzinfo (~> 0.3.29) - activeresource (3.2.22.2) - activemodel (= 3.2.22.2) - activesupport (= 3.2.22.2) - activesupport (3.2.22.2) - i18n (~> 0.6, >= 0.6.4) - multi_json (~> 1.0) - addressable (2.3.6) - arel (3.0.3) - better_errors (1.1.0) + rails-dom-testing (~> 1.0, >= 1.0.5) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + activejob (4.2.6) + activesupport (= 4.2.6) + globalid (>= 0.3.0) + activemodel (4.2.6) + activesupport (= 4.2.6) + builder (~> 3.1) + activerecord (4.2.6) + activemodel (= 4.2.6) + activesupport (= 4.2.6) + arel (~> 6.0) + activesupport (4.2.6) + i18n (~> 0.7) + json (~> 1.7, >= 1.7.7) + minitest (~> 5.1) + thread_safe (~> 0.3, >= 0.3.4) + tzinfo (~> 1.1) + addressable (2.4.0) + arel (6.0.3) + autoprefixer-rails (6.3.6) + execjs + better_errors (2.1.1) coderay (>= 1.0.0) erubis (>= 2.6.6) + rack (>= 0.9.0) binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) - bootstrap-sass (2.3.2.2) - sass (~> 3.2) - braintree (2.48.1) + bootstrap-sass (3.3.6) + autoprefixer-rails (>= 5.2.1) + sass (>= 3.3.4) + braintree (2.60.0) builder (>= 2.0.0) - builder (3.0.4) - byebug (8.2.1) - capybara (2.4.4) + builder (3.2.2) + byebug (8.2.5) + capybara (2.7.1) + addressable mime-types (>= 1.16) nokogiri (>= 1.3.3) rack (>= 1.0.0) rack-test (>= 0.5.4) xpath (~> 2.0) - client_side_validations (3.2.6) - client_side_validations-simple_form (2.1.0) - client_side_validations (~> 3.2.5) - simple_form (~> 2.1.0) + client_side_validations (4.2.3) + jquery-rails (>= 3.1.2, < 5.0.0) + js_regex (~> 1.0, >= 1.0.9) + rails (>= 4.0.0, < 4.3.0) + client_side_validations-simple_form (3.2.3) + client_side_validations (~> 4.2.3) + simple_form (~> 3.2) cliver (0.3.2) - coderay (1.1.0) - couchrest (1.1.3) - mime-types (~> 1.15) - multi_json (~> 1.0) - rest-client (~> 1.6.1) - couchrest_model (2.0.1) - activemodel (>= 3.0) - couchrest (~> 1.1.3) + coderay (1.1.1) + concurrent-ruby (1.0.1) + couchrest (2.0.0.rc3) + httpclient (~> 2.7) mime-types (>= 1.15) + multi_json (~> 1.7) + couchrest_model (2.1.0.beta2) + activemodel (~> 4.0) + couchrest (= 2.0.0.rc3) tzinfo (>= 0.3.22) - couchrest_session_store (0.3.1) - actionpack (~> 3.0) - couchrest - couchrest_model - cucumber (1.3.17) + couchrest_session_store (0.4.2) + actionpack (~> 4.0) + couchrest (~> 2.0.0.rc3) + couchrest_model (~> 2.1.0.beta2) + cucumber (2.3.3) builder (>= 2.1.2) + cucumber-core (~> 1.4.0) + cucumber-wire (~> 0.0.1) diff-lcs (>= 1.1.3) - gherkin (~> 2.12) + gherkin (~> 3.2.0) multi_json (>= 1.7.5, < 2.0) - multi_test (>= 0.1.1) - cucumber-rails (1.4.2) + multi_test (>= 0.1.2) + cucumber-core (1.4.0) + gherkin (~> 3.2.0) + cucumber-rails (1.4.3) capybara (>= 1.1.2, < 3) - cucumber (>= 1.3.8, < 2) - mime-types (>= 1.16, < 3) + cucumber (>= 1.3.8, < 3) + mime-types (>= 1.16, < 4) nokogiri (~> 1.5) - rails (>= 3, < 5) - daemons (1.1.9) + railties (>= 3, < 5) + cucumber-wire (0.0.1) debug_inspector (0.0.2) diff-lcs (1.2.5) erubis (2.7.0) - eventmachine (1.0.3) - execjs (2.2.2) - factory_girl (4.5.0) + execjs (2.6.0) + factory_girl (4.7.0) activesupport (>= 3.0.0) - factory_girl_rails (4.5.0) - factory_girl (~> 4.5.0) + factory_girl_rails (4.7.0) + factory_girl (~> 4.7.0) railties (>= 3.0.0) - fake_braintree (0.7.0) + fake_braintree (0.8.0) activesupport braintree (~> 2.32) - capybara (>= 2.0.3) + capybara (>= 2.2.0) sinatra - faker (1.4.3) + faker (1.6.3) i18n (~> 0.5) - ffi (1.9.6) - gherkin (2.12.2) - multi_json (~> 1.3) - haml (4.0.6) + ffi (1.9.10) + gherkin (3.2.0) + globalid (0.3.6) + activesupport (>= 4.1.0) + haml (4.0.7) tilt - haml-rails (0.4) - actionpack (>= 3.1, < 4.1) - activesupport (>= 3.1, < 4.1) - haml (>= 3.1, < 4.1) - railties (>= 3.1, < 4.1) - hike (1.2.3) - http_accept_language (2.0.2) + haml-rails (0.9.0) + actionpack (>= 4.0.1) + activesupport (>= 4.0.1) + haml (>= 4.0.6, < 5.0) + html2haml (>= 1.0.1) + railties (>= 4.0.1) + html2haml (2.0.0) + erubis (~> 2.7.0) + haml (~> 4.0.0) + nokogiri (~> 1.6.0) + ruby_parser (~> 3.5) + http_accept_language (2.0.5) + httpclient (2.8.0) i18n (0.7.0) i18n-missing_translations (0.0.1) - journey (1.0.4) - jquery-rails (3.1.2) - railties (>= 3.0, < 5.0) + jquery-rails (4.1.1) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) thor (>= 0.14, < 2.0) + js_regex (1.0.14) + regexp_parser (= 0.3.3) json (1.8.3) - kaminari (0.13.0) + kaminari (0.16.3) actionpack (>= 3.0.0) activesupport (>= 3.0.0) - railties (>= 3.0.0) launchy (2.4.3) addressable (~> 2.3) - libv8 (3.16.14.11) - mail (2.5.4) - mime-types (~> 1.16) - treetop (~> 1.4.8) + libv8 (3.16.14.15) + loofah (2.0.3) + nokogiri (>= 1.5.9) + mail (2.6.4) + mime-types (>= 1.16, < 4) metaclass (0.0.4) method_source (0.8.2) - mime-types (1.25.1) - mini_portile (0.6.1) - minitest-stub-const (0.2) - mocha (0.13.3) + mime-types (3.0) + mime-types-data (~> 3.2015) + mime-types-data (3.2016.0221) + mini_portile2 (2.0.0) + minitest (5.8.4) + minitest-stub-const (0.5) + mocha (1.1.0) metaclass (~> 0.0.1) - multi_json (1.11.2) - multi_test (0.1.1) - nokogiri (1.6.5) - mini_portile (~> 0.6.0) - phantomjs-binaries (1.9.2.4) + multi_json (1.11.3) + multi_test (0.1.2) + nokogiri (1.6.7.2) + mini_portile2 (~> 2.0.0.rc2) + phantomjs-binaries (2.1.1.0) sys-uname (= 0.9.0) - poltergeist (1.5.1) + poltergeist (1.9.0) capybara (~> 2.1) cliver (~> 0.3.1) multi_json (~> 1.0) websocket-driver (>= 0.2.0) - polyglot (0.3.5) - pry (0.10.1) + pry (0.10.3) coderay (~> 1.1.0) method_source (~> 0.8.1) slop (~> 3.4) - quiet_assets (1.0.3) - railties (>= 3.1, < 5.0) - rack (1.4.7) - rack-cache (1.6.1) - rack (>= 0.4) + rack (1.6.4) rack-protection (1.5.3) rack - rack-ssl (1.3.4) - rack rack-test (0.6.3) rack (>= 1.0) - rails (3.2.22.2) - actionmailer (= 3.2.22.2) - actionpack (= 3.2.22.2) - activerecord (= 3.2.22.2) - activeresource (= 3.2.22.2) - activesupport (= 3.2.22.2) - bundler (~> 1.0) - railties (= 3.2.22.2) - rails-i18n (3.0.1) - i18n (~> 0.5) - rails (>= 3.0.0, < 4.0.0) + rails (4.2.6) + actionmailer (= 4.2.6) + actionpack (= 4.2.6) + actionview (= 4.2.6) + activejob (= 4.2.6) + activemodel (= 4.2.6) + activerecord (= 4.2.6) + activesupport (= 4.2.6) + bundler (>= 1.3.0, < 2.0) + railties (= 4.2.6) + sprockets-rails + rails-deprecated_sanitizer (1.0.3) + activesupport (>= 4.2.0.alpha) + rails-dom-testing (1.0.7) + activesupport (>= 4.2.0.beta, < 5.0) + nokogiri (~> 1.6.0) + rails-deprecated_sanitizer (>= 1.0.1) + rails-html-sanitizer (1.0.3) + loofah (~> 2.0) + rails-i18n (4.0.8) + i18n (~> 0.7) + railties (~> 4.0) rails_warden (0.5.8) warden (>= 1.0.0) - railties (3.2.22.2) - actionpack (= 3.2.22.2) - activesupport (= 3.2.22.2) - rack-ssl (~> 1.3.2) + railties (4.2.6) + actionpack (= 4.2.6) + activesupport (= 4.2.6) rake (>= 0.8.7) - rdoc (~> 3.4) - thor (>= 0.14.6, < 2.0) - rake (10.5.0) - rdiscount (2.1.7.1) - rdoc (3.12.2) - json (~> 1.4) + thor (>= 0.18.1, < 2.0) + rake (11.1.2) + rdiscount (2.1.8) ref (2.0.0) - rest-client (1.6.9) - mime-types (~> 1.16) + regexp_parser (0.3.3) + responders (2.1.2) + railties (>= 4.2.0, < 5.1) ruby-srp (0.2.1) - sass (3.4.9) - sass-rails (3.2.6) - railties (~> 3.2.0) - sass (>= 3.1.10) - tilt (~> 1.3) - simple_form (2.1.2) - actionpack (~> 3.0) - activemodel (~> 3.0) - sinatra (1.4.5) - rack (~> 1.4) + ruby_parser (3.8.1) + sexp_processor (~> 4.1) + sass (3.4.22) + sass-rails (5.0.4) + railties (>= 4.0.0, < 5.0) + sass (~> 3.1) + sprockets (>= 2.8, < 4.0) + sprockets-rails (>= 2.0, < 4.0) + tilt (>= 1.1, < 3) + sexp_processor (4.7.0) + simple_form (3.2.1) + actionpack (> 4, < 5.1) + activemodel (> 4, < 5.1) + sinatra (1.4.7) + rack (~> 1.5) rack-protection (~> 1.4) - tilt (~> 1.3, >= 1.3.4) + tilt (>= 1.3, < 3) slop (3.6.0) - sprockets (2.2.3) - hike (~> 1.2) - multi_json (~> 1.0) - rack (~> 1.0) - tilt (~> 1.1, != 1.3.0) + sprockets (3.6.0) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.0.4) + actionpack (>= 4.0) + activesupport (>= 4.0) + sprockets (>= 3.0.0) sys-uname (0.9.0) ffi (>= 1.0.0) therubyracer (0.12.2) libv8 (~> 3.16.14.0) ref - thin (1.6.3) - daemons (~> 1.0, >= 1.0.9) - eventmachine (~> 1.0) - rack (~> 1.0) thor (0.19.1) - tilt (1.4.1) - treetop (1.4.15) - polyglot - polyglot (>= 0.3.1) - tzinfo (0.3.47) - uglifier (1.2.7) - execjs (>= 0.3.0) - multi_json (~> 1.3) - valid_email (0.0.7) + thread_safe (0.3.5) + tilt (2.0.2) + tzinfo (1.2.2) + thread_safe (~> 0.1) + uglifier (3.0.0) + execjs (>= 0.3.0, < 3) + valid_email (0.0.13) activemodel - mail - warden (1.2.3) + mail (~> 2.6.1) + warden (1.2.6) rack (>= 1.0) - websocket-driver (0.5.1) + websocket-driver (0.6.3) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.1) + websocket-extensions (0.1.2) xpath (2.0.0) nokogiri (~> 1.3) @@ -264,47 +297,46 @@ PLATFORMS DEPENDENCIES SyslogLogger (~> 2.0) - better_errors (= 1.1.0) + better_errors binding_of_caller - bootstrap-sass (= 2.3.2.2) + bootstrap-sass byebug capybara certificate_authority! client_side_validations client_side_validations-simple_form common_languages! - couchrest (~> 1.1.3) - couchrest_model (~> 2.0.0) - couchrest_session_store (= 0.3.1) + couchrest (~> 2.0.0.rc3) + couchrest_model (~> 2.1.0.beta2) + couchrest_session_store (~> 0.4.2) cucumber-rails factory_girl_rails fake_braintree faker - haml-rails (= 0.4.0) + haml-rails http_accept_language i18n-missing_translations jquery-rails - kaminari (= 0.13.0) + kaminari launchy leap_web_billing! leap_web_help! minitest-stub-const - mocha (~> 0.13.0) + mocha phantomjs-binaries poltergeist pry - quiet_assets - rails (~> 3.2.21) + rails (~> 4.2.6) rails-i18n rails_warden - rake (~> 10.4) + rake rdiscount + responders (~> 2.0) ruby-srp (~> 0.2.1) - sass-rails (~> 3.2.5) + sass-rails simple_form - therubyracer (~> 0.12.2) - thin - uglifier (~> 1.2.7) + therubyracer + uglifier valid_email BUNDLED WITH @@ -46,20 +46,6 @@ External docs: * Overview of the main code repositories * Ideas for discrete, unclaimed development projects that would greatly benefit the LEAP ecosystem. -Known problems ---------------------------- - -* Client certificates are generated without a CSR. The problem is that - this makes the web application extremely vulnerable to denial of - service attacks. This is not an issue unless the provider enables the - possibility of anonymously fetching a client certificate without - authenticating first. - -* By its very nature, the user database is vulnerable to enumeration - attacks. These are very hard to prevent, because our protocol is - designed to allow query of a user database via proxy in order to - provide network perspective. - Installation --------------------------- @@ -69,17 +55,37 @@ these instructions: ### Install system requirements +You'll need git, ruby (2.1.5), couchdb and bundler installed. +On a recent debian based distribution run + sudo apt install git ruby couchdb bundler -Your actual requirements might differ if you are running an older OS that defaults to ruby 1.9. +For other operation systems please lookup the install instructions of these +tools. ### Download source +We host our own git repository. In order to create a local clone run + git clone --recursive git://leap.se/leap_web + cd leap_web + +The repo is mirrored on github and we accept pull requests there: + + https://github.com/leapcode/leap_web + +### Pick branch (development only) + +We use the master branch for the stable version deployed to production. +Development usually happens on the develop branch. So for development you +want to run + + git checkout origin/develop -b develop + +This will create a local branch called develop based on our develop branch. ### Install required ruby libraries - cd leap_web bundle --binstubs Typically, you run ``bundle`` as a normal user and it will ask you for a @@ -88,13 +94,13 @@ have sudo, run ``bundle`` as root. ### Installation for development purposes -Please see `doc/DEVELOP.md` for further required steps when installing +Please see `doc/DEVELOP.md` for details about installing leap_web for development purposes. -Configuration +Configuration for Production ---------------------------- -The configuration file `config/defaults.yml` providers good defaults for +The configuration file `config/defaults.yml` provides good defaults for most values. You can override these defaults by creating a file `config/config.yml`. @@ -167,3 +173,17 @@ To run an individual test: or ruby -Itest certs/test/unit/client_certificate_test.rb +Known problems +--------------------------- + +* Client certificates are generated without a CSR. The problem is that + this makes the web application extremely vulnerable to denial of + service attacks. This is not an issue unless the provider enables the + possibility of anonymously fetching a client certificate without + authenticating first. + +* By its very nature, the user database is vulnerable to enumeration + attacks. These are very hard to prevent, because our protocol is + designed to allow query of a user database via proxy in order to + provide network perspective. + diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 9af373d..7888161 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -16,6 +16,7 @@ //= require bootstrap //= require rails.validations //= require rails.validations.simple_form +//= require typeahead.bundle //= require leap //= require platform //= require tickets diff --git a/app/assets/javascripts/leap.js b/app/assets/javascripts/leap.js index 94e602d..c8fbcf5 100644 --- a/app/assets/javascripts/leap.js +++ b/app/assets/javascripts/leap.js @@ -5,3 +5,5 @@ function alert_message(msg) { $('#messages').append('<div class="alert alert-error"><a class="close" data-dismiss="alert">×</a><span>'+msg+'</span></div>'); } + +ClientSideValidations.formBuilders['SimpleForm::FormBuilder'].wrappers.bootstrap = ClientSideValidations.formBuilders['SimpleForm::FormBuilder'].wrappers["default"]; diff --git a/app/assets/javascripts/typeahead.bundle.js b/app/assets/javascripts/typeahead.bundle.js new file mode 100644 index 0000000..bb0c8ae --- /dev/null +++ b/app/assets/javascripts/typeahead.bundle.js @@ -0,0 +1,2451 @@ +/*! + * typeahead.js 0.11.1 + * https://github.com/twitter/typeahead.js + * Copyright 2013-2015 Twitter, Inc. and other contributors; Licensed MIT + */ + +(function(root, factory) { + if (typeof define === "function" && define.amd) { + define("bloodhound", [ "jquery" ], function(a0) { + return root["Bloodhound"] = factory(a0); + }); + } else if (typeof exports === "object") { + module.exports = factory(require("jquery")); + } else { + root["Bloodhound"] = factory(jQuery); + } +})(this, function($) { + var _ = function() { + "use strict"; + return { + isMsie: function() { + return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; + }, + isBlankString: function(str) { + return !str || /^\s*$/.test(str); + }, + escapeRegExChars: function(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + }, + isString: function(obj) { + return typeof obj === "string"; + }, + isNumber: function(obj) { + return typeof obj === "number"; + }, + isArray: $.isArray, + isFunction: $.isFunction, + isObject: $.isPlainObject, + isUndefined: function(obj) { + return typeof obj === "undefined"; + }, + isElement: function(obj) { + return !!(obj && obj.nodeType === 1); + }, + isJQuery: function(obj) { + return obj instanceof $; + }, + toStr: function toStr(s) { + return _.isUndefined(s) || s === null ? "" : s + ""; + }, + bind: $.proxy, + each: function(collection, cb) { + $.each(collection, reverseArgs); + function reverseArgs(index, value) { + return cb(value, index); + } + }, + map: $.map, + filter: $.grep, + every: function(obj, test) { + var result = true; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (!(result = test.call(null, val, key, obj))) { + return false; + } + }); + return !!result; + }, + some: function(obj, test) { + var result = false; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (result = test.call(null, val, key, obj)) { + return false; + } + }); + return !!result; + }, + mixin: $.extend, + identity: function(x) { + return x; + }, + clone: function(obj) { + return $.extend(true, {}, obj); + }, + getIdGenerator: function() { + var counter = 0; + return function() { + return counter++; + }; + }, + templatify: function templatify(obj) { + return $.isFunction(obj) ? obj : template; + function template() { + return String(obj); + } + }, + defer: function(fn) { + setTimeout(fn, 0); + }, + debounce: function(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments, later, callNow; + later = function() { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + }; + }, + throttle: function(func, wait) { + var context, args, timeout, result, previous, later; + previous = 0; + later = function() { + previous = new Date(); + timeout = null; + result = func.apply(context, args); + }; + return function() { + var now = new Date(), remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); + timeout = null; + previous = now; + result = func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(later, remaining); + } + return result; + }; + }, + stringify: function(val) { + return _.isString(val) ? val : JSON.stringify(val); + }, + noop: function() {} + }; + }(); + var VERSION = "0.11.1"; + var tokenizers = function() { + "use strict"; + return { + nonword: nonword, + whitespace: whitespace, + obj: { + nonword: getObjTokenizer(nonword), + whitespace: getObjTokenizer(whitespace) + } + }; + function whitespace(str) { + str = _.toStr(str); + return str ? str.split(/\s+/) : []; + } + function nonword(str) { + str = _.toStr(str); + return str ? str.split(/\W+/) : []; + } + function getObjTokenizer(tokenizer) { + return function setKey(keys) { + keys = _.isArray(keys) ? keys : [].slice.call(arguments, 0); + return function tokenize(o) { + var tokens = []; + _.each(keys, function(k) { + tokens = tokens.concat(tokenizer(_.toStr(o[k]))); + }); + return tokens; + }; + }; + } + }(); + var LruCache = function() { + "use strict"; + function LruCache(maxSize) { + this.maxSize = _.isNumber(maxSize) ? maxSize : 100; + this.reset(); + if (this.maxSize <= 0) { + this.set = this.get = $.noop; + } + } + _.mixin(LruCache.prototype, { + set: function set(key, val) { + var tailItem = this.list.tail, node; + if (this.size >= this.maxSize) { + this.list.remove(tailItem); + delete this.hash[tailItem.key]; + this.size--; + } + if (node = this.hash[key]) { + node.val = val; + this.list.moveToFront(node); + } else { + node = new Node(key, val); + this.list.add(node); + this.hash[key] = node; + this.size++; + } + }, + get: function get(key) { + var node = this.hash[key]; + if (node) { + this.list.moveToFront(node); + return node.val; + } + }, + reset: function reset() { + this.size = 0; + this.hash = {}; + this.list = new List(); + } + }); + function List() { + this.head = this.tail = null; + } + _.mixin(List.prototype, { + add: function add(node) { + if (this.head) { + node.next = this.head; + this.head.prev = node; + } + this.head = node; + this.tail = this.tail || node; + }, + remove: function remove(node) { + node.prev ? node.prev.next = node.next : this.head = node.next; + node.next ? node.next.prev = node.prev : this.tail = node.prev; + }, + moveToFront: function(node) { + this.remove(node); + this.add(node); + } + }); + function Node(key, val) { + this.key = key; + this.val = val; + this.prev = this.next = null; + } + return LruCache; + }(); + var PersistentStorage = function() { + "use strict"; + var LOCAL_STORAGE; + try { + LOCAL_STORAGE = window.localStorage; + LOCAL_STORAGE.setItem("~~~", "!"); + LOCAL_STORAGE.removeItem("~~~"); + } catch (err) { + LOCAL_STORAGE = null; + } + function PersistentStorage(namespace, override) { + this.prefix = [ "__", namespace, "__" ].join(""); + this.ttlKey = "__ttl__"; + this.keyMatcher = new RegExp("^" + _.escapeRegExChars(this.prefix)); + this.ls = override || LOCAL_STORAGE; + !this.ls && this._noop(); + } + _.mixin(PersistentStorage.prototype, { + _prefix: function(key) { + return this.prefix + key; + }, + _ttlKey: function(key) { + return this._prefix(key) + this.ttlKey; + }, + _noop: function() { + this.get = this.set = this.remove = this.clear = this.isExpired = _.noop; + }, + _safeSet: function(key, val) { + try { + this.ls.setItem(key, val); + } catch (err) { + if (err.name === "QuotaExceededError") { + this.clear(); + this._noop(); + } + } + }, + get: function(key) { + if (this.isExpired(key)) { + this.remove(key); + } + return decode(this.ls.getItem(this._prefix(key))); + }, + set: function(key, val, ttl) { + if (_.isNumber(ttl)) { + this._safeSet(this._ttlKey(key), encode(now() + ttl)); + } else { + this.ls.removeItem(this._ttlKey(key)); + } + return this._safeSet(this._prefix(key), encode(val)); + }, + remove: function(key) { + this.ls.removeItem(this._ttlKey(key)); + this.ls.removeItem(this._prefix(key)); + return this; + }, + clear: function() { + var i, keys = gatherMatchingKeys(this.keyMatcher); + for (i = keys.length; i--; ) { + this.remove(keys[i]); + } + return this; + }, + isExpired: function(key) { + var ttl = decode(this.ls.getItem(this._ttlKey(key))); + return _.isNumber(ttl) && now() > ttl ? true : false; + } + }); + return PersistentStorage; + function now() { + return new Date().getTime(); + } + function encode(val) { + return JSON.stringify(_.isUndefined(val) ? null : val); + } + function decode(val) { + return $.parseJSON(val); + } + function gatherMatchingKeys(keyMatcher) { + var i, key, keys = [], len = LOCAL_STORAGE.length; + for (i = 0; i < len; i++) { + if ((key = LOCAL_STORAGE.key(i)).match(keyMatcher)) { + keys.push(key.replace(keyMatcher, "")); + } + } + return keys; + } + }(); + var Transport = function() { + "use strict"; + var pendingRequestsCount = 0, pendingRequests = {}, maxPendingRequests = 6, sharedCache = new LruCache(10); + function Transport(o) { + o = o || {}; + this.cancelled = false; + this.lastReq = null; + this._send = o.transport; + this._get = o.limiter ? o.limiter(this._get) : this._get; + this._cache = o.cache === false ? new LruCache(0) : sharedCache; + } + Transport.setMaxPendingRequests = function setMaxPendingRequests(num) { + maxPendingRequests = num; + }; + Transport.resetCache = function resetCache() { + sharedCache.reset(); + }; + _.mixin(Transport.prototype, { + _fingerprint: function fingerprint(o) { + o = o || {}; + return o.url + o.type + $.param(o.data || {}); + }, + _get: function(o, cb) { + var that = this, fingerprint, jqXhr; + fingerprint = this._fingerprint(o); + if (this.cancelled || fingerprint !== this.lastReq) { + return; + } + if (jqXhr = pendingRequests[fingerprint]) { + jqXhr.done(done).fail(fail); + } else if (pendingRequestsCount < maxPendingRequests) { + pendingRequestsCount++; + pendingRequests[fingerprint] = this._send(o).done(done).fail(fail).always(always); + } else { + this.onDeckRequestArgs = [].slice.call(arguments, 0); + } + function done(resp) { + cb(null, resp); + that._cache.set(fingerprint, resp); + } + function fail() { + cb(true); + } + function always() { + pendingRequestsCount--; + delete pendingRequests[fingerprint]; + if (that.onDeckRequestArgs) { + that._get.apply(that, that.onDeckRequestArgs); + that.onDeckRequestArgs = null; + } + } + }, + get: function(o, cb) { + var resp, fingerprint; + cb = cb || $.noop; + o = _.isString(o) ? { + url: o + } : o || {}; + fingerprint = this._fingerprint(o); + this.cancelled = false; + this.lastReq = fingerprint; + if (resp = this._cache.get(fingerprint)) { + cb(null, resp); + } else { + this._get(o, cb); + } + }, + cancel: function() { + this.cancelled = true; + } + }); + return Transport; + }(); + var SearchIndex = window.SearchIndex = function() { + "use strict"; + var CHILDREN = "c", IDS = "i"; + function SearchIndex(o) { + o = o || {}; + if (!o.datumTokenizer || !o.queryTokenizer) { + $.error("datumTokenizer and queryTokenizer are both required"); + } + this.identify = o.identify || _.stringify; + this.datumTokenizer = o.datumTokenizer; + this.queryTokenizer = o.queryTokenizer; + this.reset(); + } + _.mixin(SearchIndex.prototype, { + bootstrap: function bootstrap(o) { + this.datums = o.datums; + this.trie = o.trie; + }, + add: function(data) { + var that = this; + data = _.isArray(data) ? data : [ data ]; + _.each(data, function(datum) { + var id, tokens; + that.datums[id = that.identify(datum)] = datum; + tokens = normalizeTokens(that.datumTokenizer(datum)); + _.each(tokens, function(token) { + var node, chars, ch; + node = that.trie; + chars = token.split(""); + while (ch = chars.shift()) { + node = node[CHILDREN][ch] || (node[CHILDREN][ch] = newNode()); + node[IDS].push(id); + } + }); + }); + }, + get: function get(ids) { + var that = this; + return _.map(ids, function(id) { + return that.datums[id]; + }); + }, + search: function search(query) { + var that = this, tokens, matches; + tokens = normalizeTokens(this.queryTokenizer(query)); + _.each(tokens, function(token) { + var node, chars, ch, ids; + if (matches && matches.length === 0) { + return false; + } + node = that.trie; + chars = token.split(""); + while (node && (ch = chars.shift())) { + node = node[CHILDREN][ch]; + } + if (node && chars.length === 0) { + ids = node[IDS].slice(0); + matches = matches ? getIntersection(matches, ids) : ids; + } else { + matches = []; + return false; + } + }); + return matches ? _.map(unique(matches), function(id) { + return that.datums[id]; + }) : []; + }, + all: function all() { + var values = []; + for (var key in this.datums) { + values.push(this.datums[key]); + } + return values; + }, + reset: function reset() { + this.datums = {}; + this.trie = newNode(); + }, + serialize: function serialize() { + return { + datums: this.datums, + trie: this.trie + }; + } + }); + return SearchIndex; + function normalizeTokens(tokens) { + tokens = _.filter(tokens, function(token) { + return !!token; + }); + tokens = _.map(tokens, function(token) { + return token.toLowerCase(); + }); + return tokens; + } + function newNode() { + var node = {}; + node[IDS] = []; + node[CHILDREN] = {}; + return node; + } + function unique(array) { + var seen = {}, uniques = []; + for (var i = 0, len = array.length; i < len; i++) { + if (!seen[array[i]]) { + seen[array[i]] = true; + uniques.push(array[i]); + } + } + return uniques; + } + function getIntersection(arrayA, arrayB) { + var ai = 0, bi = 0, intersection = []; + arrayA = arrayA.sort(); + arrayB = arrayB.sort(); + var lenArrayA = arrayA.length, lenArrayB = arrayB.length; + while (ai < lenArrayA && bi < lenArrayB) { + if (arrayA[ai] < arrayB[bi]) { + ai++; + } else if (arrayA[ai] > arrayB[bi]) { + bi++; + } else { + intersection.push(arrayA[ai]); + ai++; + bi++; + } + } + return intersection; + } + }(); + var Prefetch = function() { + "use strict"; + var keys; + keys = { + data: "data", + protocol: "protocol", + thumbprint: "thumbprint" + }; + function Prefetch(o) { + this.url = o.url; + this.ttl = o.ttl; + this.cache = o.cache; + this.prepare = o.prepare; + this.transform = o.transform; + this.transport = o.transport; + this.thumbprint = o.thumbprint; + this.storage = new PersistentStorage(o.cacheKey); + } + _.mixin(Prefetch.prototype, { + _settings: function settings() { + return { + url: this.url, + type: "GET", + dataType: "json" + }; + }, + store: function store(data) { + if (!this.cache) { + return; + } + this.storage.set(keys.data, data, this.ttl); + this.storage.set(keys.protocol, location.protocol, this.ttl); + this.storage.set(keys.thumbprint, this.thumbprint, this.ttl); + }, + fromCache: function fromCache() { + var stored = {}, isExpired; + if (!this.cache) { + return null; + } + stored.data = this.storage.get(keys.data); + stored.protocol = this.storage.get(keys.protocol); + stored.thumbprint = this.storage.get(keys.thumbprint); + isExpired = stored.thumbprint !== this.thumbprint || stored.protocol !== location.protocol; + return stored.data && !isExpired ? stored.data : null; + }, + fromNetwork: function(cb) { + var that = this, settings; + if (!cb) { + return; + } + settings = this.prepare(this._settings()); + this.transport(settings).fail(onError).done(onResponse); + function onError() { + cb(true); + } + function onResponse(resp) { + cb(null, that.transform(resp)); + } + }, + clear: function clear() { + this.storage.clear(); + return this; + } + }); + return Prefetch; + }(); + var Remote = function() { + "use strict"; + function Remote(o) { + this.url = o.url; + this.prepare = o.prepare; + this.transform = o.transform; + this.transport = new Transport({ + cache: o.cache, + limiter: o.limiter, + transport: o.transport + }); + } + _.mixin(Remote.prototype, { + _settings: function settings() { + return { + url: this.url, + type: "GET", + dataType: "json" + }; + }, + get: function get(query, cb) { + var that = this, settings; + if (!cb) { + return; + } + query = query || ""; + settings = this.prepare(query, this._settings()); + return this.transport.get(settings, onResponse); + function onResponse(err, resp) { + err ? cb([]) : cb(that.transform(resp)); + } + }, + cancelLastRequest: function cancelLastRequest() { + this.transport.cancel(); + } + }); + return Remote; + }(); + var oParser = function() { + "use strict"; + return function parse(o) { + var defaults, sorter; + defaults = { + initialize: true, + identify: _.stringify, + datumTokenizer: null, + queryTokenizer: null, + sufficient: 5, + sorter: null, + local: [], + prefetch: null, + remote: null + }; + o = _.mixin(defaults, o || {}); + !o.datumTokenizer && $.error("datumTokenizer is required"); + !o.queryTokenizer && $.error("queryTokenizer is required"); + sorter = o.sorter; + o.sorter = sorter ? function(x) { + return x.sort(sorter); + } : _.identity; + o.local = _.isFunction(o.local) ? o.local() : o.local; + o.prefetch = parsePrefetch(o.prefetch); + o.remote = parseRemote(o.remote); + return o; + }; + function parsePrefetch(o) { + var defaults; + if (!o) { + return null; + } + defaults = { + url: null, + ttl: 24 * 60 * 60 * 1e3, + cache: true, + cacheKey: null, + thumbprint: "", + prepare: _.identity, + transform: _.identity, + transport: null + }; + o = _.isString(o) ? { + url: o + } : o; + o = _.mixin(defaults, o); + !o.url && $.error("prefetch requires url to be set"); + o.transform = o.filter || o.transform; + o.cacheKey = o.cacheKey || o.url; + o.thumbprint = VERSION + o.thumbprint; + o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax; + return o; + } + function parseRemote(o) { + var defaults; + if (!o) { + return; + } + defaults = { + url: null, + cache: true, + prepare: null, + replace: null, + wildcard: null, + limiter: null, + rateLimitBy: "debounce", + rateLimitWait: 300, + transform: _.identity, + transport: null + }; + o = _.isString(o) ? { + url: o + } : o; + o = _.mixin(defaults, o); + !o.url && $.error("remote requires url to be set"); + o.transform = o.filter || o.transform; + o.prepare = toRemotePrepare(o); + o.limiter = toLimiter(o); + o.transport = o.transport ? callbackToDeferred(o.transport) : $.ajax; + delete o.replace; + delete o.wildcard; + delete o.rateLimitBy; + delete o.rateLimitWait; + return o; + } + function toRemotePrepare(o) { + var prepare, replace, wildcard; + prepare = o.prepare; + replace = o.replace; + wildcard = o.wildcard; + if (prepare) { + return prepare; + } + if (replace) { + prepare = prepareByReplace; + } else if (o.wildcard) { + prepare = prepareByWildcard; + } else { + prepare = idenityPrepare; + } + return prepare; + function prepareByReplace(query, settings) { + settings.url = replace(settings.url, query); + return settings; + } + function prepareByWildcard(query, settings) { + settings.url = settings.url.replace(wildcard, encodeURIComponent(query)); + return settings; + } + function idenityPrepare(query, settings) { + return settings; + } + } + function toLimiter(o) { + var limiter, method, wait; + limiter = o.limiter; + method = o.rateLimitBy; + wait = o.rateLimitWait; + if (!limiter) { + limiter = /^throttle$/i.test(method) ? throttle(wait) : debounce(wait); + } + return limiter; + function debounce(wait) { + return function debounce(fn) { + return _.debounce(fn, wait); + }; + } + function throttle(wait) { + return function throttle(fn) { + return _.throttle(fn, wait); + }; + } + } + function callbackToDeferred(fn) { + return function wrapper(o) { + var deferred = $.Deferred(); + fn(o, onSuccess, onError); + return deferred; + function onSuccess(resp) { + _.defer(function() { + deferred.resolve(resp); + }); + } + function onError(err) { + _.defer(function() { + deferred.reject(err); + }); + } + }; + } + }(); + var Bloodhound = function() { + "use strict"; + var old; + old = window && window.Bloodhound; + function Bloodhound(o) { + o = oParser(o); + this.sorter = o.sorter; + this.identify = o.identify; + this.sufficient = o.sufficient; + this.local = o.local; + this.remote = o.remote ? new Remote(o.remote) : null; + this.prefetch = o.prefetch ? new Prefetch(o.prefetch) : null; + this.index = new SearchIndex({ + identify: this.identify, + datumTokenizer: o.datumTokenizer, + queryTokenizer: o.queryTokenizer + }); + o.initialize !== false && this.initialize(); + } + Bloodhound.noConflict = function noConflict() { + window && (window.Bloodhound = old); + return Bloodhound; + }; + Bloodhound.tokenizers = tokenizers; + _.mixin(Bloodhound.prototype, { + __ttAdapter: function ttAdapter() { + var that = this; + return this.remote ? withAsync : withoutAsync; + function withAsync(query, sync, async) { + return that.search(query, sync, async); + } + function withoutAsync(query, sync) { + return that.search(query, sync); + } + }, + _loadPrefetch: function loadPrefetch() { + var that = this, deferred, serialized; + deferred = $.Deferred(); + if (!this.prefetch) { + deferred.resolve(); + } else if (serialized = this.prefetch.fromCache()) { + this.index.bootstrap(serialized); + deferred.resolve(); + } else { + this.prefetch.fromNetwork(done); + } + return deferred.promise(); + function done(err, data) { + if (err) { + return deferred.reject(); + } + that.add(data); + that.prefetch.store(that.index.serialize()); + deferred.resolve(); + } + }, + _initialize: function initialize() { + var that = this, deferred; + this.clear(); + (this.initPromise = this._loadPrefetch()).done(addLocalToIndex); + return this.initPromise; + function addLocalToIndex() { + that.add(that.local); + } + }, + initialize: function initialize(force) { + return !this.initPromise || force ? this._initialize() : this.initPromise; + }, + add: function add(data) { + this.index.add(data); + return this; + }, + get: function get(ids) { + ids = _.isArray(ids) ? ids : [].slice.call(arguments); + return this.index.get(ids); + }, + search: function search(query, sync, async) { + var that = this, local; + local = this.sorter(this.index.search(query)); + sync(this.remote ? local.slice() : local); + if (this.remote && local.length < this.sufficient) { + this.remote.get(query, processRemote); + } else if (this.remote) { + this.remote.cancelLastRequest(); + } + return this; + function processRemote(remote) { + var nonDuplicates = []; + _.each(remote, function(r) { + !_.some(local, function(l) { + return that.identify(r) === that.identify(l); + }) && nonDuplicates.push(r); + }); + async && async(nonDuplicates); + } + }, + all: function all() { + return this.index.all(); + }, + clear: function clear() { + this.index.reset(); + return this; + }, + clearPrefetchCache: function clearPrefetchCache() { + this.prefetch && this.prefetch.clear(); + return this; + }, + clearRemoteCache: function clearRemoteCache() { + Transport.resetCache(); + return this; + }, + ttAdapter: function ttAdapter() { + return this.__ttAdapter(); + } + }); + return Bloodhound; + }(); + return Bloodhound; +}); + +(function(root, factory) { + if (typeof define === "function" && define.amd) { + define("typeahead.js", [ "jquery" ], function(a0) { + return factory(a0); + }); + } else if (typeof exports === "object") { + module.exports = factory(require("jquery")); + } else { + factory(jQuery); + } +})(this, function($) { + var _ = function() { + "use strict"; + return { + isMsie: function() { + return /(msie|trident)/i.test(navigator.userAgent) ? navigator.userAgent.match(/(msie |rv:)(\d+(.\d+)?)/i)[2] : false; + }, + isBlankString: function(str) { + return !str || /^\s*$/.test(str); + }, + escapeRegExChars: function(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + }, + isString: function(obj) { + return typeof obj === "string"; + }, + isNumber: function(obj) { + return typeof obj === "number"; + }, + isArray: $.isArray, + isFunction: $.isFunction, + isObject: $.isPlainObject, + isUndefined: function(obj) { + return typeof obj === "undefined"; + }, + isElement: function(obj) { + return !!(obj && obj.nodeType === 1); + }, + isJQuery: function(obj) { + return obj instanceof $; + }, + toStr: function toStr(s) { + return _.isUndefined(s) || s === null ? "" : s + ""; + }, + bind: $.proxy, + each: function(collection, cb) { + $.each(collection, reverseArgs); + function reverseArgs(index, value) { + return cb(value, index); + } + }, + map: $.map, + filter: $.grep, + every: function(obj, test) { + var result = true; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (!(result = test.call(null, val, key, obj))) { + return false; + } + }); + return !!result; + }, + some: function(obj, test) { + var result = false; + if (!obj) { + return result; + } + $.each(obj, function(key, val) { + if (result = test.call(null, val, key, obj)) { + return false; + } + }); + return !!result; + }, + mixin: $.extend, + identity: function(x) { + return x; + }, + clone: function(obj) { + return $.extend(true, {}, obj); + }, + getIdGenerator: function() { + var counter = 0; + return function() { + return counter++; + }; + }, + templatify: function templatify(obj) { + return $.isFunction(obj) ? obj : template; + function template() { + return String(obj); + } + }, + defer: function(fn) { + setTimeout(fn, 0); + }, + debounce: function(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments, later, callNow; + later = function() { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + }; + }, + throttle: function(func, wait) { + var context, args, timeout, result, previous, later; + previous = 0; + later = function() { + previous = new Date(); + timeout = null; + result = func.apply(context, args); + }; + return function() { + var now = new Date(), remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0) { + clearTimeout(timeout); + timeout = null; + previous = now; + result = func.apply(context, args); + } else if (!timeout) { + timeout = setTimeout(later, remaining); + } + return result; + }; + }, + stringify: function(val) { + return _.isString(val) ? val : JSON.stringify(val); + }, + noop: function() {} + }; + }(); + var WWW = function() { + "use strict"; + var defaultClassNames = { + wrapper: "twitter-typeahead", + input: "tt-input", + hint: "tt-hint", + menu: "tt-menu", + dataset: "tt-dataset", + suggestion: "tt-suggestion", + selectable: "tt-selectable", + empty: "tt-empty", + open: "tt-open", + cursor: "tt-cursor", + highlight: "tt-highlight" + }; + return build; + function build(o) { + var www, classes; + classes = _.mixin({}, defaultClassNames, o); + www = { + css: buildCss(), + classes: classes, + html: buildHtml(classes), + selectors: buildSelectors(classes) + }; + return { + css: www.css, + html: www.html, + classes: www.classes, + selectors: www.selectors, + mixin: function(o) { + _.mixin(o, www); + } + }; + } + function buildHtml(c) { + return { + wrapper: '<span class="' + c.wrapper + '"></span>', + menu: '<div class="' + c.menu + '"></div>' + }; + } + function buildSelectors(classes) { + var selectors = {}; + _.each(classes, function(v, k) { + selectors[k] = "." + v; + }); + return selectors; + } + function buildCss() { + var css = { + wrapper: { + position: "relative", + display: "inline-block" + }, + hint: { + position: "absolute", + top: "0", + left: "0", + borderColor: "transparent", + boxShadow: "none", + opacity: "1" + }, + input: { + position: "relative", + verticalAlign: "top", + backgroundColor: "transparent" + }, + inputWithNoHint: { + position: "relative", + verticalAlign: "top" + }, + menu: { + position: "absolute", + top: "100%", + left: "0", + zIndex: "100", + display: "none" + }, + ltr: { + left: "0", + right: "auto" + }, + rtl: { + left: "auto", + right: " 0" + } + }; + if (_.isMsie()) { + _.mixin(css.input, { + backgroundImage: "url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)" + }); + } + return css; + } + }(); + var EventBus = function() { + "use strict"; + var namespace, deprecationMap; + namespace = "typeahead:"; + deprecationMap = { + render: "rendered", + cursorchange: "cursorchanged", + select: "selected", + autocomplete: "autocompleted" + }; + function EventBus(o) { + if (!o || !o.el) { + $.error("EventBus initialized without el"); + } + this.$el = $(o.el); + } + _.mixin(EventBus.prototype, { + _trigger: function(type, args) { + var $e; + $e = $.Event(namespace + type); + (args = args || []).unshift($e); + this.$el.trigger.apply(this.$el, args); + return $e; + }, + before: function(type) { + var args, $e; + args = [].slice.call(arguments, 1); + $e = this._trigger("before" + type, args); + return $e.isDefaultPrevented(); + }, + trigger: function(type) { + var deprecatedType; + this._trigger(type, [].slice.call(arguments, 1)); + if (deprecatedType = deprecationMap[type]) { + this._trigger(deprecatedType, [].slice.call(arguments, 1)); + } + } + }); + return EventBus; + }(); + var EventEmitter = function() { + "use strict"; + var splitter = /\s+/, nextTick = getNextTick(); + return { + onSync: onSync, + onAsync: onAsync, + off: off, + trigger: trigger + }; + function on(method, types, cb, context) { + var type; + if (!cb) { + return this; + } + types = types.split(splitter); + cb = context ? bindContext(cb, context) : cb; + this._callbacks = this._callbacks || {}; + while (type = types.shift()) { + this._callbacks[type] = this._callbacks[type] || { + sync: [], + async: [] + }; + this._callbacks[type][method].push(cb); + } + return this; + } + function onAsync(types, cb, context) { + return on.call(this, "async", types, cb, context); + } + function onSync(types, cb, context) { + return on.call(this, "sync", types, cb, context); + } + function off(types) { + var type; + if (!this._callbacks) { + return this; + } + types = types.split(splitter); + while (type = types.shift()) { + delete this._callbacks[type]; + } + return this; + } + function trigger(types) { + var type, callbacks, args, syncFlush, asyncFlush; + if (!this._callbacks) { + return this; + } + types = types.split(splitter); + args = [].slice.call(arguments, 1); + while ((type = types.shift()) && (callbacks = this._callbacks[type])) { + syncFlush = getFlush(callbacks.sync, this, [ type ].concat(args)); + asyncFlush = getFlush(callbacks.async, this, [ type ].concat(args)); + syncFlush() && nextTick(asyncFlush); + } + return this; + } + function getFlush(callbacks, context, args) { + return flush; + function flush() { + var cancelled; + for (var i = 0, len = callbacks.length; !cancelled && i < len; i += 1) { + cancelled = callbacks[i].apply(context, args) === false; + } + return !cancelled; + } + } + function getNextTick() { + var nextTickFn; + if (window.setImmediate) { + nextTickFn = function nextTickSetImmediate(fn) { + setImmediate(function() { + fn(); + }); + }; + } else { + nextTickFn = function nextTickSetTimeout(fn) { + setTimeout(function() { + fn(); + }, 0); + }; + } + return nextTickFn; + } + function bindContext(fn, context) { + return fn.bind ? fn.bind(context) : function() { + fn.apply(context, [].slice.call(arguments, 0)); + }; + } + }(); + var highlight = function(doc) { + "use strict"; + var defaults = { + node: null, + pattern: null, + tagName: "strong", + className: null, + wordsOnly: false, + caseSensitive: false + }; + return function hightlight(o) { + var regex; + o = _.mixin({}, defaults, o); + if (!o.node || !o.pattern) { + return; + } + o.pattern = _.isArray(o.pattern) ? o.pattern : [ o.pattern ]; + regex = getRegex(o.pattern, o.caseSensitive, o.wordsOnly); + traverse(o.node, hightlightTextNode); + function hightlightTextNode(textNode) { + var match, patternNode, wrapperNode; + if (match = regex.exec(textNode.data)) { + wrapperNode = doc.createElement(o.tagName); + o.className && (wrapperNode.className = o.className); + patternNode = textNode.splitText(match.index); + patternNode.splitText(match[0].length); + wrapperNode.appendChild(patternNode.cloneNode(true)); + textNode.parentNode.replaceChild(wrapperNode, patternNode); + } + return !!match; + } + function traverse(el, hightlightTextNode) { + var childNode, TEXT_NODE_TYPE = 3; + for (var i = 0; i < el.childNodes.length; i++) { + childNode = el.childNodes[i]; + if (childNode.nodeType === TEXT_NODE_TYPE) { + i += hightlightTextNode(childNode) ? 1 : 0; + } else { + traverse(childNode, hightlightTextNode); + } + } + } + }; + function getRegex(patterns, caseSensitive, wordsOnly) { + var escapedPatterns = [], regexStr; + for (var i = 0, len = patterns.length; i < len; i++) { + escapedPatterns.push(_.escapeRegExChars(patterns[i])); + } + regexStr = wordsOnly ? "\\b(" + escapedPatterns.join("|") + ")\\b" : "(" + escapedPatterns.join("|") + ")"; + return caseSensitive ? new RegExp(regexStr) : new RegExp(regexStr, "i"); + } + }(window.document); + var Input = function() { + "use strict"; + var specialKeyCodeMap; + specialKeyCodeMap = { + 9: "tab", + 27: "esc", + 37: "left", + 39: "right", + 13: "enter", + 38: "up", + 40: "down" + }; + function Input(o, www) { + o = o || {}; + if (!o.input) { + $.error("input is missing"); + } + www.mixin(this); + this.$hint = $(o.hint); + this.$input = $(o.input); + this.query = this.$input.val(); + this.queryWhenFocused = this.hasFocus() ? this.query : null; + this.$overflowHelper = buildOverflowHelper(this.$input); + this._checkLanguageDirection(); + if (this.$hint.length === 0) { + this.setHint = this.getHint = this.clearHint = this.clearHintIfInvalid = _.noop; + } + } + Input.normalizeQuery = function(str) { + return _.toStr(str).replace(/^\s*/g, "").replace(/\s{2,}/g, " "); + }; + _.mixin(Input.prototype, EventEmitter, { + _onBlur: function onBlur() { + this.resetInputValue(); + this.trigger("blurred"); + }, + _onFocus: function onFocus() { + this.queryWhenFocused = this.query; + this.trigger("focused"); + }, + _onKeydown: function onKeydown($e) { + var keyName = specialKeyCodeMap[$e.which || $e.keyCode]; + this._managePreventDefault(keyName, $e); + if (keyName && this._shouldTrigger(keyName, $e)) { + this.trigger(keyName + "Keyed", $e); + } + }, + _onInput: function onInput() { + this._setQuery(this.getInputValue()); + this.clearHintIfInvalid(); + this._checkLanguageDirection(); + }, + _managePreventDefault: function managePreventDefault(keyName, $e) { + var preventDefault; + switch (keyName) { + case "up": + case "down": + preventDefault = !withModifier($e); + break; + + default: + preventDefault = false; + } + preventDefault && $e.preventDefault(); + }, + _shouldTrigger: function shouldTrigger(keyName, $e) { + var trigger; + switch (keyName) { + case "tab": + trigger = !withModifier($e); + break; + + default: + trigger = true; + } + return trigger; + }, + _checkLanguageDirection: function checkLanguageDirection() { + var dir = (this.$input.css("direction") || "ltr").toLowerCase(); + if (this.dir !== dir) { + this.dir = dir; + this.$hint.attr("dir", dir); + this.trigger("langDirChanged", dir); + } + }, + _setQuery: function setQuery(val, silent) { + var areEquivalent, hasDifferentWhitespace; + areEquivalent = areQueriesEquivalent(val, this.query); + hasDifferentWhitespace = areEquivalent ? this.query.length !== val.length : false; + this.query = val; + if (!silent && !areEquivalent) { + this.trigger("queryChanged", this.query); + } else if (!silent && hasDifferentWhitespace) { + this.trigger("whitespaceChanged", this.query); + } + }, + bind: function() { + var that = this, onBlur, onFocus, onKeydown, onInput; + onBlur = _.bind(this._onBlur, this); + onFocus = _.bind(this._onFocus, this); + onKeydown = _.bind(this._onKeydown, this); + onInput = _.bind(this._onInput, this); + this.$input.on("blur.tt", onBlur).on("focus.tt", onFocus).on("keydown.tt", onKeydown); + if (!_.isMsie() || _.isMsie() > 9) { + this.$input.on("input.tt", onInput); + } else { + this.$input.on("keydown.tt keypress.tt cut.tt paste.tt", function($e) { + if (specialKeyCodeMap[$e.which || $e.keyCode]) { + return; + } + _.defer(_.bind(that._onInput, that, $e)); + }); + } + return this; + }, + focus: function focus() { + this.$input.focus(); + }, + blur: function blur() { + this.$input.blur(); + }, + getLangDir: function getLangDir() { + return this.dir; + }, + getQuery: function getQuery() { + return this.query || ""; + }, + setQuery: function setQuery(val, silent) { + this.setInputValue(val); + this._setQuery(val, silent); + }, + hasQueryChangedSinceLastFocus: function hasQueryChangedSinceLastFocus() { + return this.query !== this.queryWhenFocused; + }, + getInputValue: function getInputValue() { + return this.$input.val(); + }, + setInputValue: function setInputValue(value) { + this.$input.val(value); + this.clearHintIfInvalid(); + this._checkLanguageDirection(); + }, + resetInputValue: function resetInputValue() { + this.setInputValue(this.query); + }, + getHint: function getHint() { + return this.$hint.val(); + }, + setHint: function setHint(value) { + this.$hint.val(value); + }, + clearHint: function clearHint() { + this.setHint(""); + }, + clearHintIfInvalid: function clearHintIfInvalid() { + var val, hint, valIsPrefixOfHint, isValid; + val = this.getInputValue(); + hint = this.getHint(); + valIsPrefixOfHint = val !== hint && hint.indexOf(val) === 0; + isValid = val !== "" && valIsPrefixOfHint && !this.hasOverflow(); + !isValid && this.clearHint(); + }, + hasFocus: function hasFocus() { + return this.$input.is(":focus"); + }, + hasOverflow: function hasOverflow() { + var constraint = this.$input.width() - 2; + this.$overflowHelper.text(this.getInputValue()); + return this.$overflowHelper.width() >= constraint; + }, + isCursorAtEnd: function() { + var valueLength, selectionStart, range; + valueLength = this.$input.val().length; + selectionStart = this.$input[0].selectionStart; + if (_.isNumber(selectionStart)) { + return selectionStart === valueLength; + } else if (document.selection) { + range = document.selection.createRange(); + range.moveStart("character", -valueLength); + return valueLength === range.text.length; + } + return true; + }, + destroy: function destroy() { + this.$hint.off(".tt"); + this.$input.off(".tt"); + this.$overflowHelper.remove(); + this.$hint = this.$input = this.$overflowHelper = $("<div>"); + } + }); + return Input; + function buildOverflowHelper($input) { + return $('<pre aria-hidden="true"></pre>').css({ + position: "absolute", + visibility: "hidden", + whiteSpace: "pre", + fontFamily: $input.css("font-family"), + fontSize: $input.css("font-size"), + fontStyle: $input.css("font-style"), + fontVariant: $input.css("font-variant"), + fontWeight: $input.css("font-weight"), + wordSpacing: $input.css("word-spacing"), + letterSpacing: $input.css("letter-spacing"), + textIndent: $input.css("text-indent"), + textRendering: $input.css("text-rendering"), + textTransform: $input.css("text-transform") + }).insertAfter($input); + } + function areQueriesEquivalent(a, b) { + return Input.normalizeQuery(a) === Input.normalizeQuery(b); + } + function withModifier($e) { + return $e.altKey || $e.ctrlKey || $e.metaKey || $e.shiftKey; + } + }(); + var Dataset = function() { + "use strict"; + var keys, nameGenerator; + keys = { + val: "tt-selectable-display", + obj: "tt-selectable-object" + }; + nameGenerator = _.getIdGenerator(); + function Dataset(o, www) { + o = o || {}; + o.templates = o.templates || {}; + o.templates.notFound = o.templates.notFound || o.templates.empty; + if (!o.source) { + $.error("missing source"); + } + if (!o.node) { + $.error("missing node"); + } + if (o.name && !isValidName(o.name)) { + $.error("invalid dataset name: " + o.name); + } + www.mixin(this); + this.highlight = !!o.highlight; + this.name = o.name || nameGenerator(); + this.limit = o.limit || 5; + this.displayFn = getDisplayFn(o.display || o.displayKey); + this.templates = getTemplates(o.templates, this.displayFn); + this.source = o.source.__ttAdapter ? o.source.__ttAdapter() : o.source; + this.async = _.isUndefined(o.async) ? this.source.length > 2 : !!o.async; + this._resetLastSuggestion(); + this.$el = $(o.node).addClass(this.classes.dataset).addClass(this.classes.dataset + "-" + this.name); + } + Dataset.extractData = function extractData(el) { + var $el = $(el); + if ($el.data(keys.obj)) { + return { + val: $el.data(keys.val) || "", + obj: $el.data(keys.obj) || null + }; + } + return null; + }; + _.mixin(Dataset.prototype, EventEmitter, { + _overwrite: function overwrite(query, suggestions) { + suggestions = suggestions || []; + if (suggestions.length) { + this._renderSuggestions(query, suggestions); + } else if (this.async && this.templates.pending) { + this._renderPending(query); + } else if (!this.async && this.templates.notFound) { + this._renderNotFound(query); + } else { + this._empty(); + } + this.trigger("rendered", this.name, suggestions, false); + }, + _append: function append(query, suggestions) { + suggestions = suggestions || []; + if (suggestions.length && this.$lastSuggestion.length) { + this._appendSuggestions(query, suggestions); + } else if (suggestions.length) { + this._renderSuggestions(query, suggestions); + } else if (!this.$lastSuggestion.length && this.templates.notFound) { + this._renderNotFound(query); + } + this.trigger("rendered", this.name, suggestions, true); + }, + _renderSuggestions: function renderSuggestions(query, suggestions) { + var $fragment; + $fragment = this._getSuggestionsFragment(query, suggestions); + this.$lastSuggestion = $fragment.children().last(); + this.$el.html($fragment).prepend(this._getHeader(query, suggestions)).append(this._getFooter(query, suggestions)); + }, + _appendSuggestions: function appendSuggestions(query, suggestions) { + var $fragment, $lastSuggestion; + $fragment = this._getSuggestionsFragment(query, suggestions); + $lastSuggestion = $fragment.children().last(); + this.$lastSuggestion.after($fragment); + this.$lastSuggestion = $lastSuggestion; + }, + _renderPending: function renderPending(query) { + var template = this.templates.pending; + this._resetLastSuggestion(); + template && this.$el.html(template({ + query: query, + dataset: this.name + })); + }, + _renderNotFound: function renderNotFound(query) { + var template = this.templates.notFound; + this._resetLastSuggestion(); + template && this.$el.html(template({ + query: query, + dataset: this.name + })); + }, + _empty: function empty() { + this.$el.empty(); + this._resetLastSuggestion(); + }, + _getSuggestionsFragment: function getSuggestionsFragment(query, suggestions) { + var that = this, fragment; + fragment = document.createDocumentFragment(); + _.each(suggestions, function getSuggestionNode(suggestion) { + var $el, context; + context = that._injectQuery(query, suggestion); + $el = $(that.templates.suggestion(context)).data(keys.obj, suggestion).data(keys.val, that.displayFn(suggestion)).addClass(that.classes.suggestion + " " + that.classes.selectable); + fragment.appendChild($el[0]); + }); + this.highlight && highlight({ + className: this.classes.highlight, + node: fragment, + pattern: query + }); + return $(fragment); + }, + _getFooter: function getFooter(query, suggestions) { + return this.templates.footer ? this.templates.footer({ + query: query, + suggestions: suggestions, + dataset: this.name + }) : null; + }, + _getHeader: function getHeader(query, suggestions) { + return this.templates.header ? this.templates.header({ + query: query, + suggestions: suggestions, + dataset: this.name + }) : null; + }, + _resetLastSuggestion: function resetLastSuggestion() { + this.$lastSuggestion = $(); + }, + _injectQuery: function injectQuery(query, obj) { + return _.isObject(obj) ? _.mixin({ + _query: query + }, obj) : obj; + }, + update: function update(query) { + var that = this, canceled = false, syncCalled = false, rendered = 0; + this.cancel(); + this.cancel = function cancel() { + canceled = true; + that.cancel = $.noop; + that.async && that.trigger("asyncCanceled", query); + }; + this.source(query, sync, async); + !syncCalled && sync([]); + function sync(suggestions) { + if (syncCalled) { + return; + } + syncCalled = true; + suggestions = (suggestions || []).slice(0, that.limit); + rendered = suggestions.length; + that._overwrite(query, suggestions); + if (rendered < that.limit && that.async) { + that.trigger("asyncRequested", query); + } + } + function async(suggestions) { + suggestions = suggestions || []; + if (!canceled && rendered < that.limit) { + that.cancel = $.noop; + rendered += suggestions.length; + that._append(query, suggestions.slice(0, that.limit - rendered)); + that.async && that.trigger("asyncReceived", query); + } + } + }, + cancel: $.noop, + clear: function clear() { + this._empty(); + this.cancel(); + this.trigger("cleared"); + }, + isEmpty: function isEmpty() { + return this.$el.is(":empty"); + }, + destroy: function destroy() { + this.$el = $("<div>"); + } + }); + return Dataset; + function getDisplayFn(display) { + display = display || _.stringify; + return _.isFunction(display) ? display : displayFn; + function displayFn(obj) { + return obj[display]; + } + } + function getTemplates(templates, displayFn) { + return { + notFound: templates.notFound && _.templatify(templates.notFound), + pending: templates.pending && _.templatify(templates.pending), + header: templates.header && _.templatify(templates.header), + footer: templates.footer && _.templatify(templates.footer), + suggestion: templates.suggestion || suggestionTemplate + }; + function suggestionTemplate(context) { + return $("<div>").text(displayFn(context)); + } + } + function isValidName(str) { + return /^[_a-zA-Z0-9-]+$/.test(str); + } + }(); + var Menu = function() { + "use strict"; + function Menu(o, www) { + var that = this; + o = o || {}; + if (!o.node) { + $.error("node is required"); + } + www.mixin(this); + this.$node = $(o.node); + this.query = null; + this.datasets = _.map(o.datasets, initializeDataset); + function initializeDataset(oDataset) { + var node = that.$node.find(oDataset.node).first(); + oDataset.node = node.length ? node : $("<div>").appendTo(that.$node); + return new Dataset(oDataset, www); + } + } + _.mixin(Menu.prototype, EventEmitter, { + _onSelectableClick: function onSelectableClick($e) { + this.trigger("selectableClicked", $($e.currentTarget)); + }, + _onRendered: function onRendered(type, dataset, suggestions, async) { + this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty()); + this.trigger("datasetRendered", dataset, suggestions, async); + }, + _onCleared: function onCleared() { + this.$node.toggleClass(this.classes.empty, this._allDatasetsEmpty()); + this.trigger("datasetCleared"); + }, + _propagate: function propagate() { + this.trigger.apply(this, arguments); + }, + _allDatasetsEmpty: function allDatasetsEmpty() { + return _.every(this.datasets, isDatasetEmpty); + function isDatasetEmpty(dataset) { + return dataset.isEmpty(); + } + }, + _getSelectables: function getSelectables() { + return this.$node.find(this.selectors.selectable); + }, + _removeCursor: function _removeCursor() { + var $selectable = this.getActiveSelectable(); + $selectable && $selectable.removeClass(this.classes.cursor); + }, + _ensureVisible: function ensureVisible($el) { + var elTop, elBottom, nodeScrollTop, nodeHeight; + elTop = $el.position().top; + elBottom = elTop + $el.outerHeight(true); + nodeScrollTop = this.$node.scrollTop(); + nodeHeight = this.$node.height() + parseInt(this.$node.css("paddingTop"), 10) + parseInt(this.$node.css("paddingBottom"), 10); + if (elTop < 0) { + this.$node.scrollTop(nodeScrollTop + elTop); + } else if (nodeHeight < elBottom) { + this.$node.scrollTop(nodeScrollTop + (elBottom - nodeHeight)); + } + }, + bind: function() { + var that = this, onSelectableClick; + onSelectableClick = _.bind(this._onSelectableClick, this); + this.$node.on("click.tt", this.selectors.selectable, onSelectableClick); + _.each(this.datasets, function(dataset) { + dataset.onSync("asyncRequested", that._propagate, that).onSync("asyncCanceled", that._propagate, that).onSync("asyncReceived", that._propagate, that).onSync("rendered", that._onRendered, that).onSync("cleared", that._onCleared, that); + }); + return this; + }, + isOpen: function isOpen() { + return this.$node.hasClass(this.classes.open); + }, + open: function open() { + this.$node.addClass(this.classes.open); + }, + close: function close() { + this.$node.removeClass(this.classes.open); + this._removeCursor(); + }, + setLanguageDirection: function setLanguageDirection(dir) { + this.$node.attr("dir", dir); + }, + selectableRelativeToCursor: function selectableRelativeToCursor(delta) { + var $selectables, $oldCursor, oldIndex, newIndex; + $oldCursor = this.getActiveSelectable(); + $selectables = this._getSelectables(); + oldIndex = $oldCursor ? $selectables.index($oldCursor) : -1; + newIndex = oldIndex + delta; + newIndex = (newIndex + 1) % ($selectables.length + 1) - 1; + newIndex = newIndex < -1 ? $selectables.length - 1 : newIndex; + return newIndex === -1 ? null : $selectables.eq(newIndex); + }, + setCursor: function setCursor($selectable) { + this._removeCursor(); + if ($selectable = $selectable && $selectable.first()) { + $selectable.addClass(this.classes.cursor); + this._ensureVisible($selectable); + } + }, + getSelectableData: function getSelectableData($el) { + return $el && $el.length ? Dataset.extractData($el) : null; + }, + getActiveSelectable: function getActiveSelectable() { + var $selectable = this._getSelectables().filter(this.selectors.cursor).first(); + return $selectable.length ? $selectable : null; + }, + getTopSelectable: function getTopSelectable() { + var $selectable = this._getSelectables().first(); + return $selectable.length ? $selectable : null; + }, + update: function update(query) { + var isValidUpdate = query !== this.query; + if (isValidUpdate) { + this.query = query; + _.each(this.datasets, updateDataset); + } + return isValidUpdate; + function updateDataset(dataset) { + dataset.update(query); + } + }, + empty: function empty() { + _.each(this.datasets, clearDataset); + this.query = null; + this.$node.addClass(this.classes.empty); + function clearDataset(dataset) { + dataset.clear(); + } + }, + destroy: function destroy() { + this.$node.off(".tt"); + this.$node = $("<div>"); + _.each(this.datasets, destroyDataset); + function destroyDataset(dataset) { + dataset.destroy(); + } + } + }); + return Menu; + }(); + var DefaultMenu = function() { + "use strict"; + var s = Menu.prototype; + function DefaultMenu() { + Menu.apply(this, [].slice.call(arguments, 0)); + } + _.mixin(DefaultMenu.prototype, Menu.prototype, { + open: function open() { + !this._allDatasetsEmpty() && this._show(); + return s.open.apply(this, [].slice.call(arguments, 0)); + }, + close: function close() { + this._hide(); + return s.close.apply(this, [].slice.call(arguments, 0)); + }, + _onRendered: function onRendered() { + if (this._allDatasetsEmpty()) { + this._hide(); + } else { + this.isOpen() && this._show(); + } + return s._onRendered.apply(this, [].slice.call(arguments, 0)); + }, + _onCleared: function onCleared() { + if (this._allDatasetsEmpty()) { + this._hide(); + } else { + this.isOpen() && this._show(); + } + return s._onCleared.apply(this, [].slice.call(arguments, 0)); + }, + setLanguageDirection: function setLanguageDirection(dir) { + this.$node.css(dir === "ltr" ? this.css.ltr : this.css.rtl); + return s.setLanguageDirection.apply(this, [].slice.call(arguments, 0)); + }, + _hide: function hide() { + this.$node.hide(); + }, + _show: function show() { + this.$node.css("display", "block"); + } + }); + return DefaultMenu; + }(); + var Typeahead = function() { + "use strict"; + function Typeahead(o, www) { + var onFocused, onBlurred, onEnterKeyed, onTabKeyed, onEscKeyed, onUpKeyed, onDownKeyed, onLeftKeyed, onRightKeyed, onQueryChanged, onWhitespaceChanged; + o = o || {}; + if (!o.input) { + $.error("missing input"); + } + if (!o.menu) { + $.error("missing menu"); + } + if (!o.eventBus) { + $.error("missing event bus"); + } + www.mixin(this); + this.eventBus = o.eventBus; + this.minLength = _.isNumber(o.minLength) ? o.minLength : 1; + this.input = o.input; + this.menu = o.menu; + this.enabled = true; + this.active = false; + this.input.hasFocus() && this.activate(); + this.dir = this.input.getLangDir(); + this._hacks(); + this.menu.bind().onSync("selectableClicked", this._onSelectableClicked, this).onSync("asyncRequested", this._onAsyncRequested, this).onSync("asyncCanceled", this._onAsyncCanceled, this).onSync("asyncReceived", this._onAsyncReceived, this).onSync("datasetRendered", this._onDatasetRendered, this).onSync("datasetCleared", this._onDatasetCleared, this); + onFocused = c(this, "activate", "open", "_onFocused"); + onBlurred = c(this, "deactivate", "_onBlurred"); + onEnterKeyed = c(this, "isActive", "isOpen", "_onEnterKeyed"); + onTabKeyed = c(this, "isActive", "isOpen", "_onTabKeyed"); + onEscKeyed = c(this, "isActive", "_onEscKeyed"); + onUpKeyed = c(this, "isActive", "open", "_onUpKeyed"); + onDownKeyed = c(this, "isActive", "open", "_onDownKeyed"); + onLeftKeyed = c(this, "isActive", "isOpen", "_onLeftKeyed"); + onRightKeyed = c(this, "isActive", "isOpen", "_onRightKeyed"); + onQueryChanged = c(this, "_openIfActive", "_onQueryChanged"); + onWhitespaceChanged = c(this, "_openIfActive", "_onWhitespaceChanged"); + this.input.bind().onSync("focused", onFocused, this).onSync("blurred", onBlurred, this).onSync("enterKeyed", onEnterKeyed, this).onSync("tabKeyed", onTabKeyed, this).onSync("escKeyed", onEscKeyed, this).onSync("upKeyed", onUpKeyed, this).onSync("downKeyed", onDownKeyed, this).onSync("leftKeyed", onLeftKeyed, this).onSync("rightKeyed", onRightKeyed, this).onSync("queryChanged", onQueryChanged, this).onSync("whitespaceChanged", onWhitespaceChanged, this).onSync("langDirChanged", this._onLangDirChanged, this); + } + _.mixin(Typeahead.prototype, { + _hacks: function hacks() { + var $input, $menu; + $input = this.input.$input || $("<div>"); + $menu = this.menu.$node || $("<div>"); + $input.on("blur.tt", function($e) { + var active, isActive, hasActive; + active = document.activeElement; + isActive = $menu.is(active); + hasActive = $menu.has(active).length > 0; + if (_.isMsie() && (isActive || hasActive)) { + $e.preventDefault(); + $e.stopImmediatePropagation(); + _.defer(function() { + $input.focus(); + }); + } + }); + $menu.on("mousedown.tt", function($e) { + $e.preventDefault(); + }); + }, + _onSelectableClicked: function onSelectableClicked(type, $el) { + this.select($el); + }, + _onDatasetCleared: function onDatasetCleared() { + this._updateHint(); + }, + _onDatasetRendered: function onDatasetRendered(type, dataset, suggestions, async) { + this._updateHint(); + this.eventBus.trigger("render", suggestions, async, dataset); + }, + _onAsyncRequested: function onAsyncRequested(type, dataset, query) { + this.eventBus.trigger("asyncrequest", query, dataset); + }, + _onAsyncCanceled: function onAsyncCanceled(type, dataset, query) { + this.eventBus.trigger("asynccancel", query, dataset); + }, + _onAsyncReceived: function onAsyncReceived(type, dataset, query) { + this.eventBus.trigger("asyncreceive", query, dataset); + }, + _onFocused: function onFocused() { + this._minLengthMet() && this.menu.update(this.input.getQuery()); + }, + _onBlurred: function onBlurred() { + if (this.input.hasQueryChangedSinceLastFocus()) { + this.eventBus.trigger("change", this.input.getQuery()); + } + }, + _onEnterKeyed: function onEnterKeyed(type, $e) { + var $selectable; + if ($selectable = this.menu.getActiveSelectable()) { + this.select($selectable) && $e.preventDefault(); + } + }, + _onTabKeyed: function onTabKeyed(type, $e) { + var $selectable; + if ($selectable = this.menu.getActiveSelectable()) { + this.select($selectable) && $e.preventDefault(); + } else if ($selectable = this.menu.getTopSelectable()) { + this.autocomplete($selectable) && $e.preventDefault(); + } + }, + _onEscKeyed: function onEscKeyed() { + this.close(); + }, + _onUpKeyed: function onUpKeyed() { + this.moveCursor(-1); + }, + _onDownKeyed: function onDownKeyed() { + this.moveCursor(+1); + }, + _onLeftKeyed: function onLeftKeyed() { + if (this.dir === "rtl" && this.input.isCursorAtEnd()) { + this.autocomplete(this.menu.getTopSelectable()); + } + }, + _onRightKeyed: function onRightKeyed() { + if (this.dir === "ltr" && this.input.isCursorAtEnd()) { + this.autocomplete(this.menu.getTopSelectable()); + } + }, + _onQueryChanged: function onQueryChanged(e, query) { + this._minLengthMet(query) ? this.menu.update(query) : this.menu.empty(); + }, + _onWhitespaceChanged: function onWhitespaceChanged() { + this._updateHint(); + }, + _onLangDirChanged: function onLangDirChanged(e, dir) { + if (this.dir !== dir) { + this.dir = dir; + this.menu.setLanguageDirection(dir); + } + }, + _openIfActive: function openIfActive() { + this.isActive() && this.open(); + }, + _minLengthMet: function minLengthMet(query) { + query = _.isString(query) ? query : this.input.getQuery() || ""; + return query.length >= this.minLength; + }, + _updateHint: function updateHint() { + var $selectable, data, val, query, escapedQuery, frontMatchRegEx, match; + $selectable = this.menu.getTopSelectable(); + data = this.menu.getSelectableData($selectable); + val = this.input.getInputValue(); + if (data && !_.isBlankString(val) && !this.input.hasOverflow()) { + query = Input.normalizeQuery(val); + escapedQuery = _.escapeRegExChars(query); + frontMatchRegEx = new RegExp("^(?:" + escapedQuery + ")(.+$)", "i"); + match = frontMatchRegEx.exec(data.val); + match && this.input.setHint(val + match[1]); + } else { + this.input.clearHint(); + } + }, + isEnabled: function isEnabled() { + return this.enabled; + }, + enable: function enable() { + this.enabled = true; + }, + disable: function disable() { + this.enabled = false; + }, + isActive: function isActive() { + return this.active; + }, + activate: function activate() { + if (this.isActive()) { + return true; + } else if (!this.isEnabled() || this.eventBus.before("active")) { + return false; + } else { + this.active = true; + this.eventBus.trigger("active"); + return true; + } + }, + deactivate: function deactivate() { + if (!this.isActive()) { + return true; + } else if (this.eventBus.before("idle")) { + return false; + } else { + this.active = false; + this.close(); + this.eventBus.trigger("idle"); + return true; + } + }, + isOpen: function isOpen() { + return this.menu.isOpen(); + }, + open: function open() { + if (!this.isOpen() && !this.eventBus.before("open")) { + this.menu.open(); + this._updateHint(); + this.eventBus.trigger("open"); + } + return this.isOpen(); + }, + close: function close() { + if (this.isOpen() && !this.eventBus.before("close")) { + this.menu.close(); + this.input.clearHint(); + this.input.resetInputValue(); + this.eventBus.trigger("close"); + } + return !this.isOpen(); + }, + setVal: function setVal(val) { + this.input.setQuery(_.toStr(val)); + }, + getVal: function getVal() { + return this.input.getQuery(); + }, + select: function select($selectable) { + var data = this.menu.getSelectableData($selectable); + if (data && !this.eventBus.before("select", data.obj)) { + this.input.setQuery(data.val, true); + this.eventBus.trigger("select", data.obj); + this.close(); + return true; + } + return false; + }, + autocomplete: function autocomplete($selectable) { + var query, data, isValid; + query = this.input.getQuery(); + data = this.menu.getSelectableData($selectable); + isValid = data && query !== data.val; + if (isValid && !this.eventBus.before("autocomplete", data.obj)) { + this.input.setQuery(data.val); + this.eventBus.trigger("autocomplete", data.obj); + return true; + } + return false; + }, + moveCursor: function moveCursor(delta) { + var query, $candidate, data, payload, cancelMove; + query = this.input.getQuery(); + $candidate = this.menu.selectableRelativeToCursor(delta); + data = this.menu.getSelectableData($candidate); + payload = data ? data.obj : null; + cancelMove = this._minLengthMet() && this.menu.update(query); + if (!cancelMove && !this.eventBus.before("cursorchange", payload)) { + this.menu.setCursor($candidate); + if (data) { + this.input.setInputValue(data.val); + } else { + this.input.resetInputValue(); + this._updateHint(); + } + this.eventBus.trigger("cursorchange", payload); + return true; + } + return false; + }, + destroy: function destroy() { + this.input.destroy(); + this.menu.destroy(); + } + }); + return Typeahead; + function c(ctx) { + var methods = [].slice.call(arguments, 1); + return function() { + var args = [].slice.call(arguments); + _.each(methods, function(method) { + return ctx[method].apply(ctx, args); + }); + }; + } + }(); + (function() { + "use strict"; + var old, keys, methods; + old = $.fn.typeahead; + keys = { + www: "tt-www", + attrs: "tt-attrs", + typeahead: "tt-typeahead" + }; + methods = { + initialize: function initialize(o, datasets) { + var www; + datasets = _.isArray(datasets) ? datasets : [].slice.call(arguments, 1); + o = o || {}; + www = WWW(o.classNames); + return this.each(attach); + function attach() { + var $input, $wrapper, $hint, $menu, defaultHint, defaultMenu, eventBus, input, menu, typeahead, MenuConstructor; + _.each(datasets, function(d) { + d.highlight = !!o.highlight; + }); + $input = $(this); + $wrapper = $(www.html.wrapper); + $hint = $elOrNull(o.hint); + $menu = $elOrNull(o.menu); + defaultHint = o.hint !== false && !$hint; + defaultMenu = o.menu !== false && !$menu; + defaultHint && ($hint = buildHintFromInput($input, www)); + defaultMenu && ($menu = $(www.html.menu).css(www.css.menu)); + $hint && $hint.val(""); + $input = prepInput($input, www); + if (defaultHint || defaultMenu) { + $wrapper.css(www.css.wrapper); + $input.css(defaultHint ? www.css.input : www.css.inputWithNoHint); + $input.wrap($wrapper).parent().prepend(defaultHint ? $hint : null).append(defaultMenu ? $menu : null); + } + MenuConstructor = defaultMenu ? DefaultMenu : Menu; + eventBus = new EventBus({ + el: $input + }); + input = new Input({ + hint: $hint, + input: $input + }, www); + menu = new MenuConstructor({ + node: $menu, + datasets: datasets + }, www); + typeahead = new Typeahead({ + input: input, + menu: menu, + eventBus: eventBus, + minLength: o.minLength + }, www); + $input.data(keys.www, www); + $input.data(keys.typeahead, typeahead); + } + }, + isEnabled: function isEnabled() { + var enabled; + ttEach(this.first(), function(t) { + enabled = t.isEnabled(); + }); + return enabled; + }, + enable: function enable() { + ttEach(this, function(t) { + t.enable(); + }); + return this; + }, + disable: function disable() { + ttEach(this, function(t) { + t.disable(); + }); + return this; + }, + isActive: function isActive() { + var active; + ttEach(this.first(), function(t) { + active = t.isActive(); + }); + return active; + }, + activate: function activate() { + ttEach(this, function(t) { + t.activate(); + }); + return this; + }, + deactivate: function deactivate() { + ttEach(this, function(t) { + t.deactivate(); + }); + return this; + }, + isOpen: function isOpen() { + var open; + ttEach(this.first(), function(t) { + open = t.isOpen(); + }); + return open; + }, + open: function open() { + ttEach(this, function(t) { + t.open(); + }); + return this; + }, + close: function close() { + ttEach(this, function(t) { + t.close(); + }); + return this; + }, + select: function select(el) { + var success = false, $el = $(el); + ttEach(this.first(), function(t) { + success = t.select($el); + }); + return success; + }, + autocomplete: function autocomplete(el) { + var success = false, $el = $(el); + ttEach(this.first(), function(t) { + success = t.autocomplete($el); + }); + return success; + }, + moveCursor: function moveCursoe(delta) { + var success = false; + ttEach(this.first(), function(t) { + success = t.moveCursor(delta); + }); + return success; + }, + val: function val(newVal) { + var query; + if (!arguments.length) { + ttEach(this.first(), function(t) { + query = t.getVal(); + }); + return query; + } else { + ttEach(this, function(t) { + t.setVal(newVal); + }); + return this; + } + }, + destroy: function destroy() { + ttEach(this, function(typeahead, $input) { + revert($input); + typeahead.destroy(); + }); + return this; + } + }; + $.fn.typeahead = function(method) { + if (methods[method]) { + return methods[method].apply(this, [].slice.call(arguments, 1)); + } else { + return methods.initialize.apply(this, arguments); + } + }; + $.fn.typeahead.noConflict = function noConflict() { + $.fn.typeahead = old; + return this; + }; + function ttEach($els, fn) { + $els.each(function() { + var $input = $(this), typeahead; + (typeahead = $input.data(keys.typeahead)) && fn(typeahead, $input); + }); + } + function buildHintFromInput($input, www) { + return $input.clone().addClass(www.classes.hint).removeData().css(www.css.hint).css(getBackgroundStyles($input)).prop("readonly", true).removeAttr("id name placeholder required").attr({ + autocomplete: "off", + spellcheck: "false", + tabindex: -1 + }); + } + function prepInput($input, www) { + $input.data(keys.attrs, { + dir: $input.attr("dir"), + autocomplete: $input.attr("autocomplete"), + spellcheck: $input.attr("spellcheck"), + style: $input.attr("style") + }); + $input.addClass(www.classes.input).attr({ + autocomplete: "off", + spellcheck: false + }); + try { + !$input.attr("dir") && $input.attr("dir", "auto"); + } catch (e) {} + return $input; + } + function getBackgroundStyles($el) { + return { + backgroundAttachment: $el.css("background-attachment"), + backgroundClip: $el.css("background-clip"), + backgroundColor: $el.css("background-color"), + backgroundImage: $el.css("background-image"), + backgroundOrigin: $el.css("background-origin"), + backgroundPosition: $el.css("background-position"), + backgroundRepeat: $el.css("background-repeat"), + backgroundSize: $el.css("background-size") + }; + } + function revert($input) { + var www, $wrapper; + www = $input.data(keys.www); + $wrapper = $input.parent().filter(www.selectors.wrapper); + _.each($input.data(keys.attrs), function(val, key) { + _.isUndefined(val) ? $input.removeAttr(key) : $input.attr(key, val); + }); + $input.removeData(keys.typeahead).removeData(keys.www).removeData(keys.attr).removeClass(www.classes.input); + if ($wrapper.length) { + $input.detach().insertAfter($wrapper); + $wrapper.remove(); + } + } + function $elOrNull(obj) { + var isValid, $el; + isValid = _.isJQuery(obj) || _.isElement(obj); + $el = isValid ? $(obj).first() : []; + return $el.length ? $el : null; + } + })(); +});
\ No newline at end of file diff --git a/app/assets/javascripts/users.js b/app/assets/javascripts/users.js index 70d78fd..3dae526 100644 --- a/app/assets/javascripts/users.js +++ b/app/assets/javascripts/users.js @@ -6,7 +6,7 @@ "error_tag":"span", "wrapper_error_class":"error", "wrapper_tag":"div", - "wrapper_class":"control-group" + "wrapper_class":"form-group" } // diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 9cd3a55..856a559 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,10 +9,8 @@ // // import bootstrap. // +@import "bootstrap-sprockets"; @import "bootstrap"; -@import "bootstrap-responsive"; -// backport bootstrap 3.2 features -@import "backport"; // // LEAP web app specific overrides diff --git a/app/assets/stylesheets/backport.scss b/app/assets/stylesheets/backport.scss deleted file mode 100644 index cadb035..0000000 --- a/app/assets/stylesheets/backport.scss +++ /dev/null @@ -1,24 +0,0 @@ -// -// Backporting styles from bootstrap 3.2 -// - - -// List options - -// Unstyled keeps list items block level, just removes default browser padding and list-style -.list-unstyled { - padding-left: 0; - list-style: none; -} - -// Inline turns list items into inline-block -.list-inline { - @extend .list-unstyled; - margin-left: -5px; - - > li { - display: inline-block; - padding-left: 5px; - padding-right: 5px; - } -} diff --git a/app/assets/stylesheets/leap.scss b/app/assets/stylesheets/leap.scss index abbfc88..8c4d702 100644 --- a/app/assets/stylesheets/leap.scss +++ b/app/assets/stylesheets/leap.scss @@ -57,7 +57,6 @@ [class*="-icon-"] { display: inline-block; - @include ie7-restore-right-whitespace(); vertical-align: middle; background-repeat: no-repeat; margin-top: 1px; @@ -92,8 +91,8 @@ // input.large { - font-size: $baseFontSize * 1.25; - line-height: $baseLineHeight * 1.5; + font-size: $font-size-base * 1.25; + line-height: $line-height-base * 1.5; } .p { @@ -120,9 +119,9 @@ input, textarea { // like a label, but with no background .label-clear { - background-color: $white; + background-color: $body-bg; text-shadow: none; - color: $black; + color: $gray-base; } // force a black icon, even if bootstrap thinks differently @@ -188,7 +187,7 @@ input, textarea { font-weight: bold; } a { - color: $textColor; + color: $text-color; } } @@ -199,7 +198,7 @@ input, textarea { } .download { a.btn { - width: 15em; + width: 18em; font-weight: bold; small { font-weight: normal; @@ -228,9 +227,9 @@ input, textarea { box-shadow: 0 2px 4px rgba(0,0,0,.1); li.active { a, a:hover { - background-color: $linkColor; - color: $white; - border-color: darken($linkColor, 0%); + background-color: $link-color; + color: $body-bg; + border-color: darken($link-color, 0%); cursor: pointer; } } @@ -265,7 +264,7 @@ $footer-height: 100px; $footer-border-width: 1px; $footer-gutter: 20px; // vertical gap above footer $footer-combined: $footer-height + $footer-border-width + $footer-gutter; -$footer-color: $grayLighter !default; +$footer-color: $gray-lighter !default; html, body { height: 100%; @@ -313,7 +312,7 @@ html, body { // border-top: $footer-border-width solid darken($footer-color, 10%); background-color: $footer-color; a { - color: $black; + color: $gray-base; margin: 0 5px; } } diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb new file mode 100644 index 0000000..ee7cca4 --- /dev/null +++ b/app/controllers/account_controller.rb @@ -0,0 +1,17 @@ +class AccountController < ApplicationController + + before_filter :require_registration_allowed + before_filter :redirect_if_logged_in + + def new + @user = User.new + end + + protected + + def require_registration_allowed + unless APP_CONFIG[:allow_registration] + redirect_to home_path + end + end +end diff --git a/app/controllers/v1/certs_controller.rb b/app/controllers/api/certs_controller.rb index ffa6e35..46a84d3 100644 --- a/app/controllers/v1/certs_controller.rb +++ b/app/controllers/api/certs_controller.rb @@ -1,4 +1,4 @@ -class V1::CertsController < ApiController +class Api::CertsController < ApiController before_filter :require_login, :unless => :anonymous_access_allowed? before_filter :require_enabled diff --git a/app/controllers/v1/configs_controller.rb b/app/controllers/api/configs_controller.rb index 4a6f455..0f9b8a6 100644 --- a/app/controllers/v1/configs_controller.rb +++ b/app/controllers/api/configs_controller.rb @@ -1,17 +1,15 @@ -class V1::ConfigsController < ApiController +class Api::ConfigsController < ApiController include ControllerExtension::JsonFile before_filter :require_login, :unless => :anonymous_access_allowed? before_filter :sanitize_id, only: :show - before_filter :lookup_file, only: :show - before_filter :fetch_file, only: :show def index render json: {services: service_paths} end def show - send_file + send_file lookup_file end protected @@ -23,7 +21,11 @@ class V1::ConfigsController < ApiController } def service_paths - Hash[SERVICE_IDS.map{|k,v| [k,"/1/configs/#{v}.json"] } ] + Hash[SERVICE_IDS.map{|k,v| [k,"/#{api_version}/configs/#{v}.json"] } ] + end + + def api_version + ["1", "2"].include?(params[:version]) ? params[:version] : "2" end def sanitize_id @@ -34,6 +36,6 @@ class V1::ConfigsController < ApiController def lookup_file path = APP_CONFIG[:config_file_paths][@id] not_found if path.blank? - @filename = Rails.root.join path + Rails.root.join path end end diff --git a/app/controllers/v1/identities_controller.rb b/app/controllers/api/identities_controller.rb index 4efd1f5..ab2ac00 100644 --- a/app/controllers/v1/identities_controller.rb +++ b/app/controllers/api/identities_controller.rb @@ -1,4 +1,4 @@ -module V1 +module Api class IdentitiesController < ApiController before_filter :token_authenticate before_filter :require_monitor diff --git a/app/controllers/v1/messages_controller.rb b/app/controllers/api/messages_controller.rb index c0ca0c7..a69a40a 100644 --- a/app/controllers/v1/messages_controller.rb +++ b/app/controllers/api/messages_controller.rb @@ -1,4 +1,4 @@ -module V1 +module Api class MessagesController < ApiController before_filter :require_login diff --git a/app/controllers/v1/services_controller.rb b/app/controllers/api/services_controller.rb index 523eb44..da2774b 100644 --- a/app/controllers/v1/services_controller.rb +++ b/app/controllers/api/services_controller.rb @@ -1,4 +1,4 @@ -class V1::ServicesController < ApiController +class Api::ServicesController < ApiController before_filter :require_login, :unless => :anonymous_access_allowed? diff --git a/app/controllers/v1/sessions_controller.rb b/app/controllers/api/sessions_controller.rb index a343d9b..c8deb7a 100644 --- a/app/controllers/v1/sessions_controller.rb +++ b/app/controllers/api/sessions_controller.rb @@ -1,4 +1,4 @@ -module V1 +module Api class SessionsController < ApiController before_filter :require_login, only: :destroy diff --git a/app/controllers/v1/smtp_certs_controller.rb b/app/controllers/api/smtp_certs_controller.rb index 5760645..d9eab7d 100644 --- a/app/controllers/v1/smtp_certs_controller.rb +++ b/app/controllers/api/smtp_certs_controller.rb @@ -1,4 +1,4 @@ -class V1::SmtpCertsController < ApiController +class Api::SmtpCertsController < ApiController before_filter :require_login before_filter :require_email_account diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/api/users_controller.rb index 6640d10..c79a729 100644 --- a/app/controllers/v1/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -1,4 +1,4 @@ -module V1 +module Api class UsersController < ApiController include ControllerExtension::FetchUser @@ -50,8 +50,7 @@ module V1 end def destroy - destroy_identity = current_user.is_monitor? || params[:identities] == "destroy" - @user.account.destroy(destroy_identity) + @user.account.destroy(release_handles) if @user == current_user logout end @@ -60,6 +59,10 @@ module V1 private + def release_handles + current_user.is_monitor? || params[:identities] == "destroy" + end + # tester auth can only create test users. def create_test_account if User::is_test?(params[:user][:login]) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 079dc18..2af2f29 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,7 +5,7 @@ class ApplicationController < ActionController::Base before_filter :no_frame_header before_filter :language_header rescue_from StandardError, :with => :default_error_handler - rescue_from RestClient::Exception, :with => :default_error_handler + rescue_from CouchRest::Exception, :with => :default_error_handler ActiveSupport.run_load_hooks(:application_controller, self) diff --git a/app/controllers/controller_extension/fetch_user.rb b/app/controllers/controller_extension/fetch_user.rb index 97f92fa..632291d 100644 --- a/app/controllers/controller_extension/fetch_user.rb +++ b/app/controllers/controller_extension/fetch_user.rb @@ -22,7 +22,7 @@ module ControllerExtension::FetchUser @user = User.find(params[:user_id] || params[:id]) if current_user.is_admin? || current_user.is_monitor? if @user.nil? - not_found(t(:no_such_thing, :thing => 'user'), users_url) + not_found(t(:no_such_user), users_url) elsif current_user.is_monitor? access_denied unless @user.is_test? end diff --git a/app/controllers/controller_extension/json_file.rb b/app/controllers/controller_extension/json_file.rb index 6be919a..df9cf55 100644 --- a/app/controllers/controller_extension/json_file.rb +++ b/app/controllers/controller_extension/json_file.rb @@ -4,20 +4,25 @@ module ControllerExtension::JsonFile protected - def send_file - if stale?(:last_modified => @file.mtime) - response.content_type = 'application/json' - render :text => @file.read + def send_file(filename) + file = fetch_file(filename) + if file.present? + send_file_or_cache_hit(file) + else + not_found end end - def fetch_file - if File.exists?(@filename) - @file = File.new(@filename) - else - not_found + def send_file_or_cache_hit(file) + if stale?(:last_modified => file.mtime) + response.content_type = 'application/json' + render :text => file.read end end + def fetch_file(filename) + File.new(filename) if File.exist?(filename) + end + end diff --git a/app/controllers/static_config_controller.rb b/app/controllers/static_config_controller.rb index c78e006..46e7cd2 100644 --- a/app/controllers/static_config_controller.rb +++ b/app/controllers/static_config_controller.rb @@ -5,13 +5,9 @@ class StaticConfigController < ActionController::Base include ControllerExtension::JsonFile before_filter :set_minimum_client_version - before_filter :set_filename - before_filter :fetch_file - - PROVIDER_JSON = Rails.root.join('config', 'provider', 'provider.json') def provider - send_file + send_file provider_json end protected @@ -23,7 +19,8 @@ class StaticConfigController < ActionController::Base APP_CONFIG[:minimum_client_version].to_s end - def set_filename - @filename = PROVIDER_JSON + def provider_json + Rails.root.join APP_CONFIG[:config_file_paths]['provider'] end + end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 1404b0e..4d198b9 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -5,11 +5,9 @@ class UsersController < ApplicationController include ControllerExtension::FetchUser - before_filter :require_login, :except => [:new] - before_filter :redirect_if_logged_in, :only => [:new] + before_filter :require_login before_filter :require_admin, :only => [:index, :deactivate, :enable] - before_filter :fetch_user, :only => [:show, :edit, :update, :destroy, :deactivate, :enable] - before_filter :require_registration_allowed, only: :new + before_filter :fetch_user, :only => [:show, :edit, :destroy, :deactivate, :enable] respond_to :html @@ -27,25 +25,12 @@ class UsersController < ApplicationController @users = @users.limit(100) end - def new - @user = User.new - end - def show end def edit end - ## added so updating service level works, but not sure we will actually want this. also not sure that this is place to prevent user from updating own effective service level, but here as placeholder: - def update - @user.update_attributes(params[:user]) unless (!admin? and params[:user][:effective_service_level]) - if @user.valid? - flash[:notice] = I18n.t(:changes_saved) - end - respond_with @user, :location => edit_user_path(@user) - end - def deactivate @user.account.disable flash[:notice] = I18n.t("actions.user_disabled_message", username: @user.username) @@ -73,10 +58,11 @@ class UsersController < ApplicationController protected - def require_registration_allowed - unless APP_CONFIG[:allow_registration] - redirect_to home_path + def user_params + if admin? + params.require(:user).permit(:effective_service_level) + else + params.require(:user).permit(:password, :password_confirmation) end end - end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 6de5e1b..920186d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -19,7 +19,8 @@ module ApplicationHelper # http://twitter.github.io/bootstrap/base-css.html#icons # def icon(name, color=nil) - "<i class=\"icon-#{name} #{color_class(color)}\"></i> ".html_safe + content_tag :span, '', + class: "glyphicon glyphicon-#{name} #{color_class(color)}" end def big_icon(name, color=nil) diff --git a/app/helpers/link_helper.rb b/app/helpers/link_helper.rb index ddb063e..b74e1d7 100644 --- a/app/helpers/link_helper.rb +++ b/app/helpers/link_helper.rb @@ -39,6 +39,10 @@ module LinkHelper def btn(*args, &block) html_options = extract_html_options!(args, &block) type = Array(html_options.delete(:type)) + btn_opts = [:default, :primary, :success, :info, :warning, :danger, :link] + if (type & btn_opts).blank? + type << :default + end type.map! {|t| "btn-#{t}"} html_options[:class] = concat_classes(html_options[:class], 'btn', type) args[0] = t(args[0]) if args[0].is_a?(Symbol) diff --git a/app/helpers/navigation_helper.rb b/app/helpers/navigation_helper.rb index 2639246..1df840c 100644 --- a/app/helpers/navigation_helper.rb +++ b/app/helpers/navigation_helper.rb @@ -66,9 +66,9 @@ module NavigationHelper end def extract_icon!(options) - icon = options.delete(:icon) - if icon.present? - content_tag(:i, '', class: "icon-#{icon}") + name = options.delete(:icon) + if name.present? + icon name else "" end diff --git a/app/models/account.rb b/app/models/account.rb index 7310250..d722caa 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -69,15 +69,13 @@ class Account @user.refresh_identity end - def destroy(destroy_identity=false) + def destroy(release_handles=false) return unless @user if !@user.is_tmp? - if destroy_identity == false - @user.identities.each do |id| + @user.identities.each do |id| + if release_handles == false id.orphan! - end - else - @user.identities.each do |id| + else id.destroy end end diff --git a/app/models/api_monitor_user.rb b/app/models/api_monitor_user.rb new file mode 100644 index 0000000..d0fe411 --- /dev/null +++ b/app/models/api_monitor_user.rb @@ -0,0 +1,11 @@ +# +# A user that has limited admin access, to be used +# for running monitor tests against a live production +# installation. +# +class ApiMonitorUser < ApiUser + def is_monitor? + true + end +end + diff --git a/app/models/api_user.rb b/app/models/api_user.rb index 2efe1cb..c70cccb 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -3,21 +3,10 @@ class ApiUser < AnonymousUser end # -# A user that has limited admin access, to be used -# for running monitor tests against a live production -# installation. -# -class ApiMonitorUser < ApiUser - def is_monitor? - true - end -end - -# # Not yet supported: # #class ApiAdminUser < ApiUser # def is_admin? # true # end -#end
\ No newline at end of file +#end diff --git a/app/models/identity.rb b/app/models/identity.rb index f987e4e..92f8f7a 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -1,3 +1,5 @@ +require 'login_format_validation' +require 'local_email' # # Identity states: # diff --git a/app/models/token.rb b/app/models/token.rb index b398fcb..8ac32b8 100644 --- a/app/models/token.rb +++ b/app/models/token.rb @@ -59,8 +59,8 @@ class Token < CouchRest::Model::Base # So let's make sure we don't crash if they disappeared def destroy_with_rescue destroy_without_rescue - rescue RestClient::ResourceNotFound # do nothing it's gone already - rescue RestClient::Conflict # do nothing - it's been updated - #7670 + rescue CouchRest::NotFound + rescue CouchRest::Conflict # do nothing - it's been updated - #7670 end alias_method_chain :destroy, :rescue diff --git a/app/models/user.rb b/app/models/user.rb index e3246ad..6541305 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,3 +1,6 @@ +require 'login_format_validation' +require 'local_email' + class User < CouchRest::Model::Base include LoginFormatValidation diff --git a/app/views/users/new.html.haml b/app/views/account/new.html.haml index bc0b1af..d40259e 100644 --- a/app/views/users/new.html.haml +++ b/app/views/account/new.html.haml @@ -1,18 +1,18 @@ -# -# This form is handled entirely by javascript -# Please take care when changing element ids. --# --# The form is hidden when no js is available +-# +-# The form is hidden when no js is available -# to prevent submission in the clear. -# - form_options = {url: '/not-used', html: {id: 'new_user', class: user_form_class('form-horizontal'), style: 'display:none'}, validate: true} -.span1 -.span9 +.col-md-1 +.col-md-9 %h2=t :signup .lead=t :signup_info - = render :partial => 'warnings' + = render "sessions/warnings" = simple_form_for(@user, form_options) do |f| = f.input :login, :label => t(:username), :required => false, :input_html => { :id => :srp_username } = f.input :password, :label => t(:password), :required => false, :validate => true, :input_html => { :id => :srp_password } diff --git a/app/views/v1/sessions/new.json.erb b/app/views/api/sessions/new.json.erb index 36154b8..36154b8 100644 --- a/app/views/v1/sessions/new.json.erb +++ b/app/views/api/sessions/new.json.erb diff --git a/app/views/common/_action_buttons.html.haml b/app/views/common/_action_buttons.html.haml index 81ebf67..eb663c0 100644 --- a/app/views/common/_action_buttons.html.haml +++ b/app/views/common/_action_buttons.html.haml @@ -1,14 +1,14 @@ .home-buttons - .row-fluid.second - .login.span4 + .row.second + .login.col-md-4 %span.link= btn icon('ok-sign') + t(:login), login_path %span.info= t(:login_info, default: "") - if APP_CONFIG[:allow_registration] - .signup.span4 + .signup.col-md-4 %span.link= btn icon('user') + t(:signup), signup_path %span.info= t(:signup_info, default: "") - else - .signup.span4 - .help.span4 + .signup.col-md-4 + .help.col-md-4 %span.link= btn icon('question-sign') + t(:get_help), new_ticket_path %span.info= t(:support_info, default: "") diff --git a/app/views/common/_download_button.html.haml b/app/views/common/_download_button.html.haml index 9c26860..1278230 100644 --- a/app/views/common/_download_button.html.haml +++ b/app/views/common/_download_button.html.haml @@ -1,8 +1,8 @@ .home-buttons - .row-fluid.first - .span2 - .download.span8 + .row.first + .col-md-2 + .download.col-md-8 = btn client_download_url, type: [:large, :primary] do = big_icon('download') = t(:download_bitmask) - .span2 + .col-md-2 diff --git a/app/views/common/_home_page_buttons.html.haml b/app/views/common/_home_page_buttons.html.haml index fc6348e..cfe3734 100644 --- a/app/views/common/_home_page_buttons.html.haml +++ b/app/views/common/_home_page_buttons.html.haml @@ -1,6 +1,6 @@ = render 'common/download_button' - if local_assigns[:divider] - .row-fluid - .span12 + .row + .col-md-12 = render local_assigns[:divider] = render 'common/action_buttons' diff --git a/app/views/home/_content.html.haml b/app/views/home/_content.html.haml index d96a1e0..67e4533 100644 --- a/app/views/home/_content.html.haml +++ b/app/views/home/_content.html.haml @@ -1,12 +1,12 @@ -.row-fluid +.row %h1= t(:welcome, :provider => APP_CONFIG[:domain]) .p=t(:welcome_message_html) -.row-fluid +.row = home_page_buttons - if Rails.env == 'development' - .row-fluid + .row %hr %p = link_to "make donation", new_payment_path if APP_CONFIG[:payment].present? diff --git a/app/views/layouts/_content.html.haml b/app/views/layouts/_content.html.haml index d5c2fa3..8e0bfbc 100644 --- a/app/views/layouts/_content.html.haml +++ b/app/views/layouts/_content.html.haml @@ -7,13 +7,13 @@ - else - content = yield -- if @show_navigation - .span2 +- if @show_navigation && @user + .col-md-2 = render 'layouts/navigation' - .span10 + .col-md-10 = render 'layouts/messages' = content - else - .span12 + .col-md-12 = render 'layouts/messages' = content diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 0aeda8b..e242a5f 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -15,13 +15,13 @@ #main .container-fluid - if @show_navigation && !admin? - .row-fluid + .row %h1= t(:user_control_panel) - if logged_in? - .row-fluid - .span12 + .row + .col-md-12 = render 'layouts/header' - .row-fluid + .row = render 'layouts/content' #push -# #push is used for sticky footer in bootstrap 2. remove when upgrading to bootstrap 3 diff --git a/app/views/users/_warnings.html.haml b/app/views/sessions/_warnings.html.haml index baf80a4..baf80a4 100644 --- a/app/views/users/_warnings.html.haml +++ b/app/views/sessions/_warnings.html.haml diff --git a/app/views/sessions/new.html.haml b/app/views/sessions/new.html.haml index bb7e4bd..6695123 100644 --- a/app/views/sessions/new.html.haml +++ b/app/views/sessions/new.html.haml @@ -1,8 +1,8 @@ -.span1 -.span9 +.col-md-1 +.col-md-9 %h2=t :login .lead=t :login_info - = render :partial => 'users/warnings' + = render 'warnings' = simple_form_for [:api, @session], validate: true, html: { id: :new_session, class: 'form-horizontal hidden js-show', style: "display:none;" } do |f| = f.input :login, :required => false, :label => t(:username), :input_html => { :id => :srp_username } = f.input :password, :required => false, :input_html => { :id => :srp_password } diff --git a/app/views/users/_change_password.html.haml b/app/views/users/_change_password.html.haml index 425e3ee..64a4d0a 100644 --- a/app/views/users/_change_password.html.haml +++ b/app/views/users/_change_password.html.haml @@ -15,7 +15,7 @@ = f.input :login, :label => t(:username), :required => false, :input_html => {:id => :srp_username} = f.input :password, :required => false, :validate => true, :input_html => { :id => :srp_password } = f.input :password_confirmation, :required => false, :input_html => { :id => :srp_password_confirmation } - .control-group + .form-group .controls = f.submit t(:save), :class => 'btn btn-primary' diff --git a/app/views/users/_change_pgp_key.html.haml b/app/views/users/_change_pgp_key.html.haml index e465125..af3eb5e 100644 --- a/app/views/users/_change_pgp_key.html.haml +++ b/app/views/users/_change_pgp_key.html.haml @@ -8,6 +8,6 @@ = 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} - .control-group + .form-group .controls = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."} diff --git a/app/views/users/_change_service_level.html.haml b/app/views/users/_change_service_level.html.haml index 42315a2..32ea8c0 100644 --- a/app/views/users/_change_service_level.html.haml +++ b/app/views/users/_change_service_level.html.haml @@ -1,8 +1,13 @@ --# TODO: probably won't want here, but here for now. Also, we will need way to ensure payment if they pick a non-free plan. --# --# SERVICE LEVEL --# -- if APP_CONFIG[:service_levels] +:ruby + # DISABLED! this form points to a route that does not exist. + # It's a draft for implementing service levels. + # TODO: probably won't want here, but here for now. + # We will need way to ensure payment for a non-free plan. + # + # SERVICE LEVEL + # + # +- if APP_CONFIG[:service_levels] && false - form_options = {:html => {:class => user_form_class('form-horizontal'), :id => 'update_service_level', :data => {token: session[:token]}}, :validate => true} = simple_form_for @user, form_options do |f| %legend= t(:service_level) @@ -13,6 +18,6 @@ %p = t(:effective_service_level) = f.select :effective_service_level_code, ServiceLevel.select_options, :selected => @user.effective_service_level.id - .control-group + .form-group .controls = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."} diff --git a/app/views/users/_contact_email.html.haml b/app/views/users/_contact_email.html.haml index ad768b7..1627ff6 100644 --- a/app/views/users/_contact_email.html.haml +++ b/app/views/users/_contact_email.html.haml @@ -4,6 +4,6 @@ = f.input :contact_email, :label => false -# %p= t(:public_key) -# = f.text_area :contact_email_key, {:class => "full-width", :rows => 4} - .control-group + .form-group .controls = f.submit t(:save), :class => 'btn', :data => {"loading-text" => "Saving..."}
\ No newline at end of file diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index da8e467..3bdf952 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -1,6 +1,6 @@ = render 'overview' .container-fluid - .row-fluid + .row %h4 To use bitmask services: = btn client_download_url, type: "primary" do %i.icon-arrow-down.icon-white diff --git a/bin/bundle b/bin/bundle new file mode 100755 index 0000000..66e9889 --- /dev/null +++ b/bin/bundle @@ -0,0 +1,3 @@ +#!/usr/bin/env ruby +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) +load Gem.bin_path('bundler', 'bundle') diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..5191e69 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path('../../config/application', __FILE__) +require_relative '../config/boot' +require 'rails/commands' diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..1724048 --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative '../config/boot' +require 'rake' +Rake.application.run diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..acdb2c1 --- /dev/null +++ b/bin/setup @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +require 'pathname' + +# path to your application root. +APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) + +Dir.chdir APP_ROOT do + # This script is a starting point to setup your application. + # Add necessary setup steps to this file: + + puts "== Installing dependencies ==" + system "gem install bundler --conservative" + system "bundle check || bundle install" + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # system "cp config/database.yml.sample config/database.yml" + # end + + puts "\n== Preparing database ==" + system "bin/rake db:setup" + + puts "\n== Removing old logs and tempfiles ==" + system "rm -f log/*" + system "rm -rf tmp/cache" + + puts "\n== Restarting application server ==" + system "touch tmp/restart.txt" +end diff --git a/config/application.rb b/config/application.rb index b13c7d9..0e00356 100644 --- a/config/application.rb +++ b/config/application.rb @@ -4,20 +4,16 @@ require File.expand_path('../boot', __FILE__) # require "active_record/railtie" require "action_controller/railtie" require "action_mailer/railtie" -require "active_resource/railtie" require "sprockets/railtie" require "rails/test_unit/railtie" -if defined?(Bundler) - # If you precompile assets before deploying to production, use this line - Bundler.require(*Rails.groups(:assets => %w(development test))) - # If you want your assets lazily compiled in production, use this line - # Bundler.require(:default, :assets, Rails.env) -end +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) APP_CONFIG = ["defaults.yml", "config.yml"].inject({}) {|config, file| filepath = File.expand_path(file, File.dirname(__FILE__)) - if File.exists?(filepath) && settings = YAML.load_file(filepath)[Rails.env] + if File.exist?(filepath) && settings = YAML.load_file(filepath)[Rails.env] config.merge(settings) else config @@ -30,16 +26,6 @@ module LeapWeb # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. - # Custom directories with classes and modules you want to be autoloadable. - # config.autoload_paths += %W(#{config.root}/extras) - - # Only load the plugins named here, in the order given (default is alphabetical). - # :all can be used as a placeholder for all plugins not explicitly named. - # config.plugins = [ :exception_notification, :ssl_requirement, :all ] - - # Activate observers that should always be running. - # config.active_record.observers = :cacher, :garbage_collector, :forum_observer - # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. # config.time_zone = 'Central Time (US & Canada)' @@ -48,48 +34,18 @@ module LeapWeb # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] # config.i18n.default_locale = :de - # Configure the default encoding used in templates for Ruby 1.9. - config.encoding = "utf-8" - - # Configure sensitive parameters which will be filtered from the log file. - config.filter_parameters += [:password] - if APP_CONFIG[:logfile].present? config.logger = Logger.new(APP_CONFIG[:logfile]) end - # Enable escaping HTML in JSON. - config.active_support.escape_html_entities_in_json = true - - # Use SQL instead of Active Record's schema dumper when creating the database. - # This is necessary if your schema can't be completely dumped by the schema dumper, - # like if you have constraints or database-specific column types - # config.active_record.schema_format = :sql - - # Enforce whitelist mode for mass assignment. - # This will create an empty whitelist of attributes available for mass-assignment for all models - # in your app. As such, your models will need to explicitly whitelist or blacklist accessible - # parameters by using an attr_accessible or attr_protected declaration. - # config.active_record.whitelist_attributes = true - - ## - ## ASSETS - ## - - # Enable the asset pipeline - config.assets.enabled = true - config.assets.initialize_on_precompile = true # don't change this (see customization.rb) - - # Version of your assets, change this if you want to expire all your assets - config.assets.version = '1.0' - - # Set to false in order to see asset requests in the log - config.quiet_assets = true - ## ## CUSTOMIZATION ## see initializers/customization.rb ## + + # don't change this (see customization.rb) + config.assets.initialize_on_precompile = true + if APP_CONFIG["customization_directory"] custom_view_path = (Pathname.new(APP_CONFIG["customization_directory"]).relative_path_from(Rails.root) + 'views').to_s else diff --git a/config/boot.rb b/config/boot.rb index 4489e58..6b750f0 100644 --- a/config/boot.rb +++ b/config/boot.rb @@ -1,6 +1,3 @@ -require 'rubygems' - -# Set up gems listed in the Gemfile. ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) -require 'bundler/setup' if File.exists?(ENV['BUNDLE_GEMFILE']) +require 'bundler/setup' # Set up gems listed in the Gemfile. diff --git a/config/defaults.yml b/config/defaults.yml index 844adaa..7e2ea58 100644 --- a/config/defaults.yml +++ b/config/defaults.yml @@ -87,6 +87,7 @@ common: &common soledad-service: 'public/1/config/soledad-service.json' eip-service: 'public/1/config/eip-service.json' smtp-service: 'public/1/config/smtp-service.json' + provider: 'config/provider/provider.json' mailer: from_address: 'noreply' diff --git a/config/environment.rb b/config/environment.rb index fe16a54..00a613f 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -1,5 +1,5 @@ -# Load the rails application +# Load the rails application. require File.expand_path('../application', __FILE__) -# Initialize the rails application -LeapWeb::Application.initialize! +# Initialize the rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb index 3c0ff0f..4112c6d 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,38 +1,58 @@ -LeapWeb::Application.configure do - # Settings specified here will take precedence over those in config/application.rb +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + ## + # DEFAULT: + # + # rails default settings in their natural order + ## # In the development environment your application's code is reloaded on # every request. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. config.cache_classes = false - # Log error messages when you accidentally call methods on nil. - config.whiny_nils = true + # Do not eager load code on boot. + config.eager_load = false - # Show full error reports and disable caching + # Show full error reports and disable caching. config.consider_all_requests_local = true config.action_controller.perform_caching = false - # Don't care if the mailer can't send + # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false - # Print deprecation notices to the Rails logger + # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log - # Only use best-standards-support built into browsers - config.action_dispatch.best_standards_support = :builtin + # Debug mode disables concatenation and preprocessing of assets. + # This option may cause significant delays in view rendering with a large + # number of complex assets. + config.assets.debug = true - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation can not be found) - config.i18n.fallbacks = true + # Asset digests allow you to set far-future HTTP expiration dates on all assets, + # yet still be able to expire them through the digest params. + config.assets.digest = true - # Do not compress assets - config.assets.compress = false + # Adds additional error checking when serving assets at runtime. + # Checks for improperly declared sprockets dependencies. + # Raises helpful error messages. + config.assets.raise_runtime_errors = true # If set to true, each asset file is loaded separately. If you are not # debugging js or css, it is much faster to set this to false. config.assets.debug = false + ## + # CUSTOM: + # + # our own settings + ## + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation can not be found) + config.i18n.fallbacks = true + # super hacky, but otherwise getting certificate error, and doesn't seem dangerous in development mode: OpenSSL::SSL.send(:remove_const, "VERIFY_PEER") OpenSSL::SSL.const_set("VERIFY_PEER", OpenSSL::SSL::VERIFY_NONE) diff --git a/config/environments/production.rb b/config/environments/production.rb index 7acca75..acdfc1d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,37 +1,56 @@ -LeapWeb::Application.configure do - # Settings specified here will take precedence over those in config/application.rb +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. - # Code is not reloaded between requests + # Code is not reloaded between requests. config.cache_classes = true - # Full error reports are disabled and caching is turned on + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. config.consider_all_requests_local = false config.action_controller.perform_caching = true - # Disable Rails's static asset server (Apache or nginx will already do this) - config.serve_static_assets = false - # Compress JavaScripts and CSS - config.assets.compress = true + # Dble Rack::Cache to put a simple HTTP cache in front of your application + # Add `rack-cache` to your Gemfile before enabling this. + # For large-scale production use, consider using a caching reverse proxy like + # NGINX, varnish or squid. + # config.action_dispatch.rack_cache = true + + # Disable serving static files from the `/public` folder by default since + # Apache or NGINX already handles this. + config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present? + + # Compress JavaScripts and CSS. + config.assets.js_compressor = :uglifier + # config.assets.css_compressor = :sass - # Don't fallback to assets pipeline if a precompiled asset is missed + #Do not fallback to assets pipeline if a precompiled asset is missed. config.assets.compile = false - # Generate digests for assets URLs + # Asset digests allow you to set far-future HTTP expiration dates on all assets, + # yet still be able to expire them through the digest params. config.assets.digest = true - # Defaults to nil and saved in location specified by config.assets.prefix - # config.assets.manifest = YOUR_PATH + # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb - # Specifies the header that your server uses for sending files - # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache - # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache + # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. config.force_ssl = APP_CONFIG[:force_ssl] - # See everything in the log (default is :info) - # config.log_level = :debug + # Use the lowest log level to ensure availability of diagnostic information + # when problems arise. + config.log_level = :debug + + # Prepend all log lines with the following tags. + # config.log_tags = [ :subdomain, :uuid ] # Use syslog if no file has been specified if APP_CONFIG[:logfile].blank? @@ -39,26 +58,23 @@ LeapWeb::Application.configure do config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new('webapp')) end - # Use a different cache store in production + # Use a different cache store in production. # config.cache_store = :mem_cache_store - # Enable serving of images, stylesheets, and JavaScripts from an asset server + # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.action_controller.asset_host = "http://assets.example.com" - # Precompile additional assets (application.js, application.css, and all non-JS/CSS are already added) - # config.assets.precompile += %w( search.js ) - - # Disable delivery errors, bad email addresses will be ignored + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. # config.action_mailer.raise_delivery_errors = false - # Enable threaded mode - # config.threadsafe! - # Enable locale fallbacks for I18n (makes lookups for any locale fall back to - # the I18n.default_locale when a translation can not be found) + # the I18n.default_locale when a translation cannot be found). config.i18n.fallbacks = true # Send deprecation notices to registered listeners config.active_support.deprecation = :notify + # Use default logging formatter so that PID and timestamp are not suppressed. + config.log_formatter = ::Logger::Formatter.new end diff --git a/config/environments/test.rb b/config/environments/test.rb index 1a38df7..0ba4fbd 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,5 +1,7 @@ -LeapWeb::Application.configure do - # Settings specified here will take precedence over those in config/application.rb +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + config.assets.debug = true # The test environment is used exclusively to run your application's # test suite. You never need to work with it otherwise. Remember that @@ -7,29 +9,36 @@ LeapWeb::Application.configure do # and recreated between test runs. Don't rely on the data there! config.cache_classes = true - # Configure static asset server for tests with Cache-Control for performance - config.serve_static_assets = true - config.static_cache_control = "public, max-age=3600" + # Do not eager load code on boot. This avoids loading your whole application + # just for the purpose of running a single test. If you are using a tool that + # preloads Rails for running tests, you may have to set it to true. + config.eager_load = false - # Log error messages when you accidentally call methods on nil - config.whiny_nils = true + # Configure static asset server for tests with Cache-Control for performance + config.serve_static_files = true + config.static_cache_control = 'public, max-age=3600' - # Show full error reports and disable caching + # Show full error reports and disable caching. config.consider_all_requests_local = true config.action_controller.perform_caching = false - # Raise exceptions instead of rendering exception templates + # Raise exceptions instead of rendering exception templates. config.action_dispatch.show_exceptions = false - # Disable request forgery protection in test environment - config.action_controller.allow_forgery_protection = false + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. config.action_mailer.delivery_method = :test + # Randomize the order test cases are executed. + config.active_support.test_order = :random - # Print deprecation notices to the stderr + # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + + # Raises error for missing translations + # config.action_view.raise_on_missing_translations = true end diff --git a/config/initializers/client_side_validations.rb b/config/initializers/client_side_validations.rb index 252aded..f7a658a 100644 --- a/config/initializers/client_side_validations.rb +++ b/config/initializers/client_side_validations.rb @@ -1,9 +1,16 @@ # ClientSideValidations Initializer -# Uncomment to disable uniqueness validator, possible security issue -ClientSideValidations::Config.disabled_validators = [:uniqueness] +# Disabled validators. The uniqueness validator is disabled by default for security issues. Enable it on your own responsibility! +# ClientSideValidations::Config.disabled_validators = [:uniqueness] + +# Uncomment to validate number format with current I18n locale +# ClientSideValidations::Config.number_format_with_locale = true # Uncomment the following block if you want each input field to have the validation messages attached. +# +# Note: client_side_validation requires the error to be encapsulated within +# <label for="#{instance.send(:tag_id)}" class="message"></label> + ActionView::Base.field_error_proc = Proc.new do |html_tag, instance| unless html_tag =~ /^<label/ %{<div class="field_with_errors">#{html_tag}<label for="#{instance.send(:tag_id)}" class="message">#{instance.error_message.first}</label></div>}.html_safe @@ -11,4 +18,3 @@ ActionView::Base.field_error_proc = Proc.new do |html_tag, instance| %{<div class="field_with_errors">#{html_tag}</div>}.html_safe end end - diff --git a/config/initializers/customization.rb b/config/initializers/customization.rb index aaa2939..9f537e9 100644 --- a/config/initializers/customization.rb +++ b/config/initializers/customization.rb @@ -26,7 +26,7 @@ Rails.application.config.assets.paths.unshift "#{customization_directory}/styles # # Copy files to public # -if !defined?(RAKE) && Dir.exists?("#{customization_directory}/public") +if !defined?(RAKE) && Dir.exist?("#{customization_directory}/public") require 'fileutils' FileUtils.cp_r("#{customization_directory}/public/.", "#{Rails.root}/public", :preserve => true) end diff --git a/config/initializers/error_constants.rb b/config/initializers/error_constants.rb index fdd3624..bf2dad4 100644 --- a/config/initializers/error_constants.rb +++ b/config/initializers/error_constants.rb @@ -1,3 +1,8 @@ require 'ruby-srp' WRONG_PASSWORD = SRP::WrongPassword + +# In case we use a different ORM at some point +VALIDATION_FAILED = CouchRest::Model::Errors::Validations +RECORD_NOT_FOUND = CouchRest::Model::DocumentNotFound +RESOURCE_NOT_FOUDN = CouchRest::NotFound diff --git a/config/initializers/simple_form_bootstrap.rb b/config/initializers/simple_form_bootstrap.rb index c949f5e..720174f 100644 --- a/config/initializers/simple_form_bootstrap.rb +++ b/config/initializers/simple_form_bootstrap.rb @@ -1,17 +1,17 @@ # Use this setup block to configure all options available in SimpleForm. SimpleForm.setup do |config| - config.wrappers :bootstrap, :tag => 'div', :class => 'control-group', :error_class => 'error' do |b| + config.wrappers :bootstrap, :tag => 'div', :class => 'form-group', :error_class => 'error' do |b| b.use :html5 b.use :placeholder - b.use :label - b.wrapper :tag => 'div', :class => 'controls' do |ba| + b.use :label, :class => 'col-sm-2' + b.wrapper :tag => 'div', :class => 'col-sm-10' do |ba| ba.use :input ba.use :error, :wrap_with => { :tag => 'span', :class => 'help-inline' } ba.use :hint, :wrap_with => { :tag => 'p', :class => 'help-block' } end end - config.wrappers :prepend, :tag => 'div', :class => "control-group", :error_class => 'error' do |b| + config.wrappers :prepend, :tag => 'div', :class => "form-group", :error_class => 'error' do |b| b.use :html5 b.use :placeholder b.use :label @@ -24,7 +24,7 @@ SimpleForm.setup do |config| end end - config.wrappers :append, :tag => 'div', :class => "control-group", :error_class => 'error' do |b| + config.wrappers :append, :tag => 'div', :class => "form-group", :error_class => 'error' do |b| b.use :html5 b.use :placeholder b.use :label @@ -38,7 +38,7 @@ SimpleForm.setup do |config| end # - # when you don't want any bootstrap "control-group" or "controls" wrappers. + # when you don't want any bootstrap "form-group" or "controls" wrappers. # config.wrappers :none, :tag => 'div', :error_class => 'error' do |b| b.use :html5 diff --git a/config/initializers/validations.rb b/config/initializers/validations.rb deleted file mode 100644 index e8acfbe..0000000 --- a/config/initializers/validations.rb +++ /dev/null @@ -1,4 +0,0 @@ -# In case we use a different ORM at some point -VALIDATION_FAILED = CouchRest::Model::Errors::Validations -RECORD_NOT_FOUND = CouchRest::Model::DocumentNotFound -RESOURCE_NOT_FOUND = RestClient::ResourceNotFound diff --git a/config/locales/en/generic.en.yml b/config/locales/en/generic.en.yml index be62a40..617116f 100644 --- a/config/locales/en/generic.en.yml +++ b/config/locales/en/generic.en.yml @@ -21,7 +21,7 @@ en: example_email: 'user@domain.org' - no_such_thing: "No such %{thing}." + no_such_user: "No such user." create_thing: "Create %{thing}" overview: "Overview" diff --git a/config/routes.rb b/config/routes.rb index e370aa4..b152c9c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,8 +10,8 @@ LeapWeb::Application.routes.draw do # HTTP Error Handling # instead of the default error pages use the errors controller and views # - match '/404' => 'errors#not_found' - match '/500' => 'errors#server_error' + match '/404' => 'errors#not_found', via: [:get, :post] + match '/500' => 'errors#server_error', via: [:get, :post] scope "(:locale)", :locale => CommonLanguages.match_available, :controller => 'pages', :action => 'show' do get 'privacy-policy', :as => 'privacy_policy' @@ -24,10 +24,10 @@ LeapWeb::Application.routes.draw do get '/provider.json' => 'static_config#provider' - namespace "api", { module: "v1", - path: "/1/", - defaults: {format: 'json'}, - :constraints => { :id => /[^\/]+(?=\.json\z)|[^\/]+/ } + namespace "api", { module: "api", + path: "/:version/", + defaults: {version: '2', format: 'json'}, + :constraints => { :id => /[^\/]+(?=\.json\z)|[^\/]+/, :version => /[12]/ } } do resources :sessions, :only => [:new, :create, :update] delete "logout" => "sessions#destroy", :as => "logout" @@ -44,8 +44,8 @@ LeapWeb::Application.routes.draw do get "login" => "sessions#new", :as => "login" delete "logout" => "sessions#destroy", :as => "logout" - get "signup" => "users#new", :as => "signup" - resources :users, :except => [:create, :update] do + get "signup" => "account#new", :as => "signup" + resources :users, :except => [:new, :create, :update] do # resource :email_settings, :only => [:edit, :update] # resources :email_aliases, :only => [:destroy], :id => /.*/ post 'deactivate', on: :member diff --git a/doc/DEPLOY.md b/doc/DEPLOY.md index 33d5598..4d59701 100644 --- a/doc/DEPLOY.md +++ b/doc/DEPLOY.md @@ -1,5 +1,10 @@ # Deployment # +LEAP Web is provisioned and run as part of the overall [LEAP platform](https://leap.se/en/docs/platform). +We strongly recomment using the whole Platform and following its instructions. +If you want to directly deploy the webapp never the less these instructions are +for you. + These instructions are targeting a Debian GNU/Linux system. You might need to change the commands to match your own needs. @@ -10,9 +15,9 @@ change the commands to match your own needs. The following packages need to be installed: * git -* ruby1.9 -* rubygems1.9 +* ruby (2.1.5) * couchdb (if you want to use a local couch) +* bundler ### Setup Capistrano ### diff --git a/doc/DEVELOP.md b/doc/DEVELOP.md index cdd0867..97ecd25 100644 --- a/doc/DEVELOP.md +++ b/doc/DEVELOP.md @@ -1,5 +1,19 @@ # Development # +## Branches + +We use the 'master' branch to hold the version currently deployed to the +production servers. Only hotfixes are applied here. + +Most of development happens based upon the 'develop' branch. So unless +you are investigating a specific issue that occured in production you +probably want to base your changes on 'develop': +``` +git checkout origin/develop -b my-new-feature +``` +This will create a new branch called 'my-new-feature' based on the develop +branch from the origin remote. + ## Setting up the local CouchDB CouchDB operates in Admin Party by default, meaning there are no access @@ -7,17 +21,24 @@ control checks. This is handy for local development. However, there is the risk that running tests with Couch in Admin Party yields false results. -You have two options: +We recommend keeping the default CouchDB configuration locally and testing +the more complex setup with access control in Continuous Integration. -1) Use Admin Party and accept the risk -2) Stop Admin Party by creating user accounts & security docs by running the -following script: +Please see .travis.yml for the configuration of our CI runs. - test/travis/setup_couch.sh +In order to prepare you local couch for development run +``` +bin/rake db:rotate +bin/rake db:migrate +``` -### Database configuration +### Customized database configuration (advanced) + +If you want to stop Admin Party mode you need to create user accounts & +security docs. You can use the following script as a guideline: + test/travis/setup_couch.sh -Copy & adapt the default database configuration: +Afterwards copy & adapt the default database configuration: ``` mv config/couchdb.example.yml config/couchdb.yml @@ -37,14 +58,14 @@ Some tips on modifying the views: ## Engines ## -Leap Web contains some. They live in their own subdirectory and are included through bundler via their path. This way changes to the engines immediately affect the server as if they were in the main `app` directory. +We use engines to separate optional functionality from the core. They live in their own subdirectory and are included through bundler via their path. This way changes to the engines immediately affect the server as if they were in the main `app` directory. Currently Leap Web includes 2 Engines: * [support](https://github.com/leapcode/leap_web/blob/master/engines/support) - Help ticket management * [billing](https://github.com/leapcode/leap_web/blob/master/engines/billing) - Billing System -## Creating a new engine ## +## Creating a new engine (advanced) ## If you want to add functionality to the webapp but keep it easy to remove you might consider adding an engine. This only makes sense if your engine really is a plugin - so no other pieces of code depend on it. @@ -99,7 +120,7 @@ For example: visit robot_path(@robot, :locale => nil) end -## Debugging +## Debugging Production (advanced) Sometimes bugs only show up when deployed to the live production server. Debugging can be tricky, because the open source mod_passenger does not support debugger. You can't just run diff --git a/doc/TROUBLESHOOT.md b/doc/TROUBLESHOOT.md index f3db006..0e2957d 100644 --- a/doc/TROUBLESHOOT.md +++ b/doc/TROUBLESHOOT.md @@ -13,15 +13,19 @@ Here are some less common issues you might run into when installing Leap Web. Make sure bundler is installed. `gem list bundler` should list `bundler`. You also need to be able to access the `bundler` executable in your PATH. -## Outdated version of rubygems ## +## Incompatible ruby version ## -### Error Messages ### +### Detecting the problem ### +The rubyversion we use for development and testing is noted in the file + + .ruby-version -`bundler requires rubygems >= 1.3.6` +It should match what `ruby --version` prints. ### Solution ### -`gem update --system` will install the latest rubygems +Install the matching ruby version. For some operation systems this may require +the use of rbenv or rvm. ## Missing development tools ## @@ -42,5 +46,7 @@ Some gem dependencies might not compile because they lack the needed c libraries ### Solution ### Install the libraries in question including their development files. +Usually the missing library is mentioned in the error message. Searching the +internet for similar errors is a good starting point aswell. diff --git a/engines/billing/config/routes.rb b/engines/billing/config/routes.rb index 357c55b..12a6778 100644 --- a/engines/billing/config/routes.rb +++ b/engines/billing/config/routes.rb @@ -2,26 +2,23 @@ Rails.application.routes.draw do scope "(:locale)", :locale => CommonLanguages.match_available do - get 'payments/new' => 'payments#new', :as => :new_payment - post 'payments/confirm' => 'payments#confirm', :as => :confirm_payment - # match 'payments/new' => 'payments#new', :as => :new_payment - # match 'payments/confirm' => 'payments#confirm', :as => :confirm_payment - #resources :users do - # resources :payments, :only => [:new, :confirm] - # resources :subscriptions, :only => [:index, :destroy] - #end - resources :subscriptions, :only => [:index, :show] do - member do - post 'subscribe' - delete 'unsubscribe' + get 'payments/new' => 'payments#new', :as => :new_payment + post 'payments/confirm' => 'payments#confirm', :as => :confirm_payment + # match 'payments/new' => 'payments#new', :as => :new_payment + # match 'payments/confirm' => 'payments#confirm', :as => :confirm_payment + #resources :users do + # resources :payments, :only => [:new, :confirm] + # resources :subscriptions, :only => [:index, :destroy] + #end + resources :subscriptions, :only => [:index, :show] do + member do + post 'subscribe' + delete 'unsubscribe' + end end - end - - resources :customer, :only => [:new, :edit] - match 'customer/confirm/' => 'customer#confirm', :as => :confirm_customer - match 'customer/show/:id' => 'customer#show', :as => :show_customer + resources :customer, :only => [:new, :edit] - match 'billing_admin' => 'billing_admin#show', :as => :billing_admin + get 'billing_admin' => 'billing_admin#show' end end diff --git a/engines/support/app/helpers/auto_tickets_path_helper.rb b/engines/support/app/helpers/auto_tickets_path_helper.rb index bc98a0a..17fc003 100644 --- a/engines/support/app/helpers/auto_tickets_path_helper.rb +++ b/engines/support/app/helpers/auto_tickets_path_helper.rb @@ -4,7 +4,8 @@ # (1) include the user in the path if appropriate. # (2) retain the sort params, if appropriate. # -# Tickets views with a user_id are limited to that user. For admins, they don't need a user_id for any ticket action. +# Tickets views with a user_id are limited to that user. +# Admins don't need a user_id for any ticket action. # # This is available both to the views and the tickets_controller. # @@ -13,7 +14,6 @@ module AutoTicketsPathHelper protected def auto_tickets_path(options={}) - return unless options.class == Hash options = ticket_view_options.merge options if @user.is_a? User user_tickets_path(@user, options) @@ -22,9 +22,9 @@ module AutoTicketsPathHelper end end - def auto_ticket_path(ticket, options={}) + def auto_ticket_path(ticket) return unless ticket.persisted? - options = ticket_view_options.merge options + options = ticket_view_options if @user.is_a? User user_ticket_path(@user, ticket, options) else @@ -32,10 +32,9 @@ module AutoTicketsPathHelper end end - def auto_new_ticket_path(options={}) - return unless options.class == Hash - options = ticket_view_options.merge options - if @user + def auto_new_ticket_path + options = ticket_view_options + if @user.is_a? User new_user_ticket_path(@user, options) else new_ticket_path(options) diff --git a/engines/support/app/models/account_extension/tickets.rb b/engines/support/app/models/account_extension/tickets.rb deleted file mode 100644 index f38d5fd..0000000 --- a/engines/support/app/models/account_extension/tickets.rb +++ /dev/null @@ -1,13 +0,0 @@ -module AccountExtension::Tickets - extend ActiveSupport::Concern - - def destroy_with_tickets(destroy_identities=false) - Ticket.destroy_all_from(self.user) - destroy_without_tickets(destroy_identities) - end - - included do - alias_method_chain :destroy, :tickets - end - -end diff --git a/engines/support/app/models/ticket.rb b/engines/support/app/models/ticket.rb index b1bdf8d..025e2ab 100644 --- a/engines/support/app/models/ticket.rb +++ b/engines/support/app/models/ticket.rb @@ -37,9 +37,11 @@ class Ticket < CouchRest::Model::Base # email can be nil, "", or valid address. # validation provided by 'valid_email' gem. + # mx validation depends on network availability and is disabled in test + # and development environment validates :email, :allow_blank => true, :email => true, - :mx_with_fallback => true + :mx_with_fallback => Rails.env.production? def self.search(options = {}) @selection = TicketSelection.new(options) @@ -50,7 +52,7 @@ class Ticket < CouchRest::Model::Base self.by_created_by.key(user.id).each do |ticket| ticket.destroy end - rescue RestClient::ResourceNotFound + rescue RESOURCE_NOT_FOUND # silently ignore if design docs are not yet created end diff --git a/engines/support/app/views/tickets/_edit_form.html.haml b/engines/support/app/views/tickets/_edit_form.html.haml index cd1dbe4..8d64256 100644 --- a/engines/support/app/views/tickets/_edit_form.html.haml +++ b/engines/support/app/views/tickets/_edit_form.html.haml @@ -34,12 +34,12 @@ = simple_form_for @ticket do |f| = hidden_ticket_fields = f.input :subject, input_html: {:class => 'large full-width'} - .row-fluid - .span4 + .row + .col-md-4 = f.input :is_open, as: :select, collection: [:true, :false], include_blank: false - .span4 + .col-md-4 = f.input :email - .span4 + .col-md-4 = f.input :regarding_user, label: (Ticket.human_attribute_name(:regarding_user) + " " + regarding_user_link).html_safe = f.button :loading - if admin? diff --git a/engines/support/app/views/tickets/edit.html.haml b/engines/support/app/views/tickets/edit.html.haml index 03bda7d..cdc5e16 100644 --- a/engines/support/app/views/tickets/edit.html.haml +++ b/engines/support/app/views/tickets/edit.html.haml @@ -1,4 +1,3 @@ -- @show_navigation = params[:user_id].present? - @comment = TicketComment.new .ticket diff --git a/engines/support/app/views/tickets/index.html.haml b/engines/support/app/views/tickets/index.html.haml index d107ce2..56c7012 100644 --- a/engines/support/app/views/tickets/index.html.haml +++ b/engines/support/app/views/tickets/index.html.haml @@ -1,5 +1,3 @@ -- @show_navigation = params[:user_id].present? - = render 'tickets/tabs' = table @tickets, %w(subject created updated voices) = paginate @tickets diff --git a/engines/support/app/views/tickets/new.html.haml b/engines/support/app/views/tickets/new.html.haml index d3580f9..d0b0f89 100644 --- a/engines/support/app/views/tickets/new.html.haml +++ b/engines/support/app/views/tickets/new.html.haml @@ -1,10 +1,5 @@ -- @show_navigation = params[:user_id].present? - = render 'tickets/tabs' -- user = @user if admin? -- user ||= current_user - = simple_form_for @ticket, :validate => true, :html => {:class => 'form-horizontal'} do |f| = hidden_ticket_fields = f.input :subject diff --git a/engines/support/app/views/tickets/show.html.haml b/engines/support/app/views/tickets/show.html.haml index 99afa2a..a625870 100644 --- a/engines/support/app/views/tickets/show.html.haml +++ b/engines/support/app/views/tickets/show.html.haml @@ -1,5 +1,3 @@ -- @show_navigation = params[:user_id].present? - .ticket = render 'tickets/edit_form' = render 'tickets/comments' diff --git a/engines/support/config/initializers/account_lifecycle.rb b/engines/support/config/initializers/account_lifecycle.rb index d9f04c1..9060757 100644 --- a/engines/support/config/initializers/account_lifecycle.rb +++ b/engines/support/config/initializers/account_lifecycle.rb @@ -1,3 +1,5 @@ +require 'account_extension/tickets' + ActiveSupport.on_load(:account) do include AccountExtension::Tickets end diff --git a/engines/support/config/locales/en.yml b/engines/support/config/locales/en.yml index 83af2c4..3b509d5 100644 --- a/engines/support/config/locales/en.yml +++ b/engines/support/config/locales/en.yml @@ -92,7 +92,7 @@ en: # mouse over hints for the given fields hints: ticket: - email: "Provide an email address in order to notified when this ticket is updated." + email: "Provide an email address in order to be notified when this ticket is updated." # these will fallback to translations in "simple_form.hints.defaults" # placeholders inside the fields before anything was typed #placeholders: diff --git a/engines/support/config/routes.rb b/engines/support/config/routes.rb index 81bdf9a..5647477 100644 --- a/engines/support/config/routes.rb +++ b/engines/support/config/routes.rb @@ -3,8 +3,8 @@ Rails.application.routes.draw do resources :tickets, except: :edit do member do - put 'open' - put 'close' + patch 'open' + patch 'close' end end diff --git a/engines/support/lib/account_extension/tickets.rb b/engines/support/lib/account_extension/tickets.rb new file mode 100644 index 0000000..63f4873 --- /dev/null +++ b/engines/support/lib/account_extension/tickets.rb @@ -0,0 +1,15 @@ +module AccountExtension + module Tickets + extend ActiveSupport::Concern + + def destroy_with_tickets(destroy_identities=false) + Ticket.destroy_all_from(self.user) + destroy_without_tickets(destroy_identities) + end + + included do + alias_method_chain :destroy, :tickets + end + + end +end diff --git a/engines/support/test/functional/tickets_controller_test.rb b/engines/support/test/functional/tickets_controller_test.rb index a7a2011..5c2b346 100644 --- a/engines/support/test/functional/tickets_controller_test.rb +++ b/engines/support/test/functional/tickets_controller_test.rb @@ -35,6 +35,12 @@ class TicketsControllerTest < ActionController::TestCase assert_response :success end + test "should get new despite invalid user_id" do + get :new, user_id: :bla + assert_equal Ticket, assigns(:ticket).class + assert_response :success + end + test "unauthenticated tickets are visible" do ticket = find_record :ticket, :created_by => nil get :show, :id => ticket.id diff --git a/engines/support/test/integration/create_ticket_test.rb b/engines/support/test/integration/create_ticket_test.rb index 00f9a6b..6abb3d3 100644 --- a/engines/support/test/integration/create_ticket_test.rb +++ b/engines/support/test/integration/create_ticket_test.rb @@ -29,7 +29,7 @@ class CreateTicketTest < BrowserIntegrationTest fill_in 'Description', with: 'description of the problem goes here' click_on 'Submit Ticket' assert page.has_content?("is invalid") - assert_equal 'invalid data', find_field('Email').value + assert_equal 'invaliddata', find_field('Email').value assert_equal 'some user', find_field('Regarding User').value end diff --git a/engines/support/test/unit/ticket_test.rb b/engines/support/test/unit/ticket_test.rb index 7b5281f..373f06c 100644 --- a/engines/support/test/unit/ticket_test.rb +++ b/engines/support/test/unit/ticket_test.rb @@ -8,12 +8,12 @@ class TicketTest < ActiveSupport::TestCase test "ticket with default attribs is valid" do t = FactoryGirl.build :ticket - assert t.valid? + assert t.valid?, t.errors.full_messages.to_sentence end test "ticket without email is valid" do t = FactoryGirl.build :ticket, email: "" - assert t.valid? + assert t.valid?, t.errors.full_messages.to_sentence end test "ticket validates email format" do @@ -63,7 +63,7 @@ class TicketTest < ActiveSupport::TestCase test "find tickets user commented on" do # clear old tickets just in case - # this will cause RestClient::ResourceNotFound errors if there are multiple copies of the same ticket returned + # this will cause RESOURCE_NOT_FOUND errors if there are multiple copies of the same ticket returned Ticket.by_includes_post_by.key('123').each {|t| t.destroy} # TODO: the by_includes_post_by view is only used for tests. Maybe we should get rid of it and change the test to including ordering? diff --git a/features/1/anonymous.feature b/features/1/anonymous.feature new file mode 100644 index 0000000..73a6d3f --- /dev/null +++ b/features/1/anonymous.feature @@ -0,0 +1,34 @@ +@config +Feature: Anonymous access to EIP + + A provider may choose to allow anonymous access to EIP. + In this case some endpoints that would normally require authentication + will be available without authentication. + + Background: + Given "allow_anonymous_certs" is enabled in the config + And I set headers: + | Accept | application/json | + | Content-Type | application/json | + + Scenario: Fetch configs when anonymous certs are allowed + When I send a GET request to "/1/configs.json" + Then the response status should be "200" + + Scenario: Fetch EIP config when anonymous certs are allowed + Given there is a config for the eip + When I send a GET request to "/1/configs/eip-service.json" + Then the response status should be "200" + + Scenario: Fetch service description + When I send a GET request to "/1/service.json" + Then the response status should be "200" + And the response should be: + """ + { + "name": "anonymous", + "description": "anonymous access to the VPN", + "eip_rate_limit": false + } + """ + diff --git a/features/1/authentication.feature b/features/1/authentication.feature new file mode 100644 index 0000000..52b562f --- /dev/null +++ b/features/1/authentication.feature @@ -0,0 +1,24 @@ +Feature: Authentication + + Authentication is handled with SRP. Once the SRP handshake has been successful a token will be transmitted. This token is used to authenticate further requests. + + In the scenarios MY_AUTH_TOKEN will serve as a placeholder for the actual token received. + + Background: + Given I set headers: + | Accept | application/json | + | Content-Type | application/json | + + Scenario: Submitting a valid token + Given I authenticated + And I set headers: + | Authorization | Token token="MY_AUTH_TOKEN" | + When I send a GET request to "/1/configs.json" + Then the response status should be "200" + + Scenario: Submitting an invalid token + Given I authenticated + And I set headers: + | Authorization | Token token="InvalidToken" | + When I send a GET request to "/1/configs.json" + Then the response status should be "401" diff --git a/features/1/config.feature b/features/1/config.feature new file mode 100644 index 0000000..ff04e9d --- /dev/null +++ b/features/1/config.feature @@ -0,0 +1,58 @@ +Feature: Download Provider Configuration + + The LEAP Provider exposes parts of its configuration through the API. + + This can be used to find out about services offered. The big picture can be retrieved from `/provider.json`. Which is available without authentication (see unauthenticated.feature). + + More detailed settings of the services are available after authentication. You can get a list of the available settings from `/1/configs.json`. + + Background: + Given I authenticated + Given I set headers: + | Accept | application/json | + | Content-Type | application/json | + | Authorization | Token token="MY_AUTH_TOKEN" | + + @tempfile + Scenario: Fetch provider config + Given there is a config for the provider + When I send a GET request to "/provider.json" + Then the response status should be "200" + And the response should be that config + + Scenario: Missing provider config + When I send a GET request to "/provider.json" + Then the response status should be "404" + And the response should have "error" with "not_found" + + Scenario: Fetch list of available configs + When I send a GET request to "/1/configs.json" + Then the response status should be "200" + And the response should be: + """ + { + "services": { + "soledad": "/1/configs/soledad-service.json", + "eip": "/1/configs/eip-service.json", + "smtp": "/1/configs/smtp-service.json" + } + } + """ + + Scenario: Attempt to fetch an invalid config + When I send a GET request to "/1/configs/non-existing.json" + Then the response status should be "403" + + # I am not sure what this test is about, that config is not + # actually missing. + #Scenario: Attempt to fetch a config that is missing on the server + # When I send a GET request to "/1/configs/eip-service.json" + # Then the response status should be "404" + + @tempfile, @config + Scenario: Attempt to fetch the EIP config + Given there is a config for the eip + When I send a GET request to "/1/configs/eip-service.json" + Then the response status should be "200" + And the response should be that config + diff --git a/features/1/service.feature b/features/1/service.feature new file mode 100644 index 0000000..ea49c74 --- /dev/null +++ b/features/1/service.feature @@ -0,0 +1,33 @@ +Feature: Get service description for current user + + The LEAP provider can offer different services and their availability may + depend upon a users service level - so wether they are paying or not. + + The /1/service endpoint allows the client to find out about the services + available to the authenticated user. + + Background: + Given I authenticated + Given I set headers: + | Accept | application/json | + | Content-Type | application/json | + | Authorization | Token token="MY_AUTH_TOKEN" | + + Scenario: Get service settings + When I send a GET request to "/1/service" + Then the response status should be "200" + And the response should be: + """ + { + "name": "free", + "description": "free account, with rate limited VPN", + "eip_rate_limit": true, + "storage": 100, + "services": [ + "eip" + ] + } + """ + + + diff --git a/features/1/unauthenticated.feature b/features/1/unauthenticated.feature new file mode 100644 index 0000000..aea7117 --- /dev/null +++ b/features/1/unauthenticated.feature @@ -0,0 +1,31 @@ +Feature: Unauthenticated API endpoints + + Most of the LEAP Provider API requires authentication. + However there are a few exceptions - mostly prerequisits of authenticating. This feature and the authentication feature document these. + + Background: + Given I set headers: + | Accept | application/json | + | Content-Type | application/json | + + @tempfile + Scenario: Fetch provider config + Given there is a config for the provider + When I send a GET request to "/provider.json" + Then the response status should be "200" + And the response should be that config + + Scenario: Authentication required response + When I send a GET request to "/1/configs" + Then the response status should be "401" + And the response should have "error" with "not_authorized_login" + And the response should have "message" + + Scenario: Authentication required for all other API endpoints (incomplete) + Given I am not logged in + When I send requests to these endpoints: + | GET | /1/configs | + | GET | /1/configs/config_id.json | + | GET | /1/service | + | DELETE | /1/logout | + Then they should require authentication diff --git a/features/anonymous.feature b/features/anonymous.feature index 73a6d3f..d6b3ce2 100644 --- a/features/anonymous.feature +++ b/features/anonymous.feature @@ -5,23 +5,23 @@ Feature: Anonymous access to EIP In this case some endpoints that would normally require authentication will be available without authentication. - Background: + Background: Given "allow_anonymous_certs" is enabled in the config And I set headers: | Accept | application/json | | Content-Type | application/json | Scenario: Fetch configs when anonymous certs are allowed - When I send a GET request to "/1/configs.json" + When I send a GET request to "/2/configs.json" Then the response status should be "200" Scenario: Fetch EIP config when anonymous certs are allowed Given there is a config for the eip - When I send a GET request to "/1/configs/eip-service.json" + When I send a GET request to "/2/configs/eip-service.json" Then the response status should be "200" Scenario: Fetch service description - When I send a GET request to "/1/service.json" + When I send a GET request to "/2/service.json" Then the response status should be "200" And the response should be: """ diff --git a/features/authentication.feature b/features/authentication.feature index 52b562f..806e2b7 100644 --- a/features/authentication.feature +++ b/features/authentication.feature @@ -13,12 +13,12 @@ Feature: Authentication Given I authenticated And I set headers: | Authorization | Token token="MY_AUTH_TOKEN" | - When I send a GET request to "/1/configs.json" + When I send a GET request to "/2/configs.json" Then the response status should be "200" Scenario: Submitting an invalid token Given I authenticated And I set headers: | Authorization | Token token="InvalidToken" | - When I send a GET request to "/1/configs.json" + When I send a GET request to "/2/configs.json" Then the response status should be "401" diff --git a/features/config.feature b/features/config.feature index ff04e9d..bd627de 100644 --- a/features/config.feature +++ b/features/config.feature @@ -4,7 +4,7 @@ Feature: Download Provider Configuration This can be used to find out about services offered. The big picture can be retrieved from `/provider.json`. Which is available without authentication (see unauthenticated.feature). - More detailed settings of the services are available after authentication. You can get a list of the available settings from `/1/configs.json`. + More detailed settings of the services are available after authentication. You can get a list of the available settings from `/2/configs.json`. Background: Given I authenticated @@ -26,33 +26,33 @@ Feature: Download Provider Configuration And the response should have "error" with "not_found" Scenario: Fetch list of available configs - When I send a GET request to "/1/configs.json" + When I send a GET request to "/2/configs.json" Then the response status should be "200" And the response should be: """ { "services": { - "soledad": "/1/configs/soledad-service.json", - "eip": "/1/configs/eip-service.json", - "smtp": "/1/configs/smtp-service.json" + "soledad": "/2/configs/soledad-service.json", + "eip": "/2/configs/eip-service.json", + "smtp": "/2/configs/smtp-service.json" } } """ Scenario: Attempt to fetch an invalid config - When I send a GET request to "/1/configs/non-existing.json" + When I send a GET request to "/2/configs/non-existing.json" Then the response status should be "403" # I am not sure what this test is about, that config is not # actually missing. #Scenario: Attempt to fetch a config that is missing on the server - # When I send a GET request to "/1/configs/eip-service.json" + # When I send a GET request to "/2/configs/eip-service.json" # Then the response status should be "404" @tempfile, @config Scenario: Attempt to fetch the EIP config Given there is a config for the eip - When I send a GET request to "/1/configs/eip-service.json" + When I send a GET request to "/2/configs/eip-service.json" Then the response status should be "200" And the response should be that config diff --git a/features/service.feature b/features/service.feature index ea49c74..6244f6c 100644 --- a/features/service.feature +++ b/features/service.feature @@ -3,7 +3,7 @@ Feature: Get service description for current user The LEAP provider can offer different services and their availability may depend upon a users service level - so wether they are paying or not. - The /1/service endpoint allows the client to find out about the services + The /2/service endpoint allows the client to find out about the services available to the authenticated user. Background: @@ -14,7 +14,7 @@ Feature: Get service description for current user | Authorization | Token token="MY_AUTH_TOKEN" | Scenario: Get service settings - When I send a GET request to "/1/service" + When I send a GET request to "/2/service" Then the response status should be "200" And the response should be: """ diff --git a/features/step_definitions/config_steps.rb b/features/step_definitions/config_steps.rb index 1fc67f5..a635d06 100644 --- a/features/step_definitions/config_steps.rb +++ b/features/step_definitions/config_steps.rb @@ -4,12 +4,9 @@ Given /there is a config for the (.*)$/ do |config| @tempfile = Tempfile.new("#{config}.json") @tempfile.write @dummy_config @tempfile.close - if config == 'provider' - StaticConfigController::PROVIDER_JSON = @tempfile.path - else - @orig_config ||= APP_CONFIG.dup - APP_CONFIG[:config_file_paths].merge! "#{config}-service" => @tempfile.path - end + @orig_config ||= APP_CONFIG.dup + config = "#{config}-service" unless config == 'provider' + APP_CONFIG[:config_file_paths].merge! config => @tempfile.path end # use with @config diff --git a/features/support/hooks.rb b/features/support/hooks.rb index 256e5d8..4ddc77e 100644 --- a/features/support/hooks.rb +++ b/features/support/hooks.rb @@ -13,9 +13,9 @@ end After do |scenario| if scenario.failed? logfile_path = Rails.root + 'tmp' - logfile_path += "#{scenario.title.gsub(/\s/, '_')}.log" + logfile_path += "#{scenario.name.gsub(/\s/, '_')}.log" File.open(logfile_path, 'w') do |test_log| - test_log.puts scenario.title + test_log.puts scenario.name test_log.puts "=========================" test_log.puts `tail log/test.log -n 200` end diff --git a/features/unauthenticated.feature b/features/unauthenticated.feature index aea7117..b4b0f55 100644 --- a/features/unauthenticated.feature +++ b/features/unauthenticated.feature @@ -16,7 +16,7 @@ Feature: Unauthenticated API endpoints And the response should be that config Scenario: Authentication required response - When I send a GET request to "/1/configs" + When I send a GET request to "/2/configs" Then the response status should be "401" And the response should have "error" with "not_authorized_login" And the response should have "message" @@ -24,8 +24,8 @@ Feature: Unauthenticated API endpoints Scenario: Authentication required for all other API endpoints (incomplete) Given I am not logged in When I send requests to these endpoints: - | GET | /1/configs | - | GET | /1/configs/config_id.json | - | GET | /1/service | - | DELETE | /1/logout | + | GET | /2/configs | + | GET | /2/configs/config_id.json | + | GET | /2/service | + | DELETE | /2/logout | Then they should require authentication diff --git a/app/models/email.rb b/lib/email.rb index 4090275..4090275 100644 --- a/app/models/email.rb +++ b/lib/email.rb diff --git a/lib/extensions/couchrest.rb b/lib/extensions/couchrest.rb index cc041e0..4578926 100644 --- a/lib/extensions/couchrest.rb +++ b/lib/extensions/couchrest.rb @@ -69,7 +69,7 @@ module CouchRest def prepare_directory(dir = '') dir = Rails.root + 'tmp' + 'designs' + dir - Dir.mkdir(dir) unless Dir.exists?(dir) + Dir.mkdir(dir) unless Dir.exist?(dir) return dir end @@ -81,7 +81,7 @@ module CouchRest class ModelRailtie config.action_dispatch.rescue_responses.merge!( 'CouchRest::Model::DocumentNotFound' => :not_found, - 'RestClient::ResourceNotFound' => :not_found + 'CouchRest::NotFound' => :not_found ) end end diff --git a/lib/gemfile_tools.rb b/lib/gemfile_tools.rb index dce2448..d1c00dc 100644 --- a/lib/gemfile_tools.rb +++ b/lib/gemfile_tools.rb @@ -68,7 +68,7 @@ def local_config empty_hash.default_proc = proc{|h, k| h.key?(k.to_s) ? h[k.to_s] : nil} ["defaults.yml", "config.yml"].inject(empty_hash.dup) {|config, file| filepath = File.join(File.expand_path("../../config", __FILE__), file) - if File.exists?(filepath) + if File.exist?(filepath) new_config = YAML.load_file(filepath) ['development', 'test','production'].each do |env| config[env] ||= empty_hash.dup @@ -87,7 +87,7 @@ end # or nil if not actually a gem directory # def gem_info(gem_dir) - if Dir.exists?(gem_dir) + if Dir.exist?(gem_dir) gemspec = Dir["#{gem_dir}/*.gemspec"] if gemspec.any? gem_name = File.basename(gemspec.first).sub(/\.gemspec$/,'') diff --git a/app/models/local_email.rb b/lib/local_email.rb index ded7baf..7c592e1 100644 --- a/app/models/local_email.rb +++ b/lib/local_email.rb @@ -1,3 +1,4 @@ +require 'email' class LocalEmail < Email BLACKLIST_FROM_RFC2142 = [ diff --git a/app/models/login_format_validation.rb b/lib/login_format_validation.rb index c1fcf70..c1fcf70 100644 --- a/app/models/login_format_validation.rb +++ b/lib/login_format_validation.rb diff --git a/lib/tasks/i18n.rake b/lib/tasks/i18n.rake index 6ffbb23..1034211 100644 --- a/lib/tasks/i18n.rake +++ b/lib/tasks/i18n.rake @@ -63,7 +63,7 @@ namespace :i18n do desc "pull translations from transifex" task :download do Dir.chdir('config/') do - if !File.exists?('transifex.netrc') + if !File.exist?('transifex.netrc') puts "In order to download translations, you need a config/transifex.netrc file." puts "For example:" puts "machine www.transifex.com login yourusername password yourpassword" diff --git a/lib/tasks/test.rake b/lib/tasks/test.rake index d96b625..9859729 100644 --- a/lib/tasks/test.rake +++ b/lib/tasks/test.rake @@ -1,10 +1,15 @@ namespace :test do - [:units, :functionals, :integration].each do |type| - Rails::SubTestTask.new(type => "test:prepare") do |t| - t.libs << "test" - subdir = type.to_s.singularize - t.pattern = "engines/*/test/#{subdir}/**/*_test.rb" + namespace :engines do + [:units, :functionals, :integration].each do |type| + desc "Test engine #{type}" + Rails::TestTask.new(type => "test:prepare") do |t| + t.libs << "test" + subdir = type.to_s.singularize + t.pattern = "engines/*/test/#{subdir}/**/*_test.rb" + end + Rake::Task["test:#{type}"].enhance ["test:engines:#{type}"] + Rake::Task["test"].enhance ["test:engines:#{type}"] end end diff --git a/test/functional/account_controller_test.rb b/test/functional/account_controller_test.rb new file mode 100644 index 0000000..f5f1446 --- /dev/null +++ b/test/functional/account_controller_test.rb @@ -0,0 +1,26 @@ +require 'test_helper' + +class AccountControllerTest < ActionController::TestCase + + test "should get new" do + get :new + assert_equal User, assigns(:user).class + assert_response :success + end + + test "new should redirect logged in users" do + login + get :new + assert_response :redirect + assert_redirected_to home_path + end + + test "new redirects if registration is closed" do + with_config(allow_registration: false) do + get :new + assert_response :redirect + assert_redirected_to home_path + end + end +end + diff --git a/test/functional/v1/certs_controller_test.rb b/test/functional/api/certs_controller_test.rb index 04c1c86..f23b4c8 100644 --- a/test/functional/v1/certs_controller_test.rb +++ b/test/functional/api/certs_controller_test.rb @@ -1,11 +1,11 @@ -require_relative '../../test_helper' +require 'test_helper' -class V1::CertsControllerTest < ActionController::TestCase +class Api::CertsControllerTest < ApiControllerTest test "create unlimited cert without login" do with_config allow_anonymous_certs: true do cert = expect_cert('UNLIMITED') - post :create + api_post :create assert_response :success assert_equal cert.to_s, @response.body end @@ -15,7 +15,7 @@ class V1::CertsControllerTest < ActionController::TestCase with_config allow_limited_certs: true do login cert = expect_cert('LIMITED') - post :create + api_post :create assert_response :success assert_equal cert.to_s, @response.body end @@ -23,14 +23,14 @@ class V1::CertsControllerTest < ActionController::TestCase test "fail to create cert when disabled" do login :enabled? => false - post :create + api_post :create assert_access_denied end test "create unlimited cert" do login effective_service_level: ServiceLevel.new(id: 2) cert = expect_cert('UNLIMITED') - post :create + api_post :create assert_response :success assert_equal cert.to_s, @response.body end @@ -38,13 +38,13 @@ class V1::CertsControllerTest < ActionController::TestCase test "GET still works as an alias" do login effective_service_level: ServiceLevel.new(id: 2) cert = expect_cert('UNLIMITED') - get :show + api_get :show assert_response :success assert_equal cert.to_s, @response.body end test "redirect if no eip service offered" do - post :create + api_post :create assert_response :redirect end diff --git a/test/functional/v1/identities_controller_test.rb b/test/functional/api/identities_controller_test.rb index 6410c44..57345c8 100644 --- a/test/functional/v1/identities_controller_test.rb +++ b/test/functional/api/identities_controller_test.rb @@ -1,15 +1,15 @@ require_relative '../../test_helper' -class V1::IdentitiesControllerTest < ActionController::TestCase +class Api::IdentitiesControllerTest < ApiControllerTest test "api monitor can fetch identity" do monitor_auth do identity = FactoryGirl.create :identity - get :show, :id => identity.address, :format => 'json' + api_get :show, :id => identity.address, :format => 'json' assert_response :success assert_equal identity, assigns(:identity) - get :show, :id => "blahblahblah", :format => 'json' + api_get :show, :id => "blahblahblah", :format => 'json' assert_response :not_found end end @@ -17,7 +17,7 @@ class V1::IdentitiesControllerTest < ActionController::TestCase test "anonymous cannot fetch identity" do identity = FactoryGirl.create :identity - get :show, :id => identity.address, :format => 'json' + api_get :show, :id => identity.address, :format => 'json' assert_response :forbidden end diff --git a/test/functional/v1/messages_controller_test.rb b/test/functional/api/messages_controller_test.rb index 67f34a1..e586980 100644 --- a/test/functional/v1/messages_controller_test.rb +++ b/test/functional/api/messages_controller_test.rb @@ -1,6 +1,6 @@ require 'test_helper' -class V1::MessagesControllerTest < ActionController::TestCase +class Api::MessagesControllerTest < ApiControllerTest setup do @user = FactoryGirl.build(:user) @@ -13,9 +13,8 @@ class V1::MessagesControllerTest < ActionController::TestCase test "get the motd" do with_config("customization_directory" => Rails.root+'test/files') do login @user - get :index, :locale => 'es' + api_get :index, :locale => 'es' body = JSON.parse(response.body) - p body message1 = "<p>\"This\" is a <strong>very</strong> fine message. <a href=\"https://bitmask.net\">https://bitmask.net</a></p>\n" assert_equal 2, body.size, 'there should be two messages' assert_equal message1, body.first["text"], 'first message text should match files/motd/1.en.md' @@ -25,7 +24,7 @@ class V1::MessagesControllerTest < ActionController::TestCase test "get localized motd" do with_config("customization_directory" => Rails.root+'test/files') do login @user - get :index, :locale => 'de' + api_get :index, :locale => 'de' body = JSON.parse(response.body) message1 = "<p>Dies ist eine sehr feine Nachricht. <a href=\"https://bitmask.net\">https://bitmask.net</a></p>\n" assert_equal message1, body.first["text"], 'first message text should match files/motd/1.de.md' @@ -34,7 +33,7 @@ class V1::MessagesControllerTest < ActionController::TestCase test "get empty motd" do login @user - get :index + api_get :index assert_equal "[]", response.body, "motd response should be empty if no motd directory exists" end @@ -59,7 +58,7 @@ class V1::MessagesControllerTest < ActionController::TestCase test "get messages for user" do login @user - get :index + api_get :index assert response.body.include? @message.text assert response.body.include? @message.id end @@ -79,7 +78,7 @@ class V1::MessagesControllerTest < ActionController::TestCase login @user put :update, :id => @message.id @message.reload - get :index + api_get :index assert !(response.body.include? @message.text) assert !(response.body.include? @message.id) end @@ -92,7 +91,7 @@ class V1::MessagesControllerTest < ActionController::TestCase end test "fails if not authenticated" do - get :index, :format => :json + api_get :index, :format => :json assert_login_required end =end diff --git a/test/functional/v1/services_controller_test.rb b/test/functional/api/services_controller_test.rb index 039eb27..cb85edf 100644 --- a/test/functional/v1/services_controller_test.rb +++ b/test/functional/api/services_controller_test.rb @@ -1,16 +1,16 @@ require 'test_helper' -class V1::ServicesControllerTest < ActionController::TestCase +class Api::ServicesControllerTest < ApiControllerTest test "anonymous user gets login required service info" do - get :show, format: :json + api_get :show, format: :json assert_json_response error: 'not_authorized_login', message: 'Please log in to perform that action.' end test "anonymous user gets vpn service info" do with_config allow_anonymous_certs: true do - get :show, format: :json + api_get :show, format: :json assert_json_response name: 'anonymous', eip_rate_limit: false, description: 'anonymous access to the VPN' @@ -19,7 +19,7 @@ class V1::ServicesControllerTest < ActionController::TestCase test "user can see their service info" do login - get :show, format: :json + api_get :show, format: :json default_level = APP_CONFIG[:default_service_level] assert_json_response APP_CONFIG[:service_levels][default_level] end diff --git a/test/functional/v1/sessions_controller_test.rb b/test/functional/api/sessions_controller_test.rb index 8bb6acd..03a1ef9 100644 --- a/test/functional/v1/sessions_controller_test.rb +++ b/test/functional/api/sessions_controller_test.rb @@ -3,7 +3,7 @@ require 'test_helper' # This is a simple controller unit test. # We're stubbing out both warden and srp. # There's an integration test testing the full rack stack and srp -class V1::SessionsControllerTest < ActionController::TestCase +class Api::SessionsControllerTest < ApiControllerTest setup do @request.env['HTTP_HOST'] = 'api.lvh.me' @@ -12,7 +12,7 @@ class V1::SessionsControllerTest < ActionController::TestCase end test "renders json" do - get :new, :format => :json + api_get :new, :format => :json assert_response :success assert_json_error nil end @@ -22,7 +22,7 @@ class V1::SessionsControllerTest < ActionController::TestCase strategy = stub :message => {:field => :translate_me} request.env['warden'].stubs(:winning_strategy).returns(strategy) I18n.expects(:t).with(:translate_me).at_least_once.returns("translation stub") - get :new, :format => :json + api_get :new, :format => :json assert_response 422 assert_json_error :field => "translation stub" end @@ -33,7 +33,7 @@ class V1::SessionsControllerTest < ActionController::TestCase request.env['warden'].expects(:authenticate!) # make sure we don't get a template missing error: @controller.stubs(:render) - post :create, :login => @user.login, 'A' => @client_hex + api_post :create, :login => @user.login, 'A' => @client_hex end test "should authenticate" do @@ -42,7 +42,7 @@ class V1::SessionsControllerTest < ActionController::TestCase handshake = stub(:to_hash => {h: "ash"}) session[:handshake] = handshake - post :update, :id => @user.login, :client_auth => @client_hex + api_post :update, :id => @user.login, :client_auth => @client_hex assert_nil session[:handshake] assert_response :success @@ -55,7 +55,7 @@ class V1::SessionsControllerTest < ActionController::TestCase test "destroy should logout" do login expect_logout - delete :destroy + api_delete :destroy assert_response 204 end diff --git a/test/functional/v1/smtp_certs_controller_test.rb b/test/functional/api/smtp_certs_controller_test.rb index 1b03995..393f090 100644 --- a/test/functional/v1/smtp_certs_controller_test.rb +++ b/test/functional/api/smtp_certs_controller_test.rb @@ -1,17 +1,17 @@ require 'test_helper' -class V1::SmtpCertsControllerTest < ActionController::TestCase +class Api::SmtpCertsControllerTest < ApiControllerTest test "no smtp cert without login" do with_config allow_anonymous_certs: true do - post :create + api_post :create assert_login_required end end test "require service level with email" do login - post :create + api_post :create assert_access_denied end @@ -19,14 +19,14 @@ class V1::SmtpCertsControllerTest < ActionController::TestCase login effective_service_level: ServiceLevel.new(id: 2) cert = expect_cert(@current_user.email_address) cert.expects(:fingerprint).returns('fingerprint') - post :create + api_post :create assert_response :success assert_equal cert.to_s, @response.body end test "fail to create cert when disabled" do login :enabled? => false - post :create + api_post :create assert_access_denied end diff --git a/test/functional/token_auth_test.rb b/test/functional/api/token_auth_test.rb index 53d5fb3..c7f91c7 100644 --- a/test/functional/token_auth_test.rb +++ b/test/functional/api/token_auth_test.rb @@ -3,15 +3,15 @@ # via static configured tokens. # -require_relative '../test_helper' +require 'test_helper' -class TokenAuthTest < ActionController::TestCase - tests V1::ConfigsController +class Api::TokenAuthTest < ApiControllerTest + tests Api::ConfigsController def test_login_via_api_token with_config(:allow_anonymous_certs => false) do monitor_auth do - get :index + api_get :index assert assigns(:token), 'should have authenticated via api token' assert assigns(:token).is_a? ApiToken assert @controller.send(:current_user).is_a? ApiMonitorUser @@ -26,10 +26,10 @@ class TokenAuthTest < ActionController::TestCase with_config(new_config) do monitor_auth do request.env['REMOTE_ADDR'] = "1.1.1.1" - get :index + api_get :index assert_nil assigns(:token), "should not be able to auth with api token when ip restriction doesn't allow it" request.env['REMOTE_ADDR'] = allowed - get :index + api_get :index assert assigns(:token), "should have authenticated via api token" end end diff --git a/test/functional/v1/users_controller_test.rb b/test/functional/api/users_controller_test.rb index df59c4d..32afd0a 100644 --- a/test/functional/v1/users_controller_test.rb +++ b/test/functional/api/users_controller_test.rb @@ -1,6 +1,6 @@ -require_relative '../../test_helper' +require 'test_helper' -class V1::UsersControllerTest < ActionController::TestCase +class Api::UsersControllerTest < ApiControllerTest test "user can change settings" do user = find_record :user @@ -10,11 +10,11 @@ class V1::UsersControllerTest < ActionController::TestCase Account.expects(:new).with(user).returns(account_settings) login user - put :update, :user => changed_attribs, :id => user.id, :format => :json + api_put :update, :user => changed_attribs, :id => user.id, :format => :json assert_equal user, assigns[:user] assert_response 204 - assert_equal " ", @response.body + assert @response.body.blank?, "Response should be blank" end test "admin can update user" do @@ -25,7 +25,7 @@ class V1::UsersControllerTest < ActionController::TestCase Account.expects(:new).with(user).returns(account_settings) login :is_admin? => true - put :update, :user => changed_attribs, :id => user.id, :format => :json + api_put :update, :user => changed_attribs, :id => user.id, :format => :json assert_equal user, assigns[:user] assert_response 204 @@ -34,7 +34,7 @@ class V1::UsersControllerTest < ActionController::TestCase test "user cannot update other user" do user = find_record :user login - put :update, id: user.id, + api_put :update, id: user.id, user: record_attributes_for(:user_with_settings), :format => :json assert_access_denied @@ -45,7 +45,7 @@ class V1::UsersControllerTest < ActionController::TestCase user = User.new(user_attribs) Account.expects(:create).with(user_attribs).returns(user) - post :create, :user => user_attribs, :format => :json + api_post :create, :user => user_attribs, :format => :json assert_nil session[:user_id] assert_json_response user @@ -59,7 +59,7 @@ class V1::UsersControllerTest < ActionController::TestCase assert !user.valid? Account.expects(:create).with(user_attribs).returns(user) - post :create, :user => user_attribs, :format => :json + api_post :create, :user => user_attribs, :format => :json assert_json_error user.errors.messages assert_response 422 @@ -67,7 +67,7 @@ class V1::UsersControllerTest < ActionController::TestCase test "admin can autocomplete users" do login :is_admin? => true - get :index, :query => 'a', :format => :json + api_get :index, :query => 'a', :format => :json assert_response :success assert assigns(:users) @@ -76,7 +76,7 @@ class V1::UsersControllerTest < ActionController::TestCase test "create returns forbidden if registration is closed" do user_attribs = record_attributes_for :user with_config(allow_registration: false) do - post :create, :user => user_attribs, :format => :json + api_post :create, :user => user_attribs, :format => :json assert_response :forbidden end end @@ -84,20 +84,20 @@ class V1::UsersControllerTest < ActionController::TestCase test "admin can show user" do user = FactoryGirl.create :user login :is_admin? => true - get :show, :id => 0, :login => user.login, :format => :json + api_get :show, :id => 0, :login => user.login, :format => :json assert_response :success assert_json_response user - get :show, :id => user.id, :format => :json + api_get :show, :id => user.id, :format => :json assert_response :success assert_json_response user - get :show, :id => "0", :format => :json + api_get :show, :id => "0", :format => :json assert_response :not_found end test "normal users cannot show user" do user = find_record :user login - get :show, :id => 0, :login => user.login, :format => :json + api_get :show, :id => 0, :login => user.login, :format => :json assert_access_denied end @@ -106,9 +106,9 @@ class V1::UsersControllerTest < ActionController::TestCase with_config(allow_registration: false, invite_required: true) do monitor_auth do user_attribs = record_attributes_for :test_user - post :create, :user => user_attribs, :format => :json + api_post :create, :user => user_attribs, :format => :json assert_response :success - delete :destroy, :id => assigns(:user).id, :format => :json + api_delete :destroy, :id => assigns(:user).id, :format => :json assert_response :success end end @@ -117,17 +117,17 @@ class V1::UsersControllerTest < ActionController::TestCase test "api monitor auth cannot create normal users" do monitor_auth do user_attribs = record_attributes_for :user - post :create, :user => user_attribs, :format => :json + api_post :create, :user => user_attribs, :format => :json assert_response :forbidden end end - test "api monitor auth cannot delete normal users" do - post :create, :user => record_attributes_for(:user), :format => :json + test "api monitor auth cannot api_delete normal users" do + api_post :create, :user => record_attributes_for(:user), :format => :json assert_response :success normal_user_id = assigns(:user).id monitor_auth do - delete :destroy, :id => normal_user_id, :format => :json + api_delete :destroy, :id => normal_user_id, :format => :json assert_response :forbidden end end diff --git a/test/functional/static_config_controller_test.rb b/test/functional/static_config_controller_test.rb index 9c2cfef..7027bf8 100644 --- a/test/functional/static_config_controller_test.rb +++ b/test/functional/static_config_controller_test.rb @@ -1,7 +1,7 @@ require 'test_helper' # use minitest for stubbing, rather than bloated mocha -require 'minitest/stub_const' +require 'minitest/mock' class StaticConfigControllerTest < ActionController::TestCase @@ -9,7 +9,7 @@ class StaticConfigControllerTest < ActionController::TestCase end def test_provider_success - StaticConfigController.stub_const(:PROVIDER_JSON, file_path('provider.json')) do + @controller.stub(:provider_json, file_path('provider.json')) do get :provider, format: :json assert_equal 'application/json', @response.content_type assert_response :success @@ -17,7 +17,7 @@ class StaticConfigControllerTest < ActionController::TestCase end def test_provider_not_modified - StaticConfigController.stub_const(:PROVIDER_JSON, file_path('provider.json')) do + @controller.stub(:provider_json, file_path('provider.json')) do request.env["HTTP_IF_MODIFIED_SINCE"] = File.mtime(file_path('provider.json')).rfc2822() get :provider, format: :json assert_response 304 diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb index 7b24098..2794422 100644 --- a/test/functional/users_controller_test.rb +++ b/test/functional/users_controller_test.rb @@ -1,20 +1,7 @@ -require_relative '../test_helper' +require 'test_helper' class UsersControllerTest < ActionController::TestCase - test "should get new" do - get :new - assert_equal User, assigns(:user).class - assert_response :success - end - - test "new should redirect logged in users" do - login - get :new - assert_response :redirect - assert_redirected_to home_path - end - test "failed show without login" do user = find_record :user get :show, :id => user.id @@ -67,8 +54,8 @@ class UsersControllerTest < ActionController::TestCase nonid = 'thisisnotanexistinguserid' login :is_admin? => true get :show, :id => nonid + assert_error_response :no_such_user assert_response :redirect - assert_equal({:alert => "No such user."}, flash.to_hash) assert_redirected_to users_path end @@ -163,11 +150,4 @@ class UsersControllerTest < ActionController::TestCase assert !assigns(:user).enabled? end - test "new redirects if registration is closed" do - with_config(allow_registration: false) do - get :new - assert_response :redirect - assert_redirected_to home_path - end - end end diff --git a/test/integration/api/cert_test.rb b/test/integration/api/cert_test.rb index 772901d..289d3c6 100644 --- a/test/integration/api/cert_test.rb +++ b/test/integration/api/cert_test.rb @@ -5,7 +5,7 @@ class CertTest < ApiIntegrationTest test "retrieve eip cert" do login - get '/1/cert', {}, RACK_ENV + get cert_url, {}, RACK_ENV assert_text_response assert_response_includes "BEGIN RSA PRIVATE KEY" assert_response_includes "END RSA PRIVATE KEY" @@ -14,13 +14,13 @@ class CertTest < ApiIntegrationTest end test "fetching certs requires login by default" do - get '/1/cert', {}, RACK_ENV + get cert_url, {}, RACK_ENV assert_login_required end test "retrieve anonymous eip cert" do with_config allow_anonymous_certs: true do - get '/1/cert', {}, RACK_ENV + get cert_url, {}, RACK_ENV assert_text_response assert_response_includes "BEGIN RSA PRIVATE KEY" assert_response_includes "END RSA PRIVATE KEY" @@ -28,4 +28,9 @@ class CertTest < ApiIntegrationTest assert_response_includes "END CERTIFICATE" end end + + def cert_url + "/#{api_version}/cert" + end + end diff --git a/test/integration/api/signup_test.rb b/test/integration/api/signup_test.rb index 05a0abe..2e515c1 100644 --- a/test/integration/api/signup_test.rb +++ b/test/integration/api/signup_test.rb @@ -1,4 +1,4 @@ -require_relative '../../test_helper' +require 'test_helper' require_relative 'srp_test' class SignupTest < SrpTest diff --git a/test/integration/api/smtp_cert_test.rb b/test/integration/api/smtp_cert_test.rb index 681d509..53382c1 100644 --- a/test/integration/api/smtp_cert_test.rb +++ b/test/integration/api/smtp_cert_test.rb @@ -11,7 +11,7 @@ class SmtpCertTest < ApiIntegrationTest test "retrieve smtp cert" do @user = FactoryGirl.create :user, effective_service_level_code: 2, :invite_code => @testcode.invite_code login - post '/1/smtp_cert', {}, RACK_ENV + post smtp_cert_url, {}, RACK_ENV assert_text_response assert_response_includes "BEGIN RSA PRIVATE KEY" assert_response_includes "END RSA PRIVATE KEY" @@ -22,7 +22,7 @@ class SmtpCertTest < ApiIntegrationTest test "cert and key" do @user = FactoryGirl.create :user, effective_service_level_code: 2, :invite_code => @testcode.invite_code login - post '/1/smtp_cert', {}, RACK_ENV + post smtp_cert_url, {}, RACK_ENV assert_text_response cert = OpenSSL::X509::Certificate.new(get_response.body) key = OpenSSL::PKey::RSA.new(get_response.body) @@ -34,7 +34,7 @@ class SmtpCertTest < ApiIntegrationTest test "fingerprint is stored with identity" do @user = FactoryGirl.create :user, effective_service_level_code: 2, :invite_code => @testcode.invite_code login - post '/1/smtp_cert', {}, RACK_ENV + post smtp_cert_url, {}, RACK_ENV assert_text_response cert = OpenSSL::X509::Certificate.new(get_response.body) fingerprint = OpenSSL::Digest::SHA1.hexdigest(cert.to_der).scan(/../).join(':') @@ -48,14 +48,18 @@ class SmtpCertTest < ApiIntegrationTest test "fetching smtp certs requires email account" do login - post '/1/smtp_cert', {}, RACK_ENV + post smtp_cert_url, {}, RACK_ENV assert_access_denied end test "no anonymous smtp certs" do with_config allow_anonymous_certs: true do - post '/1/smtp_cert', {}, RACK_ENV + post smtp_cert_url, {}, RACK_ENV assert_login_required end end + + def smtp_cert_url + "/#{api_version}/smtp_cert" + end end diff --git a/test/integration/api/srp_test.rb b/test/integration/api/srp_test.rb index 463abcd..b9605f9 100644 --- a/test/integration/api/srp_test.rb +++ b/test/integration/api/srp_test.rb @@ -14,7 +14,7 @@ class SrpTest < RackTest # this test wraps the api and implements the interface the ruby-srp client. def handshake(login, aa) - post "http://api.lvh.me:3000/1/sessions.json", + post api_url("sessions.json"), :login => login, 'A' => aa, :format => :json @@ -27,7 +27,7 @@ class SrpTest < RackTest end def validate(m) - put "http://api.lvh.me:3000/1/sessions/" + @login + '.json', + put api_url("sessions/#{@login}.json"), :client_auth => m, :format => :json return JSON.parse(last_response.body) @@ -39,7 +39,7 @@ class SrpTest < RackTest def register_user(login = "integration_test", password = 'srp, verify me!', invite_code = @testcode.invite_code) cleanup_user(login) - post 'http://api.lvh.me:3000/1/users.json', + post api_url('users.json'), user_params(login: login, password: password, invite_code: invite_code) assert(@user = User.find_by_login(login), 'user should have been created: %s' % last_response_errors) @login = login @@ -47,7 +47,7 @@ class SrpTest < RackTest end def update_user(params) - put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', + put api_url("users/#{@user.id}.json"), user_params(params), auth_headers end @@ -68,7 +68,7 @@ class SrpTest < RackTest end def logout(params=nil, headers=nil) - delete "http://api.lvh.me:3000/1/logout.json", + delete api_url("logout.json"), params || {format: :json}, headers || auth_headers end @@ -112,4 +112,12 @@ class SrpTest < RackTest rescue "" end + + def api_url(path) + "http://api.lvh.me:3000/#{api_version}/#{path}" + end + + def api_version + 2 + end end diff --git a/test/integration/api/token_test.rb b/test/integration/api/token_auth_test.rb index dafbfb7..7b20b00 100644 --- a/test/integration/api/token_test.rb +++ b/test/integration/api/token_auth_test.rb @@ -1,7 +1,7 @@ -require_relative '../../test_helper' +require 'test_helper' require_relative 'srp_test' -class TokenTest < SrpTest +class TokenAuthTest < SrpTest setup do register_user diff --git a/test/integration/api/update_account_test.rb b/test/integration/api/update_account_test.rb index 16bbb8c..1492006 100644 --- a/test/integration/api/update_account_test.rb +++ b/test/integration/api/update_account_test.rb @@ -14,7 +14,7 @@ class UpdateAccountTest < SrpTest test "require token" do authenticate - put "http://api.lvh.me:3000/1/users/" + @user.id + '.json', + put "http://api.lvh.me:3000/2/users/" + @user.id + '.json', user_params(password: "No! Verify me instead.") assert_login_required end diff --git a/test/integration/browser/account_livecycle_test.rb b/test/integration/browser/account_livecycle_test.rb new file mode 100644 index 0000000..85dbf13 --- /dev/null +++ b/test/integration/browser/account_livecycle_test.rb @@ -0,0 +1,114 @@ +require 'test_helper' + +class AccountLivecycleTest < BrowserIntegrationTest + + teardown do + Identity.destroy_all_orphaned + end + + test "signup successfully when invited" do + username, password = submit_signup + assert page.has_content?("Welcome #{username}") + click_on 'Log Out' + assert page.has_content?("Log In") + assert_equal '/', current_path + assert user = User.find_by_login(username) + user.account.destroy + end + + test "signup successfully without invitation" do + with_config invite_required: false do + + username ||= "test_#{SecureRandom.urlsafe_base64}".downcase + password ||= SecureRandom.base64 + + visit '/signup' + fill_in 'Username', with: username + fill_in 'Password', with: password + fill_in 'Password confirmation', with: password + click_on 'Sign Up' + + assert page.has_content?("Welcome #{username}") + end + end + + test "signup with username ending in dot json" do + username = Faker::Internet.user_name + '.json' + submit_signup username + assert page.has_content?("Welcome #{username}") + end + + test "signup with reserved username" do + username = 'certmaster' + submit_signup username + assert page.has_content?("is reserved.") + end + + test "successful login" do + username, password = submit_signup + click_on 'Log Out' + attempt_login(username, password) + assert page.has_content?("Welcome #{username}") + within('.sidenav li.active') do + assert page.has_content?("Overview") + end + User.find_by_login(username).account.destroy + end + + test "failed login" do + visit '/' + attempt_login("username", "wrong password") + assert_invalid_login(page) + end + + test "account destruction" do + username, password = submit_signup + + click_on I18n.t('account_settings') + click_on I18n.t('destroy_my_account') + assert page.has_content?(I18n.t('account_destroyed')) + assert_equal 1, Identity.by_address.key("#{username}@test.me").count + attempt_login(username, password) + assert_invalid_login(page) + end + + test "handle blocked after account destruction" do + username, password = submit_signup + click_on I18n.t('account_settings') + click_on I18n.t('destroy_my_account') + submit_signup(username) + assert page.has_content?('has already been taken') + end + + test "change pgp key" do + with_config user_actions: ['change_pgp_key'] do + pgp_key = FactoryGirl.build :pgp_key + login + click_on "Account Settings" + within('#update_pgp_key') do + fill_in 'Public key', with: pgp_key + click_on 'Save' + end + 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.to_s + @user.reload + assert_equal pgp_key, @user.public_key + end + end + + def attempt_login(username, password) + click_on 'Log In' + fill_in 'Username', with: username + fill_in 'Password', with: password + click_on 'Log In' + end + + def assert_invalid_login(page) + assert page.has_selector? '.btn-primary.disabled' + assert page.has_content? I18n.t(:invalid_user_pass) + assert page.has_no_selector? '.btn-primary.disabled' + end + +end diff --git a/test/integration/browser/account_test.rb b/test/integration/browser/account_livecycle_test.rb.orig index 50adb23..d1f800b 100644 --- a/test/integration/browser/account_test.rb +++ b/test/integration/browser/account_livecycle_test.rb.orig @@ -1,6 +1,6 @@ require 'test_helper' -class AccountTest < BrowserIntegrationTest +class AccountLivecycleTest < BrowserIntegrationTest teardown do Identity.destroy_all_orphaned @@ -80,24 +80,6 @@ class AccountTest < BrowserIntegrationTest assert page.has_content?('has already been taken') end - test "default user actions" do - login - click_on "Account Settings" - assert page.has_content? I18n.t('destroy_my_account') - assert page.has_no_css? '#update_login_and_password' - assert page.has_no_css? '#update_pgp_key' - end - - test "default admin actions" do - login - with_config admins: [@user.login] do - click_on "Account Settings" - assert page.has_content? I18n.t('destroy_my_account') - assert page.has_no_css? '#update_login_and_password' - assert page.has_css? '#update_pgp_key' - end - end - test "change pgp key" do with_config user_actions: ['change_pgp_key'] do pgp_key = FactoryGirl.build :pgp_key @@ -116,6 +98,8 @@ class AccountTest < BrowserIntegrationTest end end +<<<<<<< HEAD:test/integration/browser/account_livecycle_test.rb +======= # trying to seed an invalid A for srp login test "detects attempt to circumvent SRP" do @@ -133,7 +117,7 @@ class AccountTest < BrowserIntegrationTest end test "reports internal server errors" do - V1::UsersController.any_instance.stubs(:create).raises + Api::UsersController.any_instance.stubs(:create).raises submit_signup assert page.has_content?("server failed") end @@ -152,6 +136,7 @@ class AccountTest < BrowserIntegrationTest assert page.has_no_content?("Password") end +>>>>>>> api: allow version bumping - bump to 2:test/integration/browser/account_test.rb def attempt_login(username, password) click_on 'Log In' fill_in 'Username', with: username @@ -165,12 +150,4 @@ class AccountTest < BrowserIntegrationTest assert page.has_no_selector? '.btn-primary.disabled' end - def inject_malicious_js - page.execute_script <<-EOJS - var calc = new srp.Calculate(); - calc.A = function(_a) {return "00";}; - calc.S = calc.A; - srp.session = new srp.Session(null, calc); - EOJS - end end diff --git a/test/integration/browser/admin_test.rb b/test/integration/browser/admin_test.rb index 902c981..0b43c29 100644 --- a/test/integration/browser/admin_test.rb +++ b/test/integration/browser/admin_test.rb @@ -2,6 +2,24 @@ require 'test_helper' class AdminTest < BrowserIntegrationTest + test "default user actions" do + login + click_on "Account Settings" + assert page.has_content? I18n.t('destroy_my_account') + assert page.has_no_css? '#update_login_and_password' + assert page.has_no_css? '#update_pgp_key' + end + + test "default admin actions" do + login + with_config admins: [@user.login] do + click_on "Account Settings" + assert page.has_content? I18n.t('destroy_my_account') + assert page.has_no_css? '#update_login_and_password' + assert page.has_css? '#update_pgp_key' + end + end + test "clear blocked handle" do id = FactoryGirl.create :identity submit_signup(id.login) diff --git a/test/integration/browser/password_validation_test.rb b/test/integration/browser/password_validation_test.rb index 45eb0bf..51fcc5d 100644 --- a/test/integration/browser/password_validation_test.rb +++ b/test/integration/browser/password_validation_test.rb @@ -5,26 +5,26 @@ class PasswordValidationTest < BrowserIntegrationTest test "password confirmation is validated" do username ||= "test_#{SecureRandom.urlsafe_base64}".downcase password ||= SecureRandom.base64 - visit '/users/new' + visit '/signup' fill_in 'Username', with: username fill_in 'Password', with: password fill_in 'Password confirmation', with: password + "-typo" click_on 'Sign Up' assert page.has_content? "does not match." - assert_equal '/users/new', current_path + assert_equal '/signup', current_path assert page.has_selector? ".error #srp_password_confirmation" end test "password needs to be at least 8 chars long" do username ||= "test_#{SecureRandom.urlsafe_base64}".downcase password ||= SecureRandom.base64[0,7] - visit '/users/new' + visit '/signup' fill_in 'Username', with: username fill_in 'Password', with: password fill_in 'Password confirmation', with: password click_on 'Sign Up' assert page.has_content? "needs to be at least 8 characters long" - assert_equal '/users/new', current_path + assert_equal '/signup', current_path assert page.has_selector? ".error #srp_password" end end diff --git a/test/integration/browser/security_test.rb b/test/integration/browser/security_test.rb new file mode 100644 index 0000000..825d50b --- /dev/null +++ b/test/integration/browser/security_test.rb @@ -0,0 +1,52 @@ +require 'test_helper' + +class SecurityTest < BrowserIntegrationTest + + teardown do + Identity.destroy_all_orphaned + end + + # trying to seed an invalid A for srp login + test "detects attempt to circumvent SRP" do + InviteCodeValidator.any_instance.stubs(:validate) + + user = FactoryGirl.create :user + visit '/login' + fill_in 'Username', with: user.login + fill_in 'Password', with: "password" + inject_malicious_js + click_on 'Log In' + assert page.has_content?("Invalid random key") + assert page.has_no_content?("Welcome") + user.destroy + end + + test "reports internal server errors" do + Api::UsersController.any_instance.stubs(:create).raises + submit_signup + assert page.has_content?("server failed") + end + + test "does not render signup form without js" do + Capybara.current_driver = :rack_test # no js + visit '/signup' + assert page.has_no_content?("Username") + assert page.has_no_content?("Password") + end + + test "does not render login form without js" do + Capybara.current_driver = :rack_test # no js + visit '/login' + assert page.has_no_content?("Username") + assert page.has_no_content?("Password") + end + + def inject_malicious_js + page.execute_script <<-EOJS + var calc = new srp.Calculate(); + calc.A = function(_a) {return "00";}; + calc.S = calc.A; + srp.session = new srp.Session(null, calc); + EOJS + end +end diff --git a/test/integration/locale_path_test.rb b/test/integration/locale_path_test.rb index 738e7f5..22293dc 100644 --- a/test/integration/locale_path_test.rb +++ b/test/integration/locale_path_test.rb @@ -21,6 +21,11 @@ require 'test_helper' # class LocalePathTest < ActionDispatch::IntegrationTest + + teardown do + I18n.locale = 'en' + end + test "redirect if accept-language is not default locale" do get_via_redirect '/', {}, 'HTTP_ACCEPT_LANGUAGE' => 'de' assert_equal '/de', path @@ -55,4 +60,4 @@ class LocalePathTest < ActionDispatch::IntegrationTest @controller.send(:default_url_options) end -end
\ No newline at end of file +end diff --git a/test/integration/navigation_test.rb b/test/integration/navigation_test.rb deleted file mode 100644 index eec8c0e..0000000 --- a/test/integration/navigation_test.rb +++ /dev/null @@ -1,9 +0,0 @@ -require 'test_helper' - -class NavigationTest < ActionDispatch::IntegrationTest - - # test "the truth" do - # assert true - # end -end - diff --git a/test/leap_web_users_test.rb b/test/leap_web_users_test.rb deleted file mode 100644 index f142e54..0000000 --- a/test/leap_web_users_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'test_helper' - -class LeapWebUsersTest < ActiveSupport::TestCase - test "module exists" do - assert_kind_of Module, LeapWebUsers - end -end diff --git a/test/performance/browsing_test.rb b/test/performance/browsing_test.rb deleted file mode 100644 index 3fea27b..0000000 --- a/test/performance/browsing_test.rb +++ /dev/null @@ -1,12 +0,0 @@ -require 'test_helper' -require 'rails/performance_test_help' - -class BrowsingTest < ActionDispatch::PerformanceTest - # Refer to the documentation for all available options - # self.profile_options = { :runs => 5, :metrics => [:wall_time, :memory] - # :output => 'tmp/performance', :formats => [:flat] } - - def test_homepage - get '/' - end -end diff --git a/test/support/api_controller_test.rb b/test/support/api_controller_test.rb new file mode 100644 index 0000000..06cb46a --- /dev/null +++ b/test/support/api_controller_test.rb @@ -0,0 +1,29 @@ +class ApiControllerTest < ActionController::TestCase + + def api_get(*args) + get *add_api_defaults(args) + end + + def api_post(*args) + post *add_api_defaults(args) + end + + def api_delete(*args) + delete *add_api_defaults(args) + end + + def api_put(*args) + put *add_api_defaults(args) + end + + def add_api_defaults(args) + add_defaults args, version: '2' + end + + def add_defaults(args, defaults) + opts = args.extract_options! + opts.reverse_merge! defaults + args << opts + args + end +end diff --git a/test/support/api_integration_test.rb b/test/support/api_integration_test.rb index 3b3481b..cea480c 100644 --- a/test/support/api_integration_test.rb +++ b/test/support/api_integration_test.rb @@ -3,6 +3,10 @@ class ApiIntegrationTest < ActionDispatch::IntegrationTest DUMMY_TOKEN = Token.new RACK_ENV = {'HTTP_AUTHORIZATION' => %Q(Token token="#{DUMMY_TOKEN.to_s}")} + def api_version + 2 + end + setup do @testcode = InviteCode.new @testcode.save! diff --git a/test/support/assert_responses.rb b/test/support/assert_responses.rb index 7724fb4..6a22642 100644 --- a/test/support/assert_responses.rb +++ b/test/support/assert_responses.rb @@ -71,21 +71,24 @@ module AssertResponses end def assert_login_required - assert_error_response :not_authorized_login, :unauthorized + assert_error_response :not_authorized_login, + status: :unauthorized end def assert_access_denied - assert_error_response :not_authorized, :forbidden + assert_error_response :not_authorized, + status: :forbidden end - def assert_error_response(key, status=nil) - message = I18n.t(key) + def assert_error_response(key, options = {}) + status=options.delete :status + message = I18n.t(key, options) if content_type == 'application/json' status ||= :unprocessable_entity assert_json_response('error' => key.to_s, 'message' => message) assert_response status else - assert_equal({:alert => message}, flash.to_hash) + assert_equal({'alert' => message}, flash.to_hash) end end diff --git a/test/support/browser_integration_test.rb b/test/support/browser_integration_test.rb index 1deb8fa..1f5e3d2 100644 --- a/test/support/browser_integration_test.rb +++ b/test/support/browser_integration_test.rb @@ -3,10 +3,11 @@ # # Use this class for capybara based integration tests for the ui. # +require 'capybara/rails' class BrowserIntegrationTest < ActionDispatch::IntegrationTest # let's use dom_id inorder to identify sections - include ActionController::RecordIdentifier + include ActionView::RecordIdentifier CONFIG_RU = (Rails.root + 'config.ru').to_s OUTER_APP = Rack::Builder.parse_file(CONFIG_RU).first @@ -28,7 +29,7 @@ class BrowserIntegrationTest < ActionDispatch::IntegrationTest Capybara.app_host = 'http://lvh.me:3003' Capybara.server_port = 3003 Capybara.javascript_driver = :poltergeist - Capybara.default_wait_time = 5 + Capybara.default_max_wait_time = 5 # Make the Capybara DSL available include Capybara::DSL @@ -46,32 +47,17 @@ class BrowserIntegrationTest < ActionDispatch::IntegrationTest end def submit_signup(username = nil, password = nil) - - with_config invite_required: true do - - username ||= "test_#{SecureRandom.urlsafe_base64}".downcase - password ||= SecureRandom.base64 - visit '/users/new' - fill_in 'Username', with: username - fill_in 'Password', with: password + username ||= "test_#{SecureRandom.urlsafe_base64}".downcase + password ||= SecureRandom.base64 + visit '/signup' + fill_in 'Username', with: username + fill_in 'Password', with: password + if APP_CONFIG[:invite_required] fill_in 'Invite code', with: @testcode.invite_code - fill_in 'Password confirmation', with: password - click_on 'Sign Up' - return username, password - end - - with_config invite_required: false do - - username ||= "test_#{SecureRandom.urlsafe_base64}".downcase - password ||= SecureRandom.base64 - visit '/users/new' - fill_in 'Username', with: username - fill_in 'Password', with: password - fill_in 'Password confirmation', with: password - click_on 'Sign Up' - return username, password end - + fill_in 'Password confirmation', with: password + click_on 'Sign Up' + return username, password end # currently this only works for tests with poltergeist. @@ -101,7 +87,7 @@ class BrowserIntegrationTest < ActionDispatch::IntegrationTest File.open(logfile_path, 'w') do |test_log| test_log.puts self.class.name test_log.puts "=========================" - test_log.puts __name__ + test_log.puts name test_log.puts Time.now test_log.puts current_path test_log.puts page.status_code diff --git a/test/support/record_assertions.rb b/test/support/record_assertions.rb new file mode 100644 index 0000000..30b947f --- /dev/null +++ b/test/support/record_assertions.rb @@ -0,0 +1,10 @@ +module RecordAssertions + + def assert_error(record, options) + options.each do |k, v| + errors = record.errors[k] + assert_equal I18n.t("errors.messages.#{v}"), errors.first + end + end + +end diff --git a/test/test_helper.rb b/test/test_helper.rb index dfc6627..a06f710 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,5 +1,5 @@ ENV["RAILS_ENV"] = "test" -require File.expand_path('../../config/environment', __FILE__) +require_relative '../config/environment' require 'rails/test_help' require 'mocha/setup' @@ -16,11 +16,11 @@ class ActiveSupport::TestCase protected def logfile_path - Rails.root + 'tmp' + "#{self.class.name.underscore}.#{__name__}.log" + Rails.root + 'tmp' + "#{self.class.name.underscore}.#{name}.log" end def screenshot_path - Rails.root + 'tmp' + "#{self.class.name.underscore}.#{__name__}.png" + Rails.root + 'tmp' + "#{self.class.name.underscore}.#{name}.png" end def file_path(name) diff --git a/test/unit/email_test.rb b/test/unit/email_test.rb index e858bd5..739b43e 100644 --- a/test/unit/email_test.rb +++ b/test/unit/email_test.rb @@ -1,4 +1,5 @@ require 'test_helper' +require 'email' class EmailTest < ActiveSupport::TestCase diff --git a/test/unit/identity_test.rb b/test/unit/identity_test.rb index 9d4bc90..e9173af 100644 --- a/test/unit/identity_test.rb +++ b/test/unit/identity_test.rb @@ -2,6 +2,7 @@ require_relative '../test_helper' class IdentityTest < ActiveSupport::TestCase include StubRecordHelper + include RecordAssertions setup do @user = find_record :user @@ -22,7 +23,7 @@ class IdentityTest < ActiveSupport::TestCase test "enabled identity requires destination" do @id = Identity.new user: @user, address: @user.email_address assert !@id.valid? - assert_equal ["can't be blank"], @id.errors[:destination] + assert_error @id, destination: :blank end test "disabled identity requires no destination" do @@ -62,7 +63,7 @@ class IdentityTest < ActiveSupport::TestCase @id = Identity.create_for @user, address: alias_name, destination: forward_address dup = Identity.build_for @user, address: alias_name, destination: forward_address assert !dup.valid? - assert_equal ["has already been taken"], dup.errors[:destination] + assert_error dup, destination: :taken end test "validates availability" do @@ -70,7 +71,7 @@ class IdentityTest < ActiveSupport::TestCase @id = Identity.create_for @user, address: alias_name, destination: forward_address taken = Identity.build_for other_user, address: alias_name assert !taken.valid? - assert_equal ["has already been taken"], taken.errors[:address] + assert_error taken, address: :taken end test "setting and getting pgp key" do @@ -133,7 +134,7 @@ class IdentityTest < ActiveSupport::TestCase other_user = find_record :user taken = Identity.build_for other_user, address: @id.address assert !taken.valid? - assert_equal ["has already been taken"], taken.errors[:address] + assert_error taken, address: :taken end test "destroy all orphaned identities" do diff --git a/test/unit/invite_code_validator_test.rb b/test/unit/invite_code_validator_test.rb index 62eeae6..934ba2e 100644 --- a/test/unit/invite_code_validator_test.rb +++ b/test/unit/invite_code_validator_test.rb @@ -3,9 +3,9 @@ require 'test_helper' class InviteCodeValidatorTest < ActiveSupport::TestCase test "user should not be created with invalid invite code" do with_config invite_required: true do - invalid_user = FactoryGirl.build(:user) + invalid_user = FactoryGirl.build(:user) - assert !invalid_user.valid? + assert !invalid_user.valid? end end @@ -30,7 +30,7 @@ class InviteCodeValidatorTest < ActiveSupport::TestCase test "Invite count >= invite max uses is not accepted for new account signup" do - validator = InviteCodeValidator.new nil + validator = InviteCodeValidator.new user_code = InviteCode.new user_code.invite_count = 1 @@ -46,7 +46,7 @@ class InviteCodeValidatorTest < ActiveSupport::TestCase end test "Invite count < invite max uses is accepted for new account signup" do - validator = InviteCodeValidator.new nil + validator = InviteCodeValidator.new user_code = InviteCode.create user_code.save @@ -60,7 +60,7 @@ class InviteCodeValidatorTest < ActiveSupport::TestCase end test "Invite count 0 is accepted for new account signup" do - validator = InviteCodeValidator.new nil + validator = InviteCodeValidator.new user_code = InviteCode.create @@ -73,7 +73,7 @@ class InviteCodeValidatorTest < ActiveSupport::TestCase end test "There is an error message if the invite code does not exist" do - validator = InviteCodeValidator.new nil + validator = InviteCodeValidator.new user = FactoryGirl.build :user user.invite_code = "wrongcode" @@ -83,4 +83,4 @@ class InviteCodeValidatorTest < ActiveSupport::TestCase assert_equal ["This is not a valid code"], user.errors[:invite_code] end -end
\ No newline at end of file +end diff --git a/test/unit/tmp_user_test.rb b/test/unit/temporary_user_test.rb index 1dea5f9..38ccd67 100644 --- a/test/unit/tmp_user_test.rb +++ b/test/unit/temporary_user_test.rb @@ -1,6 +1,6 @@ require 'test_helper' -class TmpUserTest < ActiveSupport::TestCase +class TemporaryUserTest < ActiveSupport::TestCase setup do InviteCodeValidator.any_instance.stubs(:validate) |