├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── VSPL-LICENSE.txt ├── app ├── assets │ ├── images │ │ ├── .keep │ │ └── spinner.gif │ ├── javascripts │ │ ├── application.js │ │ ├── sparkler.js │ │ └── utils.js │ └── stylesheets │ │ ├── application.css │ │ └── style.css.scss ├── controllers │ ├── application_controller.rb │ ├── feeds_controller.rb │ ├── statistics_controller.rb │ └── user_controller.rb ├── helpers │ └── application_helper.rb ├── models │ ├── .keep │ ├── application_record.rb │ ├── feed.rb │ ├── feed_report.rb │ ├── option.rb │ ├── property.rb │ ├── statistic.rb │ ├── statistic_saver.rb │ └── user.rb └── views │ ├── feeds │ ├── _feed.html.erb │ ├── _form.html.erb │ ├── edit.html.erb │ ├── index.html.erb │ └── new.html.erb │ ├── layouts │ └── application.html.erb │ ├── statistics │ └── index.html.erb │ └── user │ ├── edit.html.erb │ └── login_form.html.erb ├── bin ├── bundle ├── rails ├── rake ├── setup ├── spring └── update ├── config.ru ├── config ├── application.rb ├── boot.rb ├── database.yml.example ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── application_controller_renderer.rb │ ├── assets.rb │ ├── backtrace_silencers.rb │ ├── cookies_serializer.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── new_framework_defaults_5_1.rb │ ├── report_types.rb │ ├── session_store.rb │ └── wrap_parameters.rb ├── routes.rb ├── secrets.yml └── spring.rb ├── db ├── migrate │ ├── 001_initial_tables.rb │ ├── 20150411125726_create_users.rb │ ├── 20150411205844_add_settings_to_feeds.rb │ ├── 20150412193930_rename_values_to_options.rb │ ├── 20150418165101_add_contents_to_feed.rb │ ├── 20150418181426_separate_statistics_per_day.rb │ ├── 20150419110659_add_inactive_to_feeds.rb │ └── 20160520173336_add_unique_indexes.rb ├── schema.rb └── seeds.rb ├── deploy └── cap │ ├── Capfile │ ├── Gemfile.deploy │ ├── Gemfile.deploy.lock │ ├── deploy.rb │ ├── install │ └── production.rb ├── lib └── tasks │ └── .keep ├── public ├── 404.html ├── 422.html ├── 500.html ├── favicon.ico └── robots.txt ├── spec ├── controllers │ ├── feeds_controller_spec.rb │ └── statistics_controller_spec.rb ├── fixtures │ └── feeds.yml ├── models │ ├── feed_report_spec.rb │ ├── feed_spec.rb │ ├── statistic_saver_spec.rb │ └── user_spec.rb ├── rails_helper.rb └── spec_helper.rb └── vendor └── assets ├── javascripts └── Chart.js └── stylesheets ├── .keep └── normalize.css /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle 2 | .byebug_history 3 | .DS_Store 4 | Capfile 5 | Gemfile.deploy* 6 | config/database.yml 7 | config/deploy.rb 8 | config/deploy 9 | config/*.key 10 | deploy/bin 11 | log 12 | tmp 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3 4 | - 2.4 5 | - 2.5 6 | bundler_args: --jobs=3 --retry=2 --deployment --without development production 7 | before_script: 8 | - cp config/database.yml.example config/database.yml 9 | - mysql -e 'CREATE DATABASE sparkler_test;' 10 | script: bundle exec rspec 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Version 1.2 (2017-08-08) 2 | 3 | Important changes: 4 | 5 | * updated Rails to version 5.1 6 | * Ruby 2.2.2 or newer is required 7 | * feed can be reloaded by calling an API endpoint, authenticating with an `X-Reload-Key` HTTP header (from the [CocoaPods fork](https://github.com/CocoaPods/sparkler/pull/3)) 8 | 9 | Other changes: 10 | 11 | - feed items that specify version using an alternate `` tag are properly parsed ([#4](https://github.com/mackuba/sparkler/issues/4)) 12 | - feed parser always finds the version with the highest version number, even if it's not the first item on the list (from the [CocoaPods fork](https://github.com/CocoaPods/sparkler/pull/5)) 13 | - charts will now include a full range of months from the first to the last recorded data point, including months with no data ([#3](https://github.com/mackuba/sparkler/issues/3)) 14 | - renamed "OS X Version" chart to "macOS Version" and "Mac Model" to "Popular Mac Models" 15 | - showing feed source URL on the index page 16 | - larger fonts on the statistics page 17 | - added signatures of some new Macs to the list 18 | - updated all gem versions 19 | 20 | --- 21 | 22 | ### Version 1.1 (2016-05-28) 23 | 24 | Important changes: 25 | 26 | * Capistrano has been updated to 3.0 (which is backwards incompatible) and is now an optional component that needs to be installed separately - check the Capistrano section in the README and the files in `deploy/cap` 27 | * Ruby 2.0 or newer is now required 28 | * there are now unique indexes in the "options" and "properties" table - there is a possibility that a migration will fail if you have duplicate records, in that case you'll have to fix the issue manually 29 | 30 | Other changes: 31 | 32 | - updated gem versions 33 | - added signatures of some new Macs to the list (for the "Mac Model" charts) 34 | 35 | --- 36 | 37 | ### Version 1.0.1 (2015-06-16) 38 | 39 | - updated various gems because of security issues (Rails, Rack, Web Console, Sprockets) 40 | 41 | --- 42 | 43 | ### Version 1.0 (2015-05-07) 44 | 45 | - initial release 46 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 5.1.6', '>= 5.1.6.1' 4 | gem 'mysql2' 5 | 6 | gem 'sass-rails', '~> 5.0' 7 | gem 'uglifier', '>= 1.3.0' 8 | gem 'nokogiri' 9 | gem 'bcrypt' 10 | gem 'open_uri_redirections' 11 | 12 | group :development do 13 | # Access an IRB console on exception pages or by using <%= console %> in views 14 | gem 'web-console' 15 | 16 | # Spring speeds up development by keeping your application running in the background 17 | gem 'spring' 18 | 19 | # Watches filesystem for changes and automatically reloads only edited files 20 | gem 'listen' 21 | end 22 | 23 | group :development, :test do 24 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 25 | gem 'byebug' 26 | end 27 | 28 | group :test do 29 | gem 'rails-controller-testing' 30 | gem 'rspec', '~> 3.6' 31 | gem 'rspec-rails', '~> 3.6' 32 | gem 'webmock', '~> 3.0' 33 | end 34 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (5.1.6.1) 5 | actionpack (= 5.1.6.1) 6 | nio4r (~> 2.0) 7 | websocket-driver (~> 0.6.1) 8 | actionmailer (5.1.6.1) 9 | actionpack (= 5.1.6.1) 10 | actionview (= 5.1.6.1) 11 | activejob (= 5.1.6.1) 12 | mail (~> 2.5, >= 2.5.4) 13 | rails-dom-testing (~> 2.0) 14 | actionpack (5.1.6.1) 15 | actionview (= 5.1.6.1) 16 | activesupport (= 5.1.6.1) 17 | rack (~> 2.0) 18 | rack-test (>= 0.6.3) 19 | rails-dom-testing (~> 2.0) 20 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 21 | actionview (5.1.6.1) 22 | activesupport (= 5.1.6.1) 23 | builder (~> 3.1) 24 | erubi (~> 1.4) 25 | rails-dom-testing (~> 2.0) 26 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 27 | activejob (5.1.6.1) 28 | activesupport (= 5.1.6.1) 29 | globalid (>= 0.3.6) 30 | activemodel (5.1.6.1) 31 | activesupport (= 5.1.6.1) 32 | activerecord (5.1.6.1) 33 | activemodel (= 5.1.6.1) 34 | activesupport (= 5.1.6.1) 35 | arel (~> 8.0) 36 | activesupport (5.1.6.1) 37 | concurrent-ruby (~> 1.0, >= 1.0.2) 38 | i18n (>= 0.7, < 2) 39 | minitest (~> 5.1) 40 | tzinfo (~> 1.1) 41 | addressable (2.5.1) 42 | public_suffix (~> 2.0, >= 2.0.2) 43 | arel (8.0.0) 44 | bcrypt (3.1.11) 45 | bindex (0.5.0) 46 | builder (3.2.3) 47 | byebug (9.0.6) 48 | concurrent-ruby (1.1.3) 49 | crack (0.4.3) 50 | safe_yaml (~> 1.0.0) 51 | crass (1.0.4) 52 | diff-lcs (1.3) 53 | erubi (1.7.1) 54 | execjs (2.7.0) 55 | ffi (1.9.25) 56 | globalid (0.4.1) 57 | activesupport (>= 4.2.0) 58 | hashdiff (0.3.4) 59 | i18n (1.1.1) 60 | concurrent-ruby (~> 1.0) 61 | listen (3.1.5) 62 | rb-fsevent (~> 0.9, >= 0.9.4) 63 | rb-inotify (~> 0.9, >= 0.9.7) 64 | ruby_dep (~> 1.2) 65 | loofah (2.2.3) 66 | crass (~> 1.0.2) 67 | nokogiri (>= 1.5.9) 68 | mail (2.7.1) 69 | mini_mime (>= 0.1.1) 70 | method_source (0.9.2) 71 | mini_mime (1.0.1) 72 | mini_portile2 (2.3.0) 73 | minitest (5.11.3) 74 | mysql2 (0.5.2) 75 | nio4r (2.3.1) 76 | nokogiri (1.8.5) 77 | mini_portile2 (~> 2.3.0) 78 | open_uri_redirections (0.2.1) 79 | public_suffix (2.0.5) 80 | rack (2.0.6) 81 | rack-test (1.1.0) 82 | rack (>= 1.0, < 3) 83 | rails (5.1.6.1) 84 | actioncable (= 5.1.6.1) 85 | actionmailer (= 5.1.6.1) 86 | actionpack (= 5.1.6.1) 87 | actionview (= 5.1.6.1) 88 | activejob (= 5.1.6.1) 89 | activemodel (= 5.1.6.1) 90 | activerecord (= 5.1.6.1) 91 | activesupport (= 5.1.6.1) 92 | bundler (>= 1.3.0) 93 | railties (= 5.1.6.1) 94 | sprockets-rails (>= 2.0.0) 95 | rails-controller-testing (1.0.2) 96 | actionpack (~> 5.x, >= 5.0.1) 97 | actionview (~> 5.x, >= 5.0.1) 98 | activesupport (~> 5.x) 99 | rails-dom-testing (2.0.3) 100 | activesupport (>= 4.2.0) 101 | nokogiri (>= 1.6) 102 | rails-html-sanitizer (1.0.4) 103 | loofah (~> 2.2, >= 2.2.2) 104 | railties (5.1.6.1) 105 | actionpack (= 5.1.6.1) 106 | activesupport (= 5.1.6.1) 107 | method_source 108 | rake (>= 0.8.7) 109 | thor (>= 0.18.1, < 2.0) 110 | rake (12.3.1) 111 | rb-fsevent (0.10.2) 112 | rb-inotify (0.9.10) 113 | ffi (>= 0.5.0, < 2) 114 | rspec (3.6.0) 115 | rspec-core (~> 3.6.0) 116 | rspec-expectations (~> 3.6.0) 117 | rspec-mocks (~> 3.6.0) 118 | rspec-core (3.6.0) 119 | rspec-support (~> 3.6.0) 120 | rspec-expectations (3.6.0) 121 | diff-lcs (>= 1.2.0, < 2.0) 122 | rspec-support (~> 3.6.0) 123 | rspec-mocks (3.6.0) 124 | diff-lcs (>= 1.2.0, < 2.0) 125 | rspec-support (~> 3.6.0) 126 | rspec-rails (3.6.0) 127 | actionpack (>= 3.0) 128 | activesupport (>= 3.0) 129 | railties (>= 3.0) 130 | rspec-core (~> 3.6.0) 131 | rspec-expectations (~> 3.6.0) 132 | rspec-mocks (~> 3.6.0) 133 | rspec-support (~> 3.6.0) 134 | rspec-support (3.6.0) 135 | ruby_dep (1.5.0) 136 | safe_yaml (1.0.4) 137 | sass (3.5.1) 138 | sass-listen (~> 4.0.0) 139 | sass-listen (4.0.0) 140 | rb-fsevent (~> 0.9, >= 0.9.4) 141 | rb-inotify (~> 0.9, >= 0.9.7) 142 | sass-rails (5.0.6) 143 | railties (>= 4.0.0, < 6) 144 | sass (~> 3.1) 145 | sprockets (>= 2.8, < 4.0) 146 | sprockets-rails (>= 2.0, < 4.0) 147 | tilt (>= 1.1, < 3) 148 | spring (2.0.2) 149 | activesupport (>= 4.2) 150 | sprockets (3.7.2) 151 | concurrent-ruby (~> 1.0) 152 | rack (> 1, < 3) 153 | sprockets-rails (3.2.1) 154 | actionpack (>= 4.0) 155 | activesupport (>= 4.0) 156 | sprockets (>= 3.0.0) 157 | thor (0.20.3) 158 | thread_safe (0.3.6) 159 | tilt (2.0.8) 160 | tzinfo (1.2.5) 161 | thread_safe (~> 0.1) 162 | uglifier (3.2.0) 163 | execjs (>= 0.3.0, < 3) 164 | web-console (3.5.1) 165 | actionview (>= 5.0) 166 | activemodel (>= 5.0) 167 | bindex (>= 0.4.0) 168 | railties (>= 5.0) 169 | webmock (3.0.1) 170 | addressable (>= 2.3.6) 171 | crack (>= 0.3.2) 172 | hashdiff 173 | websocket-driver (0.6.5) 174 | websocket-extensions (>= 0.1.0) 175 | websocket-extensions (0.1.3) 176 | 177 | PLATFORMS 178 | ruby 179 | 180 | DEPENDENCIES 181 | bcrypt 182 | byebug 183 | listen 184 | mysql2 185 | nokogiri 186 | open_uri_redirections 187 | rails (~> 5.1.6, >= 5.1.6.1) 188 | rails-controller-testing 189 | rspec (~> 3.6) 190 | rspec-rails (~> 3.6) 191 | sass-rails (~> 5.0) 192 | spring 193 | uglifier (>= 1.3.0) 194 | web-console 195 | webmock (~> 3.0) 196 | 197 | BUNDLED WITH 198 | 1.17.1 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sparkler 2 | 3 | Sparkler is a web application written in Ruby on Rails that collects system profile statistics from macOS apps using [Sparkle updater library](https://sparkle-project.org). 4 | 5 | sparkler against night 6 | 7 | [![Travis build status](https://travis-ci.org/mackuba/sparkler.svg)](https://travis-ci.org/mackuba/sparkler) 8 | 9 | ## What it does 10 | 11 | Sparkle is a very popular library used by a significant percentage of (non-app-store) macOS apps, created by [Andy Matuschak](https://andymatuschak.org) and now maintained by [porneL](https://github.com/pornel). Apps use it to check if a newer version is available and present the familiar "A new version of X is available!" dialog to the users. 12 | 13 | One of the features of Sparkle is that during the update check it can also [send anonymous system info](https://sparkle-project.org/documentation/system-profiling/) back to the author in the form of URL parameters like "?osVersion=10.13.0". The author can collect this data and then use the gathered statistics to determine e.g. if it's worth supporting Macs with older processors or older versions of macOS. 14 | 15 | However, in order to collect these statistics and do something useful with them, you need to have a place to store and present them. Sparkler is meant to be that place: it's a web app that Mac app authors can set up on their servers to collect this kind of statistics from their own users. 16 | 17 | To get an idea of how the statistics can look, check out the Sparkler pages for these apps: 18 | 19 | - [CocoaPods app](http://usage.cocoapods.org/feeds/cocoapods_app/statistics) 20 | - [Gitifier](http://sparkle.psionides.eu/feeds/gitifier/statistics) 21 | 22 | Here's a small preview: 23 | 24 | ![](http://i.imgur.com/MXnyhnD.png) 25 | 26 | ## How it works 27 | 28 | After you set up Sparkler on your server (yes, you need to have one) you use the admin panel to add a feed there. You need to give it a name and either a URL of a remote appcast file (e.g. a "raw" link to a GitHub resource) or a path to a file located on the same server. 29 | 30 | Sparkler then acts as a proxy to the actual appcast file: if you make a request to e.g. http://your.server/feeds/feed-name, it will cache and serve you the feed XML file as if it was located there, but it will also save any parameters sent with the request to the database. 31 | 32 | When you have that working, you need to release an update to your app that points it to http://your.server/feeds/feed-name instead of directly to the actual XML file. Once your users start updating to the new version, you should start seeing data on the statistics page (it might take a few days for the first data to appear, because the system profile info is only sent once a week at most). 33 | 34 | ## Requirements 35 | 36 | To install Sparkler on your server, you need a few other things there first: 37 | 38 | - Ruby 2.3 or newer 39 | - an HTTP server (Nginx/Apache) 40 | - a Ruby/Rails app server 41 | - a MySQL database 42 | 43 | If you already have these set up or you know how to install them, you can skip this section. 44 | 45 | ### Ruby 46 | 47 | Any recent Linux distribution will usually include some version of Ruby by default. However, it's possible that it's an older version of Ruby like 1.8 or 1.9. Rails 5 requires Ruby 2.2.2 or newer, but versions lower than 2.3 aren't maintained anymore. 48 | 49 | Check which version you have: 50 | 51 | ``` 52 | ruby -v 53 | ``` 54 | 55 | If you don't have any or if it's too old, you need to install a more recent version. 56 | 57 | You could use a Ruby version manager like [RVM](https://rvm.io) or [ruby-build](https://github.com/sstephenson/ruby-build), but if you don't need to switch between multiple versions of Ruby, it's enough if you install just one version directly. If you use Ubuntu, the [Brightbox repository](https://www.brightbox.com/docs/ruby/ubuntu) provides well prepared and up to date Ruby packages for all recent versions that can be easily installed in common versions of Ubuntu. Follow the instructions there and install the latest Ruby version from the repository. 58 | 59 | Also, make sure you have Bundler installed - it's a gem (library) used for installing all gems needed by an app: 60 | 61 | ``` 62 | gem install bundler 63 | ``` 64 | 65 | (you might need to add `sudo` depending on how you've installed Ruby). 66 | 67 | 68 | ### HTTP server 69 | 70 | If you have a VPS or another kind of server, you almost certainly have this already, but in case you don't: I recommend Nginx which is more lightweight and easier to set up than Apache. You can install Nginx together with Passenger, the Ruby app server (see below) from the [Phusion repository](https://www.phusionpassenger.com/library/install/apt_repo/). 71 | 72 | Remember to also open the HTTP(S) ports on your firewall. 73 | 74 | ### Ruby app server 75 | 76 | There are several different competing Ruby app servers, but most of them require running additional server processes in the background that need to be separately monitored, restarted if they go down etc. The one that's easiest to set up and use (IMHO) is [Passenger by Phusion](https://www.phusionpassenger.com), which integrates with Apache or Nginx and uses the web server to launch itself automatically. Follow the [instructions on their website](https://www.phusionpassenger.com/library/install/nginx/) to set it up. 77 | 78 | Once you have Nginx and Passenger installed, you'll need to add a server block to your Nginx config that looks something like this: 79 | 80 | ``` 81 | server { 82 | server_name sparkle.yourserver.com; 83 | 84 | passenger_enabled on; 85 | root /var/www/sparkler/current/public; 86 | 87 | access_log /var/log/nginx/sparkler-access.log; 88 | error_log /var/log/nginx/sparkler-error.log; 89 | } 90 | ``` 91 | 92 | Other Ruby servers you might consider instead include e.g. Unicorn or Puma. 93 | 94 | ## Deploying the app 95 | 96 | The recommended approach is to use Capistrano, which is a tool commonly used for deploying Rails apps. The advantages are that: 97 | 98 | - you can play with the app on your own computer first to test it there 99 | - you get automatic app versioning on the server, so you can roll back to a previous version easily 100 | - the installation is more automated 101 | 102 | ### Running the app locally 103 | 104 | If you want to try the app locally first, clone the repository to your machine: 105 | 106 | ``` 107 | git clone https://github.com/mackuba/sparkler.git 108 | cd sparkler 109 | ``` 110 | 111 | Install the required Ruby gems: 112 | 113 | ``` 114 | bundle install 115 | ``` 116 | 117 | Create a database config file based on the template file: 118 | 119 | ``` 120 | cp config/database.yml.example config/database.yml 121 | ``` 122 | 123 | The default config uses `sparkler_development` database in development mode and a `root` user with no password. Edit the file if needed. 124 | 125 | Create an empty database: 126 | 127 | ``` 128 | bin/rake db:create 129 | ``` 130 | 131 | Run the "migrations" that create a correct database schema: 132 | 133 | ``` 134 | bin/rake db:migrate 135 | ``` 136 | 137 | And then start the server at `localhost:3000`: 138 | 139 | ``` 140 | bin/rails server 141 | ``` 142 | 143 | ### Deploying with Capistrano 144 | 145 | Capistrano uses a few config files to tell it where and how to deploy the app. Since everyone will deploy it a bit differently, this repository only includes templates of those files that you need to copy and update to suit your needs. You can find them in `deploy/cap`, and you can make copies this way: 146 | 147 | ``` 148 | deploy/cap/install 149 | ``` 150 | 151 | Next, install the Capistrano gems: 152 | 153 | ``` 154 | bundle --gemfile Gemfile.deploy --binstubs deploy/bin 155 | ``` 156 | 157 | Then look at the `config/deploy.rb` and `config/deploy/production.rb` files. In the simplest case you'll only need to update the user and hostname in `config/deploy/production.rb`. The configs are prepared for a server that uses Passenger and doesn't use any Ruby version manager like RVM. If you use a Ruby version manager on the server or you use a different Ruby web server, you'll need to tweak the gems in `Gemfile.deploy` and the include lines in `Capfile`. 158 | 159 | When you're ready, call this command to deploy: 160 | 161 | ``` 162 | deploy/bin/cap deploy 163 | ``` 164 | 165 | The first deploy should fail with a message "linked file ... does not exist", because you're missing some config files. It will only create the necessary directory structure for you in the specified location on the server: 166 | 167 | - `releases` - this will contain subdirectories with several previous versions of the app 168 | - `shared` - this will contain some shared data like installed gems, custom configs etc. 169 | - there will also be a `current` directory later which will be a symlink to the latest release 170 | 171 | After you run this, log in to your server, go to `/your-app-location/shared/config` and create two files there: 172 | 173 | - `database.yml` based on the [config/database.yml.example](https://github.com/mackuba/sparkler/blob/master/config/database.yml.example) file from the Sparkler repo - fill it in with your chosen database name (under `production`), database user and password 174 | - `secret_key_base.key` which needs to contain a long random string for encrypting cookie sessions - you can generate it by running `bin/rake secret` in the Sparkler directory on your machine 175 | 176 | You also need to actually set up the user/password in your MySQL and create the specified database (you can use `bin/rake db:create`). 177 | 178 | Once this is done, you can complete the deploy using the same command again: 179 | 180 | ``` 181 | deploy/bin/cap deploy 182 | ``` 183 | 184 | Repeat this any time you want to update Sparkler to a new version. This will deploy the latest version to a new subdirectory in `releases` and will change the `current` link to point to it. 185 | 186 | ### Installing the app directly on the server 187 | 188 | If you prefer, you can clone the repository directly on the server: 189 | 190 | ``` 191 | git clone https://github.com/mackuba/sparkler.git 192 | cd sparkler 193 | ``` 194 | 195 | Next, install the required gems (you don't need the ones used in development and for running tests): 196 | 197 | ``` 198 | bundle install --without development test 199 | ``` 200 | 201 | Then create the `database.yml` and `secret_key_base.key` files in the `config` directory as described in the previous section. 202 | 203 | You'll also need to run this task to compile asset files like scripts and stylesheets (rerun this after every update too): 204 | 205 | ``` 206 | RAILS_ENV=production bin/rake assets:precompile 207 | ``` 208 | 209 | 210 | ## Configuring and using Sparkler 211 | 212 | When you first open your Sparkler site in a browser, you should see a "Feeds at [hostname]" page and a message that you've been logged in without a password. Go to the account page and set up a password. Then go back to the feeds page and add one or more feeds. 213 | 214 | ### Adding a feed 215 | 216 | The options for each feed are: 217 | 218 | - *title* - this is only displayed on the feed pages (visible only to you) and the statistics page 219 | - *name* - this goes in the URL of the feed XML, e.g. `/feeds/gitifier` - take into account that if you already have an app version out that loads the feed from this URL, changing the name will break the updates for existing users unless you set up a redirect manually 220 | - *location* - the location (local or remote) where the actual feed XML will be located 221 | 222 | The appcast location can be one of two things: 223 | 224 | - a URL to a remote file; for example, you can keep the appcast file in your repository and link Sparkler to the master version of the file, which will be updated when you change the file and push the changes to the repo (e.g. the Gitifier appcast is located [here](https://raw.githubusercontent.com/nschum/Gitifier/master/Sparkle/gitifier_appcast.xml)) 225 | - a path to a local file, like `/var/www/foobarapp/appcast.xml`; this is useful if you prefer to keep the appcast on the same server and update it e.g. through FTP 226 | 227 | You can also configure access to the feed statistics page - you have three options: 228 | 229 | - private statistics page - only you can access it 230 | - public statistics page, but download counts are hidden - only you will see the "show absolute amounts" switches, everyone else will only see percentages 231 | - completely public statistics page - everyone can see all the data you can see there 232 | 233 | And finally, you can choose to make a feed inactive - this is almost like deleting it, except the data isn't actually deleted. This is because deleting a feed is a dangerous operation since you could accidentally lose years' worth of data, so it's better to just hide it from the view. If you're really sure that you want to completely delete a feed together with the data, do this manually in the database or in a Rails console (run `RAILS_ENV=production bin/rails console` in the `current` directory on the server). 234 | 235 | ### Updating the Info.plist 236 | 237 | To collect the statistics, you need to update your app to make it load the appcast from the Sparkler server. Change the `SUFeedURL` key in your `Info.plist` to e.g. `http://your.server/feeds/foobar` (`/feed/foobar` also works as an alias to `/feeds/foobar`). Once you release the next update (and add it to the old appcast location!), the data should start coming in a few days at most depending on the amount of users you have. 238 | 239 | If you haven't done that before, you also need to add the `SUEnableSystemProfiling` key with value `YES` to tell Sparkle that you want it to send the system info in the GET parameters if the user agrees (more info on [Sparkle wiki](https://github.com/sparkle-project/Sparkle/wiki/System-Profiling)). Alternatively, you can ask the user for permission using a custom dialog and then set `SUSendProfileInfo = YES` in the user defaults if they accept it (setting it without asking might be considered not nice...). 240 | 241 | ### Reusing an existing URL 242 | 243 | If you have complete control over the current appcast location (i.e. it's on your server), you might be able to reuse the existing URL by simply adding a redirect rule to your HTTP server config. That way, you don't need to change anything in the app (apart from perhaps enabling sending profile info at all, see above). 244 | 245 | For example, you could add something like this to Nginx config: 246 | 247 | ``` 248 | location /my/old/feed/location { 249 | return 301 $scheme://$host/sparkle/feed/foo; 250 | } 251 | ``` 252 | 253 | ### Releasing new updates 254 | 255 | There's one caveat you need to remember about: the webapp caches the appcast file forever once it downloads it successfully - the feed would load much slower and less reliably for your users if it had to make requests to a remote server every time. However, this means that when you update the source appcast with a new entry, Sparkler will still serve the old version. 256 | 257 | To fix this, you need to remember to go to the feeds index page on your Sparkler site and press the "Reload data" link under the given feed: 258 | 259 | 260 | 261 | #### Reloading through the API 262 | 263 | Alternatively, you can set up an automated way of reloading the feed by calling the `reload` API endpoint. 264 | 265 | To do that, you need to configure a reload key for authenticating to that endpoint. There are two ways to do this: 266 | 267 | - configure an `X_RELOAD_KEY` environment variable 268 | - create a `config/reload_key.key` file containing the key (Capistrano deploy will symlink it from the `shared/config` folder as with `database.yml` and `secret_key_base.key`, if it exists there) 269 | 270 | In development mode, the key is set to `"reloadme!"` (see `config/secrets.yml`). 271 | 272 | When the key is configured, you can trigger a reload by making a request to `reload`, e.g.: 273 | 274 | curl -I http://localhost:3000/feeds/myapp/reload -H "X-Reload-Key: reloadme!" 275 | 276 | You can do this e.g. from your CI or a deployment script used for releasing your Mac app. 277 | 278 | ## Updating Sparkler 279 | 280 | From time to time Rails releases updated versions that fix some security issues. I'll try to update Sparkler quickly when that happens, but in any case, I'd recommend that you follow [@rails](https://twitter.com/rails) on Twitter so that you don't miss that (or alternatively, you can subscribe to the [Rails Security mailing list](https://groups.google.com/forum/#!forum/rubyonrails-security)). 281 | 282 | I don't expect to find many security issues in Sparkler itself since there isn't that much backend code here, but just in case you can check the project's GitHub page now and then :) 283 | 284 | ## Switching back to a direct link to appcast 285 | 286 | If at any moment you decide not to use Sparkler anymore, you'll have to deal with the fact that users using older versions of your app will still make requests to the Sparkler URL to check for updates, so you have to keep that working. However, you can handle this by simply configuring your web server to redirect requests made to that URL to the new location, e.g.: 287 | 288 | ``` 289 | location /feed/foobar { 290 | return 301 http://real.server.com/appcast.xml; 291 | } 292 | ``` 293 | 294 | ## Credits & contributing 295 | 296 | Copyright © 2017 [Kuba Suder](https://mackuba.eu). Licensed under [Very Simple Public License](https://github.com/mackuba/sparkler/blob/master/VSPL-LICENSE.txt) (a simplified version of the MIT license that fits in 3 lines). 297 | 298 | If you have any ideas for new features, improvements and bug fixes, pull requests are very welcome. (Just make sure you follow the existing code formatting style since I have a bit of an OCD...) 299 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /VSPL-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Jakub Suder 2 | 3 | You can modify, distribute and use this software for any purpose without any 4 | restrictions as long as you keep this copyright notice intact. The software is 5 | provided without any warranty. 6 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackuba/sparkler/830e19fe581201d760eeb87b4a9df63ebc9594ca/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/images/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackuba/sparkler/830e19fe581201d760eeb87b4a9df63ebc9594ca/app/assets/images/spinner.gif -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | //= require Chart 2 | //= require utils 3 | //= require sparkler 4 | -------------------------------------------------------------------------------- /app/assets/javascripts/sparkler.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | // initialization 3 | 4 | document.addEventListener("DOMContentLoaded", function() { 5 | initialize(); 6 | }); 7 | 8 | function initialize() { 9 | $.find('section.feed').forEach(function(feed) { 10 | initializeFeedCard(feed); 11 | }); 12 | 13 | $.find('#new_feed, #edit_feed').forEach(function(form) { 14 | initializeFeedForm(form); 15 | }); 16 | 17 | $.find('.report').forEach(function(report) { 18 | initializeReport(report); 19 | }); 20 | } 21 | 22 | 23 | // feeds page 24 | 25 | function initializeFeedCard(feed) { 26 | feed.addEventListener('click', onFeedClick); 27 | } 28 | 29 | function onFeedClick(event) { 30 | if (event.target.tagName === 'A' && event.target.classList.contains('reload')) { 31 | event.preventDefault(); 32 | 33 | var reloadLink = event.target; 34 | var feed = event.currentTarget; 35 | reloadFeed(reloadLink, feed); 36 | } 37 | } 38 | 39 | function reloadFeed(reloadLink, feed) { 40 | reloadLink.style.display = 'none'; 41 | 42 | var spinner = reloadLink.nextElementSibling; 43 | spinner.style.display = 'inline'; 44 | 45 | $.ajax({ 46 | url: reloadLink.href, 47 | success: function(response) { 48 | feed.innerHTML = response; 49 | }, 50 | error: function() { 51 | spinner.style.display = 'none'; 52 | reloadLink.style.display = 'inline'; 53 | reloadLink.textContent = 'Try again'; 54 | } 55 | }); 56 | } 57 | 58 | 59 | // feed forms 60 | 61 | function initializeFeedForm(form) { 62 | $.find('#feed_title', form).forEach(function(input) { 63 | input.addEventListener('change', onFeedTitleChange); 64 | }); 65 | 66 | $.find('#feed_public_stats', form).forEach(function(stats) { 67 | stats.addEventListener('change', onFeedPublicStatsChange); 68 | updateCountsCheckbox(stats); 69 | }); 70 | } 71 | 72 | function onFeedTitleChange(event) { 73 | var titleField = event.target; 74 | var nameField = $.findOne('#feed_name'); 75 | 76 | if (nameField.value == "") { 77 | nameField.value = titleField.value.toLowerCase().replace(/\W+/g, '_'); 78 | } 79 | } 80 | 81 | function onFeedPublicStatsChange(event) { 82 | var statsCheckbox = event.target; 83 | updateCountsCheckbox(statsCheckbox); 84 | } 85 | 86 | function updateCountsCheckbox(statsCheckbox) { 87 | var countsCheckbox = $.findOne('#feed_public_counts'); 88 | 89 | countsCheckbox.disabled = !statsCheckbox.checked; 90 | countsCheckbox.checked = statsCheckbox.checked && countsCheckbox.checked; 91 | } 92 | 93 | 94 | // stats page 95 | 96 | var MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 97 | 98 | function initializeReport(report) { 99 | var title = $.findOne('h2', report); 100 | 101 | $.find('canvas', report).forEach(function(canvas) { 102 | canvas.range = canvas.getAttribute('data-range'); 103 | canvas.normalized = (canvas.getAttribute('data-normalized') === 'true'); 104 | canvas.showLabels = (canvas.getAttribute('data-labels') === 'true'); 105 | 106 | createChart(canvas); 107 | }); 108 | 109 | $.find('nav a', report).forEach(function(a) { 110 | a.addEventListener('click', onChartModeLinkClick); 111 | }); 112 | 113 | $.find('.denormalize', report).forEach(function(checkbox) { 114 | checkbox.addEventListener('change', onDenormalizeCheckboxChange); 115 | }); 116 | } 117 | 118 | function onChartModeLinkClick(event) { 119 | event.preventDefault(); 120 | 121 | var link = event.target; 122 | selectOnlyLink(link); 123 | 124 | var canvas = $.findOne('canvas', $.parentSection(link)); 125 | canvas.range = link.getAttribute('data-range'); 126 | 127 | createChart(canvas); 128 | } 129 | 130 | function onDenormalizeCheckboxChange(event) { 131 | var checkbox = event.target; 132 | 133 | var canvas = $.findOne('canvas', $.parentSection(checkbox)); 134 | canvas.normalized = !checkbox.checked; 135 | 136 | createChart(canvas); 137 | } 138 | 139 | function selectOnlyLink(link) { 140 | var allLinks = $.find('a', link.parentElement); 141 | allLinks.forEach(function(a) { a.classList.remove('selected') }); 142 | link.classList.add('selected'); 143 | } 144 | 145 | function createChart(canvas) { 146 | if (canvas.chart) { 147 | canvas.chart.destroy(); 148 | delete canvas.chart; 149 | } 150 | 151 | if (!canvas.json) { 152 | var script = $.findOne('script', $.parentSection(canvas)); 153 | 154 | if (!script || script.type !== 'application/json') { 155 | $.log('Error: no data found for canvas.'); 156 | return; 157 | } 158 | 159 | canvas.json = JSON.parse(script.textContent); 160 | } 161 | 162 | var context = canvas.getContext('2d'); 163 | 164 | var fracValueFormat = canvas.normalized ? "<%= $.formatPercent(value) %>" : "<%= value %>"; 165 | var intValueFormat = canvas.normalized ? "<%= value %>%" : "<%= value %>"; 166 | 167 | if (canvas.range === 'month') { 168 | var chartData = pieChartDataFromJSON(canvas.json, canvas.normalized); 169 | 170 | canvas.chart = new Chart(context).Pie(chartData, { 171 | animateRotate: false, 172 | animation: false, 173 | tooltipTemplate: "<%= label %>: " + fracValueFormat 174 | }); 175 | } else { 176 | var chartData = lineChartDataFromJSON(canvas.json, canvas.range, canvas.normalized); 177 | 178 | canvas.chart = new Chart(context).Line(chartData, { 179 | animation: false, 180 | bezierCurve: false, 181 | datasetFill: false, 182 | multiTooltipTemplate: "<%= datasetLabel %> – " + fracValueFormat, 183 | pointHitDetectionRadius: 5, 184 | scaleBeginAtZero: true, 185 | scaleLabel: intValueFormat, 186 | tooltipTemplate: "<%= label %>: " + (canvas.showLabels ? "<%= datasetLabel %> – " : "") + fracValueFormat 187 | }); 188 | } 189 | 190 | if (canvas.showLabels) { 191 | var legend = $.findOne('.legend', $.parentSection(canvas)); 192 | if (legend) { 193 | legend.innerHTML = canvas.chart.generateLegend(); 194 | } else { 195 | $.log('Error: no legend element found.'); 196 | } 197 | } 198 | } 199 | 200 | function lineChartDataFromJSON(json, range, normalized) { 201 | var labels = json.months.map(function(ym) { 202 | var yearMonth = ym.split('-'); 203 | var year = yearMonth[0]; 204 | var month = parseInt(yearMonth[1], 10); 205 | 206 | return MONTH_NAMES[month - 1] + " " + year.substring(2); 207 | }); 208 | 209 | var series = json.series; 210 | 211 | if (json.show_other === false) { 212 | series = series.filter(function(s) { 213 | return !s.is_other; 214 | }); 215 | } 216 | 217 | var datasets = series.map(function(s, index) { 218 | var color = datasetColor(s, index, json.series.length); 219 | var amounts = normalized && s.normalized || s.amounts; 220 | 221 | return { 222 | label: s.title, 223 | data: amounts, 224 | strokeColor: color, 225 | pointColor: color, 226 | pointStrokeColor: "#fff", 227 | pointHighlightFill: "#fff", 228 | pointHighlightStroke: color, 229 | }; 230 | }); 231 | 232 | if (range === 'year') { 233 | labels = labels.slice(labels.length - 12); 234 | 235 | datasets.forEach(function(dataset) { 236 | dataset.data = dataset.data.slice(dataset.data.length - 12); 237 | }); 238 | } 239 | 240 | return { 241 | labels: labels, 242 | datasets: datasets 243 | }; 244 | } 245 | 246 | function pieChartDataFromJSON(json, normalized) { 247 | return json.series.map(function(s, index) { 248 | var amounts = normalized && s.normalized || s.amounts; 249 | var color = datasetColor(s, index, json.series.length); 250 | var highlight = highlightColor(s, index, json.series.length); 251 | 252 | return { 253 | label: s.title, 254 | value: amounts[amounts.length - 1], 255 | color: color, 256 | highlight: highlight 257 | }; 258 | }) 259 | } 260 | 261 | function datasetColor(series, index, total) { 262 | if (series.is_other) { 263 | return '#999'; 264 | } else { 265 | var hue = 360 / total * index; 266 | return "hsl(" + hue + ", 70%, 60%)"; 267 | } 268 | } 269 | 270 | function highlightColor(series, index, total) { 271 | if (series.is_other) { 272 | return '#aaa'; 273 | } else { 274 | var hue = 360 / total * index; 275 | return "hsl(" + hue + ", 70%, 70%)"; 276 | } 277 | } 278 | 279 | })(); 280 | -------------------------------------------------------------------------------- /app/assets/javascripts/utils.js: -------------------------------------------------------------------------------- 1 | window.$ = { 2 | find: function(selector, where) { 3 | if (!selector) { 4 | throw new Error('$.find: no selector given'); 5 | } 6 | 7 | var collection = (where || document).querySelectorAll(selector); 8 | return Array.prototype.slice.apply(collection); 9 | }, 10 | 11 | findOne: function(selector, where) { 12 | if (!selector) { 13 | throw new Error('$.findOne: no selector given'); 14 | } 15 | 16 | return (where || document).querySelector(selector); 17 | }, 18 | 19 | ajax: function(options) { 20 | var request = new XMLHttpRequest(); 21 | request.open(options.method || 'GET', options.url, true); 22 | request.setRequestHeader('X-Requested-With', 'XMLHTTPRequest'); 23 | 24 | request.onload = function() { 25 | if (this.status >= 200 && this.status < 400) { 26 | if (typeof options.success === 'function') { 27 | options.success(this.response); 28 | } 29 | } else { 30 | if (typeof options.error === 'function') { 31 | options.error(); 32 | } 33 | } 34 | }; 35 | 36 | request.onerror = function() { 37 | if (typeof options.error === 'function') { 38 | options.error(); 39 | } 40 | }; 41 | 42 | request.send(); 43 | }, 44 | 45 | log: function(text) { 46 | if (console.log) { 47 | console.log(text); 48 | } 49 | }, 50 | 51 | formatPercent: function(value) { 52 | var int = Math.floor(value); 53 | var rest = value - int; 54 | return int + '.' + (rest > 0 ? rest.toString().substr(2, 1) : '0') + '%'; 55 | }, 56 | 57 | parentSection: function(start) { 58 | if (!start) { 59 | throw new Error('$.parentSection: no element given'); 60 | } 61 | 62 | var element = start; 63 | 64 | while (element && element.tagName !== 'SECTION') { 65 | element = element.parentElement; 66 | } 67 | 68 | return element; 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | *= require normalize 3 | *= require style 4 | */ 5 | -------------------------------------------------------------------------------- /app/assets/stylesheets/style.css.scss: -------------------------------------------------------------------------------- 1 | $blue: 210; 2 | 3 | html { 4 | // 1 rem = 10 px 5 | font-size: 62.5%; 6 | } 7 | 8 | body { 9 | position: relative; 10 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 11 | font-size: 1.4rem; 12 | color: #333; 13 | -webkit-font-smoothing: antialiased; 14 | padding-bottom: 2rem; 15 | } 16 | 17 | .body-wrapper { 18 | overflow: hidden; 19 | margin: 0 auto; 20 | width: 80rem; 21 | 22 | > nav { 23 | position: absolute; 24 | top: 3rem; 25 | font-size: 1.4rem; 26 | 27 | &.left { left: 3rem; } 28 | &.right { right: 3rem; } 29 | 30 | a { 31 | margin: 0 0.7rem; 32 | } 33 | } 34 | } 35 | 36 | h1 { 37 | font-size: 4rem; 38 | margin-top: 12rem; 39 | margin-bottom: 8rem; 40 | text-align: center; 41 | font-weight: 500; 42 | color: black; 43 | 44 | a { color: black; } 45 | a:hover { text-decoration: none; } 46 | } 47 | 48 | h2 { 49 | font-size: 2.0rem; 50 | color: black; 51 | } 52 | 53 | a { 54 | text-decoration: none; 55 | color: #666; 56 | 57 | &:hover { 58 | text-decoration: underline; 59 | } 60 | } 61 | 62 | .feeds { 63 | width: 60rem; 64 | margin: 0 auto; 65 | } 66 | 67 | .feed { 68 | margin: 4rem auto; 69 | padding: 1.5rem; 70 | border: 1px solid hsl($blue, 20%, 88%); 71 | border-radius: 5px; 72 | background: hsl($blue, 80%, 97%); 73 | 74 | &.inactive { 75 | background: #f6f6f6; 76 | border-color: #ddd; 77 | } 78 | 79 | h2 { 80 | margin: 0 0 2rem; 81 | font-size: 2rem; 82 | } 83 | 84 | .status, .status-error, p a { 85 | margin-left: 0.5rem; 86 | } 87 | 88 | .status { 89 | color: black; 90 | } 91 | 92 | .status-error { 93 | color: red; 94 | } 95 | 96 | .actions { 97 | border-top: 1px solid hsl($blue, 20%, 88%); 98 | padding-top: 1.2rem; 99 | } 100 | 101 | .reload-spinner { 102 | vertical-align: top; 103 | display: none; 104 | margin: 0 1rem; 105 | } 106 | } 107 | 108 | .commands { 109 | margin: 1rem auto; 110 | padding-left: 1rem; 111 | } 112 | 113 | .report { 114 | canvas { 115 | width: 100%; 116 | height: 275px; 117 | -ms-user-select: none; 118 | -moz-user-select: none; 119 | -webkit-user-select: none; 120 | } 121 | 122 | .info { 123 | font-size: 1.3rem; 124 | line-height: 140%; 125 | color: #666; 126 | margin-bottom: 2rem; 127 | } 128 | 129 | nav { 130 | margin: 2rem 0 2.5rem; 131 | font-size: 1.2rem; 132 | 133 | a, .button { 134 | border: 1px solid #ddd; 135 | padding: 0.4rem 0.8rem 0.3rem; 136 | border-radius: 4px; 137 | margin-right: 0.3rem; 138 | color: #333; 139 | -ms-user-select: none; 140 | -moz-user-select: none; 141 | -webkit-user-select: none; 142 | 143 | &:hover { 144 | text-decoration: none; 145 | background-color: hsl($blue, 80%, 97%); 146 | border-color: hsl($blue, 50%, 87%); 147 | } 148 | 149 | &.selected { 150 | background-color: hsl($blue, 80%, 94%); 151 | border-color: hsl($blue, 50%, 75%); 152 | } 153 | 154 | input { 155 | margin-right: 3px; 156 | } 157 | } 158 | } 159 | } 160 | 161 | .legend { 162 | margin-top: 2rem; 163 | margin-bottom: 5rem; 164 | 165 | ul { 166 | margin: 0 auto; 167 | padding: 0; 168 | display: table; 169 | text-align: center; 170 | } 171 | 172 | li { 173 | display: inline-block; 174 | margin-right: 1.5rem; 175 | margin-bottom: 1rem; 176 | font-size: 1.3rem; 177 | color: #333; 178 | 179 | span { 180 | display: inline-block; 181 | width: 9px; 182 | height: 9px; 183 | margin-right: 5px; 184 | border-radius: 5px; 185 | } 186 | } 187 | } 188 | 189 | form { 190 | width: 48rem; 191 | margin: 0 auto; 192 | 193 | label { 194 | display: block; 195 | margin-bottom: 0.5rem; 196 | font-weight: 500; 197 | } 198 | 199 | input:disabled + label { 200 | color: #888; 201 | } 202 | 203 | .field { 204 | margin-bottom: 1.5rem; 205 | } 206 | 207 | .field-info { 208 | font-size: 1.2rem; 209 | color: #666; 210 | display: block; 211 | margin-bottom: 0.5rem; 212 | margin-top: -0.3rem; 213 | } 214 | 215 | .checkbox { 216 | margin: 1.5rem 0; 217 | 218 | label { 219 | display: inline; 220 | vertical-align: middle; 221 | margin-left: 0.2rem; 222 | font-weight: normal; 223 | } 224 | 225 | .field-info { 226 | margin-top: 0.2rem; 227 | margin-left: 1.8rem; 228 | } 229 | } 230 | 231 | input[type=text], input[type=password] { 232 | width: 100%; 233 | box-sizing: border-box; 234 | } 235 | 236 | input[type=submit] { 237 | margin-right: 0.3rem; 238 | } 239 | } 240 | 241 | .actions { 242 | margin-top: 2rem; 243 | 244 | a, .separator { margin-right: 0.5rem; } 245 | } 246 | 247 | .alert, .error-explanation { 248 | background: #fdd; 249 | padding: 1.5rem; 250 | margin-bottom: 2.5rem; 251 | 252 | h2 { 253 | margin: 0; 254 | margin-bottom: 1rem; 255 | font-size: 1.5rem; 256 | } 257 | 258 | ul { 259 | margin: 0; 260 | } 261 | 262 | li { 263 | margin-bottom: 0.5rem; 264 | } 265 | } 266 | 267 | .notice { 268 | background: hsl(120, 60%, 93%); 269 | border: 1px solid hsl(120, 40%, 88%); 270 | padding: 1.5rem; 271 | margin-bottom: 2.5rem; 272 | } 273 | 274 | footer { 275 | position: absolute; 276 | bottom: 3rem; 277 | right: 3rem; 278 | font-size: 1.3rem; 279 | color: #333; 280 | 281 | a { color: #666; } 282 | } 283 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | 6 | helper_method :logged_in? 7 | 8 | 9 | private 10 | 11 | def logged_in? 12 | session[:logged_in] 13 | end 14 | 15 | def require_admin 16 | redirect_to login_form_user_path, alert: 'You need to log in to access this page.' unless logged_in? 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /app/controllers/feeds_controller.rb: -------------------------------------------------------------------------------- 1 | class FeedsController < ApplicationController 2 | before_action :set_feed, only: [:reload, :edit, :update] 3 | before_action :set_active_feed, only: [:show] 4 | before_action :require_admin, except: [:show, :reload] 5 | before_action :require_admin_or_reload_key, only: [:reload] 6 | 7 | def index 8 | @feeds = Feed.order('inactive, title') 9 | end 10 | 11 | def show 12 | save_statistics(@feed) if request_from_sparkle? 13 | 14 | @feed.load_if_needed 15 | 16 | if @feed.contents 17 | render body: @feed.contents 18 | else 19 | head :not_found 20 | end 21 | end 22 | 23 | def reload 24 | @feed.load_contents 25 | 26 | if request.xhr? 27 | render partial: 'feed', locals: { feed: @feed } 28 | else 29 | redirect_to feeds_path 30 | end 31 | end 32 | 33 | def new 34 | @feed = Feed.new 35 | end 36 | 37 | def create 38 | @feed = Feed.new(feed_params) 39 | 40 | if @feed.save 41 | @feed.load_contents 42 | redirect_to feeds_path, notice: "Feed '#{@feed.title}' was successfully created." 43 | else 44 | render :new 45 | end 46 | end 47 | 48 | def edit 49 | end 50 | 51 | def update 52 | if @feed.update_attributes(feed_params) 53 | @feed.load_if_needed 54 | redirect_to feeds_path, notice: "Feed '#{@feed.title}' was successfully updated." 55 | else 56 | render :edit 57 | end 58 | end 59 | 60 | 61 | private 62 | 63 | def feed_params 64 | params.require(:feed).permit(:title, :name, :url, :public_stats, :public_counts, :inactive) 65 | end 66 | 67 | def set_feed 68 | @feed = Feed.find_by_name!(params[:id]) 69 | end 70 | 71 | def set_active_feed 72 | @feed = Feed.active.find_by_name!(params[:id]) 73 | end 74 | 75 | def require_admin_or_reload_key 76 | reload_key = Rails.application.secrets.reload_key 77 | require_admin unless reload_key.present? && request.headers['HTTP_X_RELOAD_KEY'] == reload_key 78 | end 79 | 80 | def request_from_sparkle? 81 | request.user_agent.present? && request.user_agent =~ %r(Sparkle/) 82 | end 83 | 84 | def save_statistics(feed) 85 | StatisticSaver.new(feed).save_params(request.params, request.user_agent) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /app/controllers/statistics_controller.rb: -------------------------------------------------------------------------------- 1 | class StatisticsController < ApplicationController 2 | before_action :set_feed 3 | 4 | def index 5 | require_admin unless @feed.public_stats 6 | 7 | @include_counts = @feed.public_counts || logged_in? 8 | @report = FeedReport.new(@feed, include_counts: @include_counts) 9 | end 10 | 11 | private 12 | 13 | def set_feed 14 | @feed = Feed.active.find_by_name!(params[:feed_id]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/controllers/user_controller.rb: -------------------------------------------------------------------------------- 1 | class UserController < ApplicationController 2 | before_action :find_admin, except: [:logout] 3 | 4 | def login_form 5 | if @user.has_password? 6 | render 7 | else 8 | session[:logged_in] = true 9 | redirect_to feeds_path, alert: "Logged in without a password - please set a password in account settings!" 10 | end 11 | end 12 | 13 | def login 14 | if @user.authenticate(params[:password]) 15 | session[:logged_in] = true 16 | redirect_to feeds_path 17 | else 18 | flash.now[:alert] = "The password you've entered is incorrect." 19 | render :login_form 20 | end 21 | end 22 | 23 | def edit 24 | end 25 | 26 | def update 27 | if @user.update_attributes(params.require(:user).permit(:password, :password_confirmation)) 28 | redirect_to feeds_path, notice: "Your account was updated successfully." 29 | else 30 | render :edit 31 | end 32 | end 33 | 34 | def logout 35 | session[:logged_in] = false 36 | redirect_to login_form_user_path, notice: "You've been logged out." 37 | end 38 | 39 | 40 | private 41 | 42 | def find_admin 43 | @user = User.find_admin 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def pluralize_errors(errors) 3 | errors.count == 1 ? 'this error' : 'these errors' 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackuba/sparkler/830e19fe581201d760eeb87b4a9df63ebc9594ca/app/models/.keep -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /app/models/feed.rb: -------------------------------------------------------------------------------- 1 | require 'open-uri' 2 | 3 | class Feed < ApplicationRecord 4 | has_many :statistics 5 | validates_presence_of :title, :name, :url 6 | 7 | validates_format_of :name, with: %r{\A[a-z0-9_\-\.]+\z}, allow_blank: true, 8 | message: 'may only contain letters, digits, underscores, hyphens and periods' 9 | 10 | validates_format_of :url, with: %r{\A((http|https|ftp)://|/)}, allow_blank: true 11 | 12 | before_save :reset_if_url_changed 13 | 14 | scope :active, -> { where(inactive: false) } 15 | 16 | 17 | def to_param 18 | name 19 | end 20 | 21 | def active? 22 | !inactive 23 | end 24 | 25 | def loaded? 26 | contents.present? 27 | end 28 | 29 | def load_if_needed 30 | load_contents unless loaded? 31 | end 32 | 33 | def load_contents 34 | logger.info "Reloading feed #{title} from #{url}..." 35 | 36 | text = open(url, :allow_redirections => :safe).read 37 | 38 | self.contents = text 39 | self.last_version = version_from_contents(text) 40 | self.load_error = nil 41 | save! 42 | rescue OpenURI::HTTPError, RuntimeError, SocketError, SystemCallError, Nokogiri::XML::XPath::SyntaxError => error 43 | logger.error "Couldn't download feed from #{url}: #{error}" 44 | 45 | self.load_error = error 46 | save! 47 | end 48 | 49 | def reset_if_url_changed 50 | if url_changed? 51 | self.contents = nil 52 | self.last_version = nil 53 | self.load_error = nil 54 | end 55 | end 56 | 57 | def version_from_contents(contents) 58 | xml = Nokogiri::XML(contents) 59 | versions = xml.css('item').map { |item| version_from_item(item) }.compact 60 | versions.map { |v| Gem::Version.new(v) }.sort.reverse.first.try(:to_s) 61 | end 62 | 63 | def version_from_item(item) 64 | if version = item.css('sparkle|version').first 65 | version.text 66 | elsif enclosure = item.css('enclosure').first 67 | enclosure['sparkle:shortVersionString'] || enclosure['sparkle:version'] 68 | else 69 | nil 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /app/models/feed_report.rb: -------------------------------------------------------------------------------- 1 | class FeedReport 2 | attr_reader :reports 3 | cattr_accessor :report_types 4 | 5 | def initialize(feed, options = {}) 6 | @include_counts = options[:include_counts] 7 | @report_types = options[:report_types] || self.class.report_types 8 | 9 | @feed = feed 10 | @months = generate_months(feed.statistics) 11 | @properties = Property.all.includes(:options).to_a 12 | 13 | calculate_counts 14 | calculate_sums 15 | generate_reports 16 | end 17 | 18 | def generate_months(collection) 19 | return [] if collection.count == 0 20 | 21 | date = collection.order('date').first.date 22 | end_date = collection.order('date').last.date 23 | months = [] 24 | 25 | loop do 26 | break if date > end_date 27 | months << date.strftime("%Y-%m") 28 | date = date.next_month 29 | end 30 | 31 | months 32 | end 33 | 34 | def calculate_counts 35 | @counts = {} 36 | 37 | grouped_statistics = @feed.statistics 38 | .select("DATE_FORMAT(date, '%Y-%m') AS ym, property_id, option_id, SUM(counter) AS total_for_month") 39 | .group("ym, property_id, option_id") 40 | 41 | grouped_statistics.each do |stat| 42 | property_stats = @counts[stat.property_id] ||= {} 43 | option_stats = property_stats[stat.option_id] ||= {} 44 | option_stats[stat.ym] = stat.total_for_month 45 | end 46 | end 47 | 48 | def calculate_sums 49 | @sums = {} 50 | 51 | @counts.each do |property_id, property_stats| 52 | property_sums = @sums[property_id] ||= {} 53 | property_stats.each do |option_id, option_stats| 54 | option_stats.each do |ym, count| 55 | property_sums[ym] ||= 0 56 | property_sums[ym] += count 57 | end 58 | end 59 | end 60 | end 61 | 62 | def count_for(property_id, option_id, ym) 63 | property_stats = @counts[property_id] || {} 64 | option_stats = property_stats[option_id] || {} 65 | option_stats[ym] || 0 66 | end 67 | 68 | def sum_for(property_id, ym) 69 | @sums[property_id] && @sums[property_id][ym] || 0 70 | end 71 | 72 | def generate_reports 73 | @reports = [] 74 | 75 | @report_types.each do |report_title, options| 76 | next if options[:only_counts] && !@include_counts 77 | 78 | report = generate_report(report_title, options) 79 | @reports.push(report) 80 | end 81 | end 82 | 83 | def generate_report(report_title, options) 84 | property = @properties.detect { |p| p.name == options[:field] } 85 | 86 | if property.nil? 87 | property = Property.create!(name: options[:field]) 88 | @properties.push(property) 89 | end 90 | 91 | converting_proc = case options[:options] 92 | when Proc then options[:options] 93 | when Hash then lambda { |title| options[:options][title] || title } 94 | else lambda { |title| title } 95 | end 96 | 97 | grouping_proc = options[:group_by] || lambda { |title| title } 98 | sorting_proc = options[:sort_by] || lambda { |title| [title.to_i, title.downcase] } 99 | 100 | option_map = processed_options(property.options, grouping_proc, converting_proc, sorting_proc) 101 | 102 | data_lines = option_map.map do |title, options| 103 | amounts, normalized = calculate_dataset(property) do |ym, i| 104 | options.sum { |o| count_for(property.id, o.id, ym) } 105 | end 106 | 107 | { title: title, amounts: amounts, normalized: normalized } 108 | end 109 | 110 | data_lines.delete_if { |d| d[:amounts].sum == 0 } 111 | 112 | if options[:threshold] 113 | data_lines = extract_other_dataset(data_lines, property, options[:threshold]) 114 | end 115 | 116 | if options[:only_counts] 117 | data_lines.each { |dataset| dataset.delete(:normalized) } 118 | else 119 | if !@include_counts 120 | data_lines.each { |dataset| dataset.delete(:amounts) } 121 | end 122 | end 123 | 124 | report = { 125 | title: report_title, 126 | months: @months, 127 | series: data_lines, 128 | initial_range: case @months.length 129 | when 1 then 'month' 130 | when 2..12 then 'year' 131 | else 'all' 132 | end 133 | } 134 | 135 | report[:is_downloads] = options[:is_downloads] if options.has_key?(:is_downloads) 136 | report[:show_other] = options[:show_other] if options.has_key?(:show_other) 137 | 138 | report 139 | end 140 | 141 | def processed_options(options, grouping_proc, converting_proc, sorting_proc) 142 | grouped_options = {} 143 | 144 | options.each do |option| 145 | title = converting_proc.call(grouping_proc.call(option.name).to_s).to_s 146 | grouped_options[title] ||= [] 147 | grouped_options[title] << option 148 | end 149 | 150 | sorted_options = {} 151 | 152 | grouped_options.keys.sort_by(&sorting_proc).each do |title| 153 | sorted_options[title] = grouped_options[title] 154 | end 155 | 156 | sorted_options 157 | end 158 | 159 | def calculate_dataset(property, &amount_proc) 160 | amounts = [] 161 | normalized_amounts = [] 162 | 163 | @months.each_with_index do |ym, index| 164 | amount = amount_proc.call(ym, index) 165 | total = sum_for(property.id, ym) 166 | normalized = (total > 0) ? (amount * 100.0 / total).round(1) : 0 167 | 168 | amounts.push(amount) 169 | normalized_amounts.push(normalized) 170 | end 171 | 172 | [amounts, normalized_amounts] 173 | end 174 | 175 | def extract_other_dataset(data_lines, property, threshold) 176 | other = data_lines.select { |dataset| dataset[:normalized].max < threshold } 177 | 178 | amounts, normalized = calculate_dataset(property) { |ym, i| other.sum { |dataset| dataset[:amounts][i] }} 179 | 180 | other_dataset = { 181 | title: 'Other', 182 | is_other: true, 183 | amounts: amounts, 184 | normalized: normalized 185 | } 186 | 187 | data_lines - other + [other_dataset] 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /app/models/option.rb: -------------------------------------------------------------------------------- 1 | class Option < ApplicationRecord 2 | belongs_to :property 3 | validates_presence_of :name 4 | end 5 | -------------------------------------------------------------------------------- /app/models/property.rb: -------------------------------------------------------------------------------- 1 | class Property < ApplicationRecord 2 | has_many :options 3 | validates_presence_of :name 4 | end 5 | -------------------------------------------------------------------------------- /app/models/statistic.rb: -------------------------------------------------------------------------------- 1 | class Statistic < ApplicationRecord 2 | belongs_to :feed 3 | belongs_to :property 4 | belongs_to :option 5 | end 6 | -------------------------------------------------------------------------------- /app/models/statistic_saver.rb: -------------------------------------------------------------------------------- 1 | class StatisticSaver 2 | def initialize(feed) 3 | @feed = feed 4 | @properties = Property.all.includes(:options).to_a 5 | end 6 | 7 | def save_params(params, user_agent) 8 | params = params.clone 9 | 10 | params.delete('appName') 11 | params.delete('controller') 12 | params.delete('action') 13 | params.delete('id') 14 | 15 | date = Date.today 16 | app_version = user_agent.split(' ').first.split('/').last 17 | subtype = params.delete('cpusubtype') 18 | 19 | if subtype && params['cputype'] 20 | subtype = "#{params['cputype']}.#{subtype}" 21 | end 22 | 23 | params.each do |property_name, option_name| 24 | save_param(date, property_name, option_name) 25 | end 26 | 27 | save_param(date, 'appVersionShort', app_version) 28 | save_param(date, 'cpusubtype', subtype) if subtype 29 | end 30 | 31 | def save_param(date, property_name, option_name) 32 | property = @properties.detect { |p| p.name == property_name } 33 | 34 | if property.nil? 35 | property = Property.create!(name: property_name) 36 | @properties.push(property) 37 | end 38 | 39 | option = property.options.detect { |o| o.name == option_name } || property.options.create!(name: option_name) 40 | 41 | statistic = @feed.statistics.find_by(date: date, property: property, option: option) 42 | 43 | if statistic 44 | Statistic.update_counters(statistic.id, counter: 1) 45 | else 46 | @feed.statistics.create!(date: date, property: property, option: option, counter: 1) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | has_secure_password validations: false 3 | 4 | validates_presence_of :password 5 | validates_length_of :password, 6 | minimum: 6, maximum: ActiveModel::SecurePassword::MAX_PASSWORD_LENGTH_ALLOWED, allow_blank: true 7 | validates_confirmation_of :password, allow_blank: true 8 | 9 | 10 | def self.find_admin 11 | user = User.first 12 | 13 | if user.nil? 14 | user = User.new 15 | user.save(validate: false) 16 | end 17 | 18 | user 19 | end 20 | 21 | def has_password? 22 | password_digest.present? 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/views/feeds/_feed.html.erb: -------------------------------------------------------------------------------- 1 |

<%= feed.title %>

2 | 3 | <% if feed.active? %> 4 |

Feed URL: <%= link_to feed_url(feed), feed %>

5 |

Source: <%= feed.url.include?('://') ? link_to(feed.url, feed.url) : feed.url %>

6 | 7 |

8 | Stats access: 9 | <% if feed.public_stats && feed.public_counts %> 10 | Public 11 | <% elsif feed.public_stats %> 12 | Public (only percentages) 13 | <% else %> 14 | Private 15 | <% end %> 16 |

17 | 18 |

19 | Status: 20 | <% if feed.loaded? %> 21 | <% if feed.last_version %> 22 | OK 23 | <% else %> 24 | No versions found 25 | <% end %> 26 | <% elsif feed.load_error %> 27 | Error: <%= feed.load_error %> 28 | <% else %> 29 | Not loaded yet 30 | <% end %> 31 |

32 | 33 |

34 | Last version: 35 | <% if feed.loaded? && feed.last_version %> 36 | <%= feed.last_version %> 37 | <% else %> 38 | ? 39 | <% end %> 40 |

41 | <% end %> 42 | 43 |
44 | <% if feed.active? %> 45 | <%= link_to "See statistics", feed_statistics_path(feed) %> 46 | 47 | 48 | 49 | <%= link_to(feed.loaded? || feed.load_error ? "Reload data" : "Load data", 50 | reload_feed_path(feed), class: 'reload') %> 51 | 52 | <%= image_tag 'spinner.gif', class: 'reload-spinner' %> 53 | 54 | 55 | <% end %> 56 | 57 | <%= link_to "Edit", edit_feed_path(feed) %> 58 |
59 | -------------------------------------------------------------------------------- /app/views/feeds/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(@feed) do |f| %> 2 | <% if @feed.errors.any? %> 3 |
4 |

Fix <%= pluralize_errors(@feed.errors) %> in order to save this feed:

5 | 6 | 11 |
12 | <% end %> 13 | 14 |
15 | <%= f.label :title %> 16 | <%= f.text_field :title %> 17 |
18 | 19 |
20 | <%= f.label :name, "Name (for URL)" %> 21 | <% if @feed.persisted? %> 22 | If you change the feed name, the old /feeds/name URL will stop working. 23 | <% end %> 24 | <%= f.text_field :name %> 25 |
26 | 27 |
28 | <%= f.label :url, "Appcast file location" %> 29 | Enter an HTTP(S) URL of a remote XML file or a path to a local file on the server. 30 | <%= f.text_field :url, autocomplete: 'off' %> 31 |
32 | 33 |
34 | <%= f.check_box :public_stats %> 35 | <%= f.label :public_stats, "Make statistics page publicly accessible" %> 36 |
37 | 38 |
39 | <%= f.check_box :public_counts %> 40 | <%= f.label :public_counts, "Make download counts visible to everyone" %> 41 |
42 | 43 | <% if @feed.persisted? %> 44 |
45 | <%= f.check_box :inactive %> 46 | <%= f.label :inactive, "Make feed inactive" %> 47 | This makes both the feed and its stats page inaccessible to anyone. 48 |
49 | <% end %> 50 | 51 |
52 | <%= f.submit %> or <%= link_to 'Cancel', feeds_path %> 53 |
54 | <% end %> 55 | -------------------------------------------------------------------------------- /app/views/feeds/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Edit Feed

2 | 3 | <%= render 'form' %> 4 | -------------------------------------------------------------------------------- /app/views/feeds/index.html.erb: -------------------------------------------------------------------------------- 1 |

<%= link_to "Feeds at #{request.host}", feeds_path %>

2 | 3 |
4 | <% if flash[:notice] %> 5 |

<%= flash[:notice] %>

6 | <% end %> 7 | 8 | <% @feeds.each do |feed| %> 9 |
10 | <%= render partial: 'feed', locals: { feed: feed } %> 11 |
12 | <% end %> 13 | 14 | <% if logged_in? %> 15 |

<%= link_to "Add new feed…", new_feed_path %>

16 | <% end %> 17 |
18 | -------------------------------------------------------------------------------- /app/views/feeds/new.html.erb: -------------------------------------------------------------------------------- 1 |

New Feed

2 | 3 | <%= render 'form' %> 4 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sparkler statistics 6 | 7 | <%= stylesheet_link_tag 'application' %> 8 | <%= javascript_include_tag 'application' %> 9 | <%= csrf_meta_tags %> 10 | 11 | 12 |
13 | <% if logged_in? && !(params[:controller] == 'feeds' && params[:action] == 'index') %> 14 | 17 | <% end %> 18 | 19 | 27 | 28 | <%= yield %> 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /app/views/statistics/index.html.erb: -------------------------------------------------------------------------------- 1 |

<%= @feed.title %> feed statistics

2 | 3 | <% @report.reports.each_with_index do |report, i| %> 4 |
5 |

<%= report[:title] %>

6 | 7 | <% if report[:is_downloads] %> 8 |

Only requests that include system profile info are included (submitted once 9 | a week on average and only for users who explicitly agreed).

10 | <% end %> 11 | 12 | 29 | 30 | 34 | 35 | 36 | 39 | 40 |
41 |
42 | <% end %> 43 | 44 | 48 | -------------------------------------------------------------------------------- /app/views/user/edit.html.erb: -------------------------------------------------------------------------------- 1 |

Edit account

2 | 3 | <%= form_for(@user, url: user_path) do |f| %> 4 | <% if @user.errors.any? %> 5 |
6 |

Fix <%= pluralize_errors(@user.errors) %> in order to save your account:

7 | 8 | 13 |
14 | <% end %> 15 | 16 |
17 | <%= f.label :password %> 18 | <%= f.password_field :password %> 19 |
20 |
21 | <%= f.label :password_confirmation %> 22 | <%= f.password_field :password_confirmation %> 23 |
24 |
25 | <%= f.submit 'Update Account' %> or <%= link_to 'Cancel', feeds_path %> 26 |
27 | <% end %> 28 | -------------------------------------------------------------------------------- /app/views/user/login_form.html.erb: -------------------------------------------------------------------------------- 1 |

Log in to Sparkler

2 | 3 | <%= form_tag(login_user_path) do %> 4 | <% if flash[:alert] %> 5 |

<%= flash[:alert] %>

6 | <% end %> 7 | 8 | <% if flash[:notice] %> 9 |

<%= flash[:notice] %>

10 | <% end %> 11 | 12 |
13 | <%= label_tag :password, 'Enter admin password:' %> 14 | <%= password_field_tag :password %> 15 |
16 | 17 |
18 | <%= submit_tag 'Log in' %> 19 |
20 | <% end %> 21 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | # Install JavaScript dependencies if using Yarn 22 | # system('bin/yarn') 23 | 24 | 25 | # puts "\n== Copying sample files ==" 26 | # unless File.exist?('config/database.yml') 27 | # cp 'config/database.yml.sample', 'config/database.yml' 28 | # end 29 | 30 | puts "\n== Preparing database ==" 31 | system! 'bin/rails db:setup' 32 | 33 | puts "\n== Removing old logs and tempfiles ==" 34 | system! 'bin/rails log:clear tmp:clear' 35 | 36 | puts "\n== Restarting application server ==" 37 | system! 'bin/rails restart' 38 | end 39 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast 4 | # It gets overwritten when you run the `spring binstub` command 5 | 6 | unless defined?(Spring) 7 | require "rubygems" 8 | require "bundler" 9 | 10 | if match = Bundler.default_lockfile.read.match(/^GEM$.*?^ (?: )*spring \((.*?)\)$.*?^$/m) 11 | ENV["GEM_PATH"] = ([Bundler.bundle_path.to_s] + Gem.path).join(File::PATH_SEPARATOR) 12 | ENV["GEM_HOME"] = "" 13 | Gem.paths = ENV 14 | 15 | gem "spring", match[1] 16 | require "spring/binstub" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | # Pick the frameworks you want: 4 | require "active_model/railtie" 5 | # require "active_job/railtie" 6 | require "active_record/railtie" 7 | require "action_controller/railtie" 8 | # require "action_mailer/railtie" 9 | require "action_view/railtie" 10 | # require "action_cable/engine" 11 | require "sprockets/railtie" 12 | # require "rails/test_unit/railtie" 13 | 14 | # Require the gems listed in Gemfile, including any gems 15 | # you've limited to :test, :development, or :production. 16 | Bundler.require(*Rails.groups) 17 | 18 | module Sparkler 19 | class Application < Rails::Application 20 | VERSION = '1.2' 21 | 22 | # Initialize configuration defaults for originally generated Rails version. 23 | config.load_defaults 5.1 24 | 25 | # Settings in config/environments/* take precedence over those specified here. 26 | # Application configuration should go into files in config/initializers 27 | # -- all .rb files in that directory are automatically loaded. 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /config/database.yml.example: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: mysql2 3 | encoding: utf8 4 | host: 127.0.0.1 5 | username: root 6 | password: 7 | socket: /tmp/mysql.sock 8 | 9 | development: 10 | <<: *default 11 | database: sparkler_development 12 | 13 | test: 14 | <<: *default 15 | database: sparkler_test 16 | 17 | production: 18 | <<: *default 19 | database: sparkler_production 20 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | if Rails.root.join('tmp/caching-dev.txt').exist? 17 | config.action_controller.perform_caching = true 18 | 19 | config.cache_store = :memory_store 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}" 22 | } 23 | else 24 | config.action_controller.perform_caching = false 25 | 26 | config.cache_store = :null_store 27 | end 28 | 29 | # Don't care if the mailer can't send. 30 | ## config.action_mailer.raise_delivery_errors = false 31 | 32 | ## config.action_mailer.perform_caching = false 33 | 34 | # Print deprecation notices to the Rails logger. 35 | config.active_support.deprecation = :log 36 | 37 | # Raise an error on page load if there are pending migrations. 38 | config.active_record.migration_error = :page_load 39 | 40 | # Debug mode disables concatenation and preprocessing of assets. 41 | # This option may cause significant delays in view rendering with a large 42 | # number of complex assets. 43 | config.assets.debug = true 44 | 45 | # Suppress logger output for asset requests. 46 | config.assets.quiet = true 47 | 48 | # Raises error for missing translations 49 | # config.action_view.raise_on_missing_translations = true 50 | 51 | # Use an evented file watcher to asynchronously detect changes in source code, 52 | # routes, locales, etc. This feature depends on the listen gem. 53 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 54 | end 55 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Attempt to read encrypted secrets from `config/secrets.yml.enc`. 18 | # Requires an encryption key in `ENV["RAILS_MASTER_KEY"]` or 19 | # `config/secrets.yml.key`. 20 | config.read_encrypted_secrets = true 21 | 22 | # Disable serving static files from the `/public` folder by default since 23 | # Apache or NGINX already handles this. 24 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 25 | 26 | # Compress JavaScripts and CSS. 27 | config.assets.js_compressor = :uglifier 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 34 | 35 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 36 | # config.action_controller.asset_host = 'http://assets.example.com' 37 | 38 | # Specifies the header that your server uses for sending files. 39 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 40 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 41 | 42 | # Mount Action Cable outside main process or domain 43 | # config.action_cable.mount_path = nil 44 | # config.action_cable.url = 'wss://example.com/cable' 45 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 46 | 47 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 48 | # config.force_ssl = true 49 | 50 | config.log_level = :info 51 | 52 | # Prepend all log lines with the following tags. 53 | config.log_tags = [ :request_id ] 54 | 55 | # Use a different cache store in production. 56 | # config.cache_store = :mem_cache_store 57 | 58 | # Use a real queuing backend for Active Job (and separate queues per environment) 59 | # config.active_job.queue_adapter = :resque 60 | # config.active_job.queue_name_prefix = "sparkler_#{Rails.env}" 61 | ## config.action_mailer.perform_caching = false 62 | 63 | # Ignore bad email addresses and do not raise email delivery errors. 64 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 65 | # config.action_mailer.raise_delivery_errors = false 66 | 67 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 68 | # the I18n.default_locale when a translation cannot be found). 69 | config.i18n.fallbacks = true 70 | 71 | # Send deprecation notices to registered listeners. 72 | config.active_support.deprecation = :notify 73 | 74 | # Use default logging formatter so that PID and timestamp are not suppressed. 75 | config.log_formatter = ::Logger::Formatter.new 76 | 77 | # Use a different logger for distributed setups. 78 | # require 'syslog/logger' 79 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 80 | 81 | if ENV["RAILS_LOG_TO_STDOUT"].present? 82 | logger = ActiveSupport::Logger.new(STDOUT) 83 | logger.formatter = config.log_formatter 84 | config.logger = ActiveSupport::TaggedLogging.new(logger) 85 | end 86 | 87 | # Do not dump schema after migrations. 88 | config.active_record.dump_schema_after_migration = false 89 | end 90 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | 'Cache-Control' => "public, max-age=#{1.hour.seconds.to_i}" 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | ## config.action_mailer.perform_caching = false 31 | 32 | # Tell Action Mailer not to deliver emails to the real world. 33 | # The :test delivery method accumulates sent emails in the 34 | # ActionMailer::Base.deliveries array. 35 | ## config.action_mailer.delivery_method = :test 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ApplicationController.renderer.defaults.merge!( 4 | # http_host: 'example.org', 5 | # https: false 6 | # ) 7 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | # Add Yarn node_modules folder to the asset load path. 9 | Rails.application.config.assets.paths << Rails.root.join('node_modules') 10 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 15 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | ActiveSupport::Inflector.inflections(:en) do |inflect| 14 | inflect.irregular 'this', 'these' 15 | end 16 | 17 | # These inflection rules are supported but not enabled by default: 18 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 19 | # inflect.acronym 'RESTful' 20 | # end 21 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /config/initializers/new_framework_defaults_5_1.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains migration options to ease your Rails 5.1 upgrade. 4 | # 5 | # Once upgraded flip defaults one by one to migrate to the new default. 6 | # 7 | # Read the Guide for Upgrading Ruby on Rails for more info on each option. 8 | 9 | # Make `form_with` generate non-remote forms. 10 | Rails.application.config.action_view.form_with_generates_remote_forms = false 11 | 12 | # Unknown asset fallback will return the path passed in when the given 13 | # asset is not present in the asset pipeline. 14 | # Rails.application.config.assets.unknown_asset_fallback = false 15 | -------------------------------------------------------------------------------- /config/initializers/report_types.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | FeedReport.report_types = { 4 | 'Total feed downloads' => { 5 | :field => 'osVersion', 6 | :group_by => lambda { |v| "Downloads" }, 7 | :only_counts => true, 8 | :is_downloads => true 9 | }, 10 | 11 | 'macOS Version' => { 12 | :field => 'osVersion', 13 | :group_by => lambda { |v| v.split('.').first(2).join('.') }, 14 | :sort_by => lambda { |v| v.split('.').map(&:to_i) } 15 | }, 16 | 17 | 'Mac Class' => { 18 | :field => 'model', 19 | :group_by => lambda { |v| v[/^[[:alpha:]]+/] }, 20 | :options => { 21 | 'MacBookAir' => 'MacBook Air', 22 | 'MacBookPro' => 'MacBook Pro', 23 | 'Macmini' => 'Mac Mini', 24 | 'MacPro' => 'Mac Pro' 25 | } 26 | }, 27 | 28 | 'Popular Mac Models' => { 29 | :field => 'model', 30 | :threshold => 7.5, 31 | :show_other => false, 32 | :options => { 33 | 'iMac12,1' => 'iMac 21" (2011)', 34 | 'iMac12,2' => 'iMac 27" (2011)', 35 | 'iMac13,1' => 'iMac 21" (2012)', 36 | 'iMac13,2' => 'iMac 27" (2012)', 37 | 'iMac14,1' => 'iMac 21" (2013)', 38 | 'iMac14,2' => 'iMac 27" (2013)', 39 | 'iMac14,3' => 'iMac 21" (2013)', 40 | 'iMac14,4' => 'iMac 21" (2014)', 41 | 'iMac15,1' => 'Retina iMac 27" (2014-15)', 42 | 'iMac16,1' => 'iMac 21" (2015)', 43 | 'iMac16,2' => 'Retina iMac 21" (2015)', 44 | 'iMac17,1' => 'Retina iMac 27" (2015)', 45 | 'iMac18,1' => 'iMac 21" (2017)', 46 | 'iMac18,2' => 'Retina iMac 21" (2017)', 47 | 'iMac18,3' => 'Retina iMac 27" (2017)', 48 | 49 | 'MacBook8,1' => 'MacBook 12" (2015)', 50 | 'MacBook9,1' => 'MacBook 12" (2016)', 51 | 'MacBook10,1' => 'MacBook 12" (2017)', 52 | 53 | 'MacBookAir6,1' => 'MBA 11" (2013-14)', 54 | 'MacBookAir6,2' => 'MBA 13" (2013-14)', 55 | 'MacBookAir7,1' => 'MBA 11" (2015)', 56 | 'MacBookAir7,2' => 'MBA 13" (2015)', 57 | 58 | 'MacBookPro6,1' => 'MBP 17" (2010)', 59 | 'MacBookPro6,2' => 'MBP 15" (2010)', 60 | 'MacBookPro7,1' => 'MBP 13" (2010)', 61 | 'MacBookPro8,1' => 'MBP 13" (2011)', 62 | 'MacBookPro8,2' => 'MBP 15" (2011)', 63 | 'MacBookPro8,3' => 'MBP 17" (2011)', 64 | 'MacBookPro9,1' => 'MBP 15" (2012)', 65 | 'MacBookPro9,2' => 'MBP 13" (2012)', 66 | 'MacBookPro10,1' => 'Retina MBP 15" (2012-13)', 67 | 'MacBookPro10,2' => 'Retina MBP 13" (2012-13)', 68 | 'MacBookPro11,1' => 'Retina MBP 13" (2013-14)', 69 | 'MacBookPro11,2' => 'Retina MBP 15" (2013-14)', 70 | 'MacBookPro11,3' => 'Retina MBP 15" (2013-14)', 71 | 'MacBookPro11,4' => 'Retina MBP 15" (2015)', 72 | 'MacBookPro11,5' => 'Retina MBP 15" (2015)', 73 | 'MacBookPro12,1' => 'Retina MBP 13" (2015)', 74 | 'MacBookPro13,1' => 'Retina MBP 13" (2016)', 75 | 'MacBookPro13,2' => 'TouchBar MBP 13" (2016)', 76 | 'MacBookPro13,3' => 'TouchBar MBP 15" (2016)', 77 | 'MacBookPro14,1' => 'Retina MBP 13" (2017)', 78 | 'MacBookPro14,2' => 'TouchBar MBP 13" (2017)', 79 | 'MacBookPro14,3' => 'TouchBar MBP 15" (2017)', 80 | } 81 | }, 82 | 83 | 'Number of CPU Cores' => { 84 | :field => 'ncpu' 85 | }, 86 | 87 | 'CPU Type' => { 88 | :field => 'cputype', 89 | :options => { 90 | '7' => 'Intel', 91 | '18' => 'PowerPC' 92 | } 93 | }, 94 | 95 | 'CPU Subtype' => { 96 | :field => 'cpusubtype', 97 | :options => { 98 | '7.4' => 'X86_ARCH1', 99 | '7.8' => 'X86_64_H (Haswell)', 100 | '18.9' => 'PowerPC 750', 101 | '18.10' => 'PowerPC 7400', 102 | '18.11' => 'PowerPC 7450', 103 | '18.100' => 'PowerPC 970' 104 | } 105 | }, 106 | 107 | 'CPU Bits' => { 108 | :field => 'cpu64bit', 109 | :options => { 110 | '0' => '32-bit', 111 | '1' => '64-bit' 112 | } 113 | }, 114 | 115 | 'CPU Frequency' => { 116 | :field => 'cpuFreqMHz', 117 | :group_by => lambda { |v| 118 | ghz = (v.to_i / 500 * 5).to_f / 10 119 | "#{ghz} – #{ghz + 0.4} GHz" 120 | } 121 | }, 122 | 123 | 'Amount of RAM' => { 124 | :field => 'ramMB', 125 | :threshold => 2.5, 126 | :options => lambda { |v| "#{v.to_i / 1024} GB" } 127 | }, 128 | 129 | 'App Version' => { 130 | :field => 'appVersionShort', 131 | :sort_by => lambda { |v| v.split('.').map(&:to_i) } 132 | }, 133 | 134 | 'System Locale' => { 135 | :field => 'lang', 136 | :threshold => 2.5 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_sparkler_session' 4 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root 'feeds#index' 3 | 4 | resources :feeds do 5 | resources :statistics 6 | 7 | get :reload, on: :member 8 | end 9 | 10 | get '/feed/:id' => 'feeds#show' 11 | 12 | resource :user, controller: 'user', only: [:edit, :update] do 13 | member do 14 | get :login_form, :logout 15 | post :login 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | # Shared secrets are available across all environments. 14 | 15 | # shared: 16 | # api_key: a1B2c3D4e5F6 17 | 18 | # Environmental secrets are only available for that specific environment. 19 | 20 | development: 21 | secret_key_base: 4a0fb7cab80fe9e68686e0ec416427089fee1f037b3606508518ec90f2d7cb2c3ce59542d95b1a4f2534849400613acab2f4d6f12a79c85dcaeb20ced4b8a23d 22 | reload_key: reloadme! 23 | 24 | test: 25 | secret_key_base: 2add29d1e241f0d3da91652e620804366926b3f7985dacf8a753bdabbfbd6fea8e0a9287aa5d943aa20cd025d42646d781488b65b3284641eaa6bf567a9481b3 26 | reload_key: reloadme! 27 | 28 | # Do not keep production secrets in the unencrypted secrets file. 29 | # Instead, either read values from the environment. 30 | # Or, use `bin/rails secrets:setup` to configure encrypted secrets 31 | # and move the `production:` environment over there. 32 | 33 | production: 34 | secret_key_base: <%= File.read('config/secret_key_base.key') rescue nil %> 35 | reload_key: <%= ENV['X_RELOAD_KEY'] || File.read('config/reload_key.key') rescue nil %> 36 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | %w( 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ).each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /db/migrate/001_initial_tables.rb: -------------------------------------------------------------------------------- 1 | class InitialTables < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :feeds do |t| 4 | t.string :title, :name, :url, null: false 5 | end 6 | 7 | create_table :properties do |t| 8 | t.string :name, null: false 9 | end 10 | 11 | create_table :values do |t| 12 | t.references :property, null: false 13 | t.string :name, null: false 14 | end 15 | 16 | create_table :statistics do |t| 17 | t.references :feed, null: false 18 | t.integer :year, :month, null: false 19 | t.references :property, null: false 20 | t.references :value, null: false 21 | t.integer :counter, null: false, default: 0 22 | end 23 | 24 | add_index :statistics, [:feed_id, :year, :month, :property_id, :value_id], unique: true, name: 'stats_index' 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /db/migrate/20150411125726_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :users do |t| 4 | t.string :password_digest 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20150411205844_add_settings_to_feeds.rb: -------------------------------------------------------------------------------- 1 | class AddSettingsToFeeds < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :feeds, :public_stats, :boolean, null: false, default: false 4 | add_column :feeds, :public_counts, :boolean, null: false, default: false 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20150412193930_rename_values_to_options.rb: -------------------------------------------------------------------------------- 1 | class RenameValuesToOptions < ActiveRecord::Migration[4.2] 2 | def change 3 | rename_table :values, :options 4 | rename_column :statistics, :value_id, :option_id 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20150418165101_add_contents_to_feed.rb: -------------------------------------------------------------------------------- 1 | class AddContentsToFeed < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :feeds, :contents, :text 4 | add_column :feeds, :last_version, :string 5 | add_column :feeds, :load_error, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20150418181426_separate_statistics_per_day.rb: -------------------------------------------------------------------------------- 1 | class SeparateStatisticsPerDay < ActiveRecord::Migration[4.2] 2 | def up 3 | add_column :statistics, :date, :date, null: false, after: 'feed_id' 4 | 5 | Statistic.update_all "date = STR_TO_DATE(CONCAT(year, '-', month, '-1'), '%Y-%m-%d')" 6 | 7 | remove_index "statistics", name: 'stats_index' 8 | add_index "statistics", ["feed_id", "date", "property_id", "option_id"], name: 'stats_index', unique: true 9 | 10 | remove_column :statistics, :year 11 | remove_column :statistics, :month 12 | end 13 | 14 | def down 15 | add_column :statistics, :year, :integer, null: false, after: 'feed_id' 16 | add_column :statistics, :month, :integer, null: false, after: 'year' 17 | 18 | Statistic.update_all "year = YEAR(date), month = MONTH(date)" 19 | 20 | remove_index "statistics", name: 'stats_index' 21 | add_index "statistics", ["feed_id", "year", "month", "property_id", "option_id"], name: 'stats_index', unique: true 22 | 23 | remove_column :statistics, :date 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /db/migrate/20150419110659_add_inactive_to_feeds.rb: -------------------------------------------------------------------------------- 1 | class AddInactiveToFeeds < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :feeds, :inactive, :boolean, null: false, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20160520173336_add_unique_indexes.rb: -------------------------------------------------------------------------------- 1 | class AddUniqueIndexes < ActiveRecord::Migration[4.2] 2 | def change 3 | add_index :properties, :name, unique: true 4 | add_index :options, [:property_id, :name], unique: true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 20160520173336) do 14 | 15 | create_table "feeds", id: :integer, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| 16 | t.string "title", null: false 17 | t.string "name", null: false 18 | t.string "url", null: false 19 | t.boolean "public_stats", default: false, null: false 20 | t.boolean "public_counts", default: false, null: false 21 | t.text "contents" 22 | t.string "last_version" 23 | t.string "load_error" 24 | t.boolean "inactive", default: false, null: false 25 | end 26 | 27 | create_table "options", id: :integer, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| 28 | t.integer "property_id", null: false 29 | t.string "name", null: false 30 | t.index ["property_id", "name"], name: "index_options_on_property_id_and_name", unique: true 31 | end 32 | 33 | create_table "properties", id: :integer, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| 34 | t.string "name", null: false 35 | t.index ["name"], name: "index_properties_on_name", unique: true 36 | end 37 | 38 | create_table "statistics", id: :integer, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| 39 | t.integer "feed_id", null: false 40 | t.date "date", null: false 41 | t.integer "property_id", null: false 42 | t.integer "option_id", null: false 43 | t.integer "counter", default: 0, null: false 44 | t.index ["feed_id", "date", "property_id", "option_id"], name: "stats_index", unique: true 45 | end 46 | 47 | create_table "users", id: :integer, force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8" do |t| 48 | t.string "password_digest" 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | -------------------------------------------------------------------------------- /deploy/cap/Capfile: -------------------------------------------------------------------------------- 1 | # Load DSL and set up stages 2 | require "capistrano/setup" 3 | 4 | # Include default deployment tasks 5 | require "capistrano/deploy" 6 | 7 | require 'capistrano/default_stage' 8 | set :default_stage, :production 9 | 10 | require 'capistrano/bundler' 11 | require 'capistrano/rails' 12 | require 'capistrano/passenger' 13 | 14 | # Include tasks from other gems included in your Gemfile 15 | # 16 | # For documentation on these, see for example: 17 | # 18 | # https://github.com/capistrano/rvm 19 | # https://github.com/capistrano/rbenv 20 | # https://github.com/capistrano/chruby 21 | # https://github.com/capistrano/bundler 22 | # https://github.com/capistrano/rails 23 | # https://github.com/capistrano/passenger 24 | # 25 | # require 'capistrano/rvm' 26 | # require 'capistrano/rbenv' 27 | # require 'capistrano/chruby' 28 | # require 'capistrano/bundler' 29 | # require 'capistrano/rails/assets' 30 | # require 'capistrano/rails/migrations' 31 | # require 'capistrano/passenger' 32 | 33 | # Load custom tasks from `lib/capistrano/tasks` if you have any defined 34 | Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r } 35 | -------------------------------------------------------------------------------- /deploy/cap/Gemfile.deploy: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | group :development do 4 | gem 'capistrano', '~> 3.5', require: false 5 | gem 'capistrano-default_stage', '~> 0.1', require: false 6 | gem 'capistrano-bundler', '~> 1.1', require: false 7 | gem 'capistrano-rails', '~> 1.1', require: false 8 | gem 'capistrano-passenger', '~> 0.2', require: false 9 | end 10 | -------------------------------------------------------------------------------- /deploy/cap/Gemfile.deploy.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | airbrussh (1.0.2) 5 | sshkit (>= 1.6.1, != 1.7.0) 6 | capistrano (3.5.0) 7 | airbrussh (>= 1.0.0) 8 | capistrano-harrow 9 | i18n 10 | rake (>= 10.0.0) 11 | sshkit (>= 1.9.0) 12 | capistrano-bundler (1.1.4) 13 | capistrano (~> 3.1) 14 | sshkit (~> 1.2) 15 | capistrano-default_stage (0.1.0) 16 | capistrano (~> 3.0) 17 | capistrano-harrow (0.5.1) 18 | capistrano-passenger (0.2.0) 19 | capistrano (~> 3.0) 20 | capistrano-rails (1.1.6) 21 | capistrano (~> 3.1) 22 | capistrano-bundler (~> 1.1) 23 | i18n (0.7.0) 24 | net-scp (1.2.1) 25 | net-ssh (>= 2.6.5) 26 | net-ssh (3.1.1) 27 | rake (11.1.2) 28 | sshkit (1.10.0) 29 | net-scp (>= 1.1.2) 30 | net-ssh (>= 2.8.0) 31 | 32 | PLATFORMS 33 | ruby 34 | 35 | DEPENDENCIES 36 | capistrano (~> 3.5) 37 | capistrano-bundler (~> 1.1) 38 | capistrano-default_stage (~> 0.1) 39 | capistrano-passenger (~> 0.2) 40 | capistrano-rails (~> 1.1) 41 | 42 | BUNDLED WITH 43 | 1.11.2 44 | -------------------------------------------------------------------------------- /deploy/cap/deploy.rb: -------------------------------------------------------------------------------- 1 | # config valid only for current version of Capistrano 2 | lock '3.11.0' 3 | 4 | set :application, 'sparkle' 5 | set :repo_url, 'git@github.com:mackuba/sparkler.git' 6 | set :bundle_jobs, 2 7 | set :rails_env, 'production' 8 | 9 | # this is the path of the directory where the app will be deployed on your server (default = /var/www/app_name) 10 | set :deploy_to, '/var/www/sparkle' 11 | 12 | set :linked_files, fetch(:linked_files, []).push('config/database.yml', 'config/secret_key_base.key') 13 | set :linked_dirs, fetch(:linked_dirs, []).push('log', 'tmp/pids', 'public/assets', 'public/system') 14 | set :optional_symlinks, ['config/reload_key.key'] 15 | 16 | # Default branch is :master 17 | # ask :branch, `git rev-parse --abbrev-ref HEAD`.chomp 18 | 19 | # Default value for :format is :airbrussh. 20 | # set :format, :airbrussh 21 | 22 | # You can configure the Airbrussh format using :format_options. 23 | # These are the defaults. 24 | # set :format_options, command_output: true, log_file: 'log/capistrano.log', color: :auto, truncate: :auto 25 | 26 | # Default value for :pty is false 27 | # set :pty, true 28 | 29 | # Default value for default_env is {} 30 | # set :default_env, { path: "/opt/ruby/bin:$PATH" } 31 | 32 | # Default value for keep_releases is 5 33 | # set :keep_releases, 5 34 | 35 | after "deploy:symlink:linked_dirs", :optional_symlinks do 36 | on release_roles :all do |host| 37 | fetch(:optional_symlinks, []).each do |file| 38 | target = release_path.join(file) 39 | source = shared_path.join(file) 40 | 41 | if test("[ -f #{source} ]") 42 | next if test "[ -L #{target} ]" 43 | execute :rm, target if test "[ -f #{target} ]" 44 | execute :ln, "-s", source, target 45 | else 46 | info "Skipping optional symlink #{file}" 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /deploy/cap/install: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | set -x 5 | 6 | CAP_DIR=$(dirname "$0") 7 | BASE_DIR="$CAP_DIR/../.." 8 | 9 | cp "$CAP_DIR/Capfile" "$BASE_DIR" 10 | cp "$CAP_DIR/Gemfile.deploy" "$BASE_DIR" 11 | cp "$CAP_DIR/Gemfile.deploy.lock" "$BASE_DIR" 12 | 13 | mkdir -p "$BASE_DIR/config/deploy" 14 | cp "$CAP_DIR/deploy.rb" "$BASE_DIR/config" 15 | cp "$CAP_DIR/production.rb" "$BASE_DIR/config/deploy" 16 | -------------------------------------------------------------------------------- /deploy/cap/production.rb: -------------------------------------------------------------------------------- 1 | # server-based syntax 2 | # ====================== 3 | # Defines a single server with a list of roles and multiple properties. 4 | # You can define all roles on a single server, or split them: 5 | 6 | # server 'example.com', user: 'deploy', roles: %w{app db web}, my_property: :my_value 7 | # server 'example.com', user: 'deploy', roles: %w{app web}, other_property: :other_value 8 | # server 'db.example.com', user: 'deploy', roles: %w{db} 9 | 10 | # enter name or IP of your server here 11 | server 'example.com', user: 'deploy', roles: %w{app db web} 12 | 13 | 14 | # role-based syntax 15 | # ================== 16 | 17 | # Defines a role with one or multiple servers. The primary server in each 18 | # group is considered to be the first unless any hosts have the primary 19 | # property set. Specify the username and a domain or IP for the server. 20 | # Don't use `:all`, it's a meta role. 21 | 22 | # role :app, %w{deploy@example.com}, my_property: :my_value 23 | # role :web, %w{user1@primary.com user2@additional.com}, other_property: :other_value 24 | # role :db, %w{deploy@example.com} 25 | 26 | 27 | 28 | # Configuration 29 | # ============= 30 | # You can set any configuration variable like in config/deploy.rb 31 | # These variables are then only loaded and set in this stage. 32 | # For available Capistrano configuration variables see the documentation page. 33 | # http://capistranorb.com/documentation/getting-started/configuration/ 34 | # Feel free to add new variables to customise your setup. 35 | 36 | 37 | 38 | # Custom SSH Options 39 | # ================== 40 | # You may pass any option but keep in mind that net/ssh understands a 41 | # limited set of options, consult the Net::SSH documentation. 42 | # http://net-ssh.github.io/net-ssh/classes/Net/SSH.html#method-c-start 43 | # 44 | # Global options 45 | # -------------- 46 | # set :ssh_options, { 47 | # keys: %w(/home/rlisowski/.ssh/id_rsa), 48 | # forward_agent: false, 49 | # auth_methods: %w(password) 50 | # } 51 | # 52 | # The server-based syntax can be used to override options: 53 | # ------------------------------------ 54 | # server 'example.com', 55 | # user: 'user_name', 56 | # roles: %w{web app}, 57 | # ssh_options: { 58 | # user: 'user_name', # overrides user setting above 59 | # keys: %w(/home/user_name/.ssh/id_rsa), 60 | # forward_agent: false, 61 | # auth_methods: %w(publickey password) 62 | # # password: 'please use keys' 63 | # } 64 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackuba/sparkler/830e19fe581201d760eeb87b4a9df63ebc9594ca/lib/tasks/.keep -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackuba/sparkler/830e19fe581201d760eeb87b4a9df63ebc9594ca/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /spec/controllers/feeds_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe FeedsController do 4 | fixtures :feeds 5 | 6 | before { session[:logged_in] = true } 7 | 8 | let(:feed) { feeds(:feed1) } 9 | let(:new_url) { 'http://foo.bar' } 10 | 11 | def self.it_should_require_admin(&block) 12 | context 'if user is not logged in' do 13 | before { session.delete(:logged_in) } 14 | 15 | it 'should redirect to the login page' do 16 | instance_eval(&block) 17 | 18 | response.should redirect_to('/user/login_form') 19 | end 20 | end 21 | end 22 | 23 | describe '#show' do 24 | before do 25 | session.delete(:logged_in) 26 | stub_request(:get, feed.url) 27 | end 28 | 29 | context 'if feed is loaded' do 30 | before { feed.update_attributes(contents: 'txt') } 31 | 32 | it 'should return feed body' do 33 | get :show, params: { id: feed.name } 34 | 35 | response.should be_success 36 | response.body.should == 'txt' 37 | end 38 | 39 | it 'should not reload the feed' do 40 | get :show, params: { id: feed.name } 41 | 42 | WebMock.should_not have_requested(:get, feed.url) 43 | end 44 | end 45 | 46 | context 'if feed is not loaded' do 47 | before { feed.update_attributes(contents: nil) } 48 | 49 | it 'should reload the feed' do 50 | get :show, params: { id: feed.name } 51 | 52 | WebMock.should have_requested(:get, feed.url) 53 | end 54 | 55 | context 'if feed loading succeeds' do 56 | before { stub_request(:get, feed.url).to_return(body: 'foo') } 57 | 58 | it 'should return feed body' do 59 | get :show, params: { id: feed.name } 60 | 61 | response.should be_success 62 | response.body.should == 'foo' 63 | end 64 | end 65 | 66 | context 'if feed loading fails' do 67 | before { stub_request(:get, feed.url).to_return(status: 400) } 68 | 69 | it 'should return 404' do 70 | get :show, params: { id: feed.name } 71 | 72 | response.code.should == '404' 73 | end 74 | end 75 | end 76 | 77 | context 'if the request is made from the app using Sparkle' do 78 | before { @request.user_agent = 'MyApp/1.5 Sparkle/313' } 79 | 80 | it 'should save statistics based on GET parameters and user agent' do 81 | get :show, params: { id: feed.name, cpuType: '44' } 82 | 83 | feed.statistics.detect { |s| 84 | s.date == Date.today && s.property.name == 'cpuType' && s.option.name == '44' 85 | }.should_not be_nil 86 | 87 | feed.statistics.detect { |s| 88 | s.date == Date.today && s.property.name == 'appVersionShort' && s.option.name == '1.5' 89 | }.should_not be_nil 90 | end 91 | end 92 | 93 | context 'if the request is made from another app' do 94 | before { @request.user_agent = 'AppFresh/1.0.5 (909) (Mac OS X)' } 95 | 96 | it 'should not save any statistics' do 97 | get :show, params: { id: feed.name, cpuType: '44' } 98 | 99 | feed.statistics.detect { |s| s.date == Date.today }.should be_nil 100 | end 101 | end 102 | 103 | context 'if feed is inactive' do 104 | let(:feed) { feeds(:inactive) } 105 | 106 | it 'should return ActiveRecord::RecordNotFound' do 107 | expect { get :show, params: { id: feed.name }}.to raise_error(ActiveRecord::RecordNotFound) 108 | 109 | WebMock.should_not have_requested(:get, feed.url) 110 | end 111 | end 112 | end 113 | 114 | describe '#index' do 115 | it 'should load feeds' do 116 | get :index 117 | 118 | response.should be_success 119 | response.should render_template(:index) 120 | 121 | feeds = assigns(:feeds) 122 | feeds.should_not be_nil 123 | feeds.should_not be_empty 124 | end 125 | 126 | it 'should show all inactive feeds at the end' do 127 | get :index 128 | 129 | had_inactive = false 130 | 131 | assigns(:feeds).each do |feed| 132 | if feed.inactive? 133 | had_inactive = true 134 | end 135 | 136 | if had_inactive 137 | feed.should be_inactive 138 | end 139 | end 140 | end 141 | 142 | it_should_require_admin { get :index } 143 | end 144 | 145 | describe '#reload' do 146 | before do 147 | stub_request(:get, feed.url) 148 | feed.update_attributes(contents: 'txt') 149 | end 150 | 151 | it 'should reload the feed' do 152 | get :reload, params: { id: feed.name } 153 | 154 | WebMock.should have_requested(:get, feed.url) 155 | end 156 | 157 | it 'should redirect to the index page' do 158 | get :reload, params: { id: feed.name } 159 | 160 | response.should redirect_to(feeds_path) 161 | end 162 | 163 | context 'if request was made with XHR' do 164 | it 'should return a rendered feed partial instead' do 165 | get :reload, params: { id: feed.name}, xhr: true 166 | 167 | response.should be_success 168 | response.should render_template('_feed') 169 | end 170 | end 171 | 172 | context 'if user is not logged in', type: :request do 173 | before { session.delete(:logged_in) } 174 | 175 | it 'should redirect to the login page' do 176 | get "/feeds/#{feed.name}/reload" 177 | 178 | response.should redirect_to('/user/login_form') 179 | end 180 | 181 | it 'should not reload the feed' do 182 | get "/feeds/#{feed.name}/reload" 183 | 184 | WebMock.should_not have_requested(:get, feed.url) 185 | end 186 | 187 | context 'if a correct reload key is passed' do 188 | before { Rails.application.secrets.stub(reload_key: 'qwerty') } 189 | 190 | it 'should redirect to the index page' do 191 | get "/feeds/#{feed.name}/reload", headers: { 'X-Reload-Key': 'qwerty' } 192 | 193 | response.should redirect_to(feeds_path) 194 | end 195 | 196 | it 'should reload the feed' do 197 | get "/feeds/#{feed.name}/reload", headers: { 'X-Reload-Key': 'qwerty' } 198 | 199 | WebMock.should have_requested(:get, feed.url) 200 | end 201 | end 202 | 203 | context 'if an incorrect reload key is passed' do 204 | before { Rails.application.secrets.stub(reload_key: 'qwerty') } 205 | 206 | it 'should redirect to the login page' do 207 | get "/feeds/#{feed.name}/reload", headers: { 'X-Reload-Key': 'asdf' } 208 | 209 | response.should redirect_to('/user/login_form') 210 | end 211 | 212 | it 'should not reload the feed' do 213 | get "/feeds/#{feed.name}/reload", headers: { 'X-Reload-Key': 'asdf' } 214 | 215 | WebMock.should_not have_requested(:get, feed.url) 216 | end 217 | end 218 | 219 | context 'if reload key is not passed and not set in the settings' do 220 | before { Rails.application.secrets.stub(reload_key: nil) } 221 | 222 | it 'should redirect to the login page' do 223 | get "/feeds/#{feed.name}/reload" 224 | 225 | response.should redirect_to('/user/login_form') 226 | end 227 | 228 | it 'should not reload the feed' do 229 | get "/feeds/#{feed.name}/reload" 230 | 231 | WebMock.should_not have_requested(:get, feed.url) 232 | end 233 | end 234 | end 235 | end 236 | 237 | describe '#new' do 238 | it 'should load a new feed form' do 239 | get :new 240 | 241 | response.should be_success 242 | response.should render_template(:new) 243 | end 244 | 245 | it_should_require_admin { get :new } 246 | end 247 | 248 | describe '#create' do 249 | let(:params) {{ name: 'foo', title: 'Foo', url: new_url }} 250 | 251 | context 'if feed is valid' do 252 | before do 253 | stub_request(:get, new_url) 254 | end 255 | 256 | it 'should save the feed' do 257 | post :create, params: { feed: params } 258 | 259 | Feed.find_by_name(params[:name]).should_not be_nil 260 | end 261 | 262 | it 'should redirect to the index page' do 263 | post :create, params: { feed: params } 264 | 265 | response.should redirect_to(feeds_path) 266 | end 267 | 268 | it 'should load the feed' do 269 | post :create, params: { feed: params } 270 | 271 | WebMock.should have_requested(:get, new_url) 272 | end 273 | end 274 | 275 | context 'if feed is invalid' do 276 | it 'should render the new feed form again' do 277 | post :create, params: { feed: params.merge(title: '') } 278 | 279 | response.should render_template(:new) 280 | end 281 | end 282 | 283 | it_should_require_admin { post :create, params: { feed: params }} 284 | end 285 | 286 | describe '#edit' do 287 | it 'should load an edit form' do 288 | get :edit, params: { id: feed.name } 289 | 290 | response.should be_success 291 | response.should render_template(:edit) 292 | end 293 | 294 | it_should_require_admin { get :edit, params: { id: feed.name }} 295 | end 296 | 297 | describe '#update' do 298 | context 'if feed is valid' do 299 | before do 300 | stub_request(:get, feed.url) 301 | feed.update_attributes(contents: 'txt') 302 | end 303 | 304 | it 'should save the feed' do 305 | patch :update, params: { id: feed.name, feed: { name: 'foo' }} 306 | 307 | feed.reload 308 | 309 | feed.name.should == 'foo' 310 | end 311 | 312 | it 'should redirect to the index page' do 313 | patch :update, params: { id: feed.name, feed: { name: 'foo' }} 314 | 315 | response.should redirect_to(feeds_path) 316 | end 317 | 318 | context 'if url was changed' do 319 | it 'should reload the feed' do 320 | stub_request(:get, new_url) 321 | old_url = feed.url 322 | 323 | patch :update, params: { id: feed.name, feed: { url: new_url }} 324 | 325 | WebMock.should_not have_requested(:get, old_url) 326 | WebMock.should have_requested(:get, new_url) 327 | end 328 | end 329 | 330 | context 'if url was not changed' do 331 | it 'should not reload the feed' do 332 | patch :update, params: { id: feed.name, feed: { name: 'foo' }} 333 | 334 | WebMock.should_not have_requested(:get, feed.url) 335 | end 336 | end 337 | end 338 | 339 | context 'if feed is invalid' do 340 | it 'should render the edit form again' do 341 | patch :update, params: { id: feed.name, feed: { name: '' }} 342 | 343 | response.should render_template(:edit) 344 | end 345 | end 346 | 347 | it_should_require_admin { patch :update, params: { id: feed.name, feed: { name: 'foo' }}} 348 | end 349 | end 350 | -------------------------------------------------------------------------------- /spec/controllers/statistics_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe StatisticsController do 4 | fixtures :feeds 5 | 6 | describe '#index' do 7 | before { session[:logged_in] = true } 8 | 9 | context 'if feed is inactive' do 10 | let(:feed) { feeds(:inactive) } 11 | 12 | it 'should return ActiveRecord::RecordNotFound' do 13 | expect { get :index, params: { feed_id: feed.name }}.to raise_error(ActiveRecord::RecordNotFound) 14 | end 15 | end 16 | 17 | context 'if feed is private' do 18 | let(:feed) { feeds(:private) } 19 | 20 | context 'if user is logged in' do 21 | it 'should render the page' do 22 | get :index, params: { feed_id: feed.name } 23 | 24 | response.should be_success 25 | end 26 | 27 | it 'should include counts' do 28 | get :index, params: { feed_id: feed.name } 29 | 30 | assigns(:include_counts).should == true 31 | end 32 | end 33 | 34 | context 'if user is not logged in' do 35 | before { session.delete(:logged_in) } 36 | 37 | it 'should redirect to login page' do 38 | get :index, params: { feed_id: feed.name } 39 | 40 | response.should redirect_to('/user/login_form') 41 | end 42 | end 43 | end 44 | 45 | context 'if feed is public' do 46 | let(:feed) { feeds(:public) } 47 | 48 | context 'if user is not logged in' do 49 | before { session.delete(:logged_in) } 50 | 51 | it 'should render the page' do 52 | get :index, params: { feed_id: feed.name } 53 | 54 | response.should be_success 55 | end 56 | 57 | it 'should include counts' do 58 | get :index, params: { feed_id: feed.name } 59 | 60 | assigns(:include_counts).should == true 61 | end 62 | end 63 | end 64 | 65 | context 'if feed is public without counts' do 66 | let(:feed) { feeds(:public_no_counts) } 67 | 68 | context 'if user is logged in' do 69 | it 'should render the page' do 70 | get :index, params: { feed_id: feed.name } 71 | 72 | response.should be_success 73 | end 74 | 75 | it 'should include counts' do 76 | get :index, params: { feed_id: feed.name } 77 | 78 | assigns(:include_counts).should == true 79 | end 80 | end 81 | 82 | context 'if user is not logged in' do 83 | before { session.delete(:logged_in) } 84 | 85 | it 'should render the page' do 86 | get :index, params: { feed_id: feed.name } 87 | 88 | response.should be_success 89 | end 90 | 91 | it 'should NOT include counts' do 92 | get :index, params: { feed_id: feed.name } 93 | 94 | assigns(:include_counts).should be_falsy 95 | end 96 | end 97 | end 98 | end 99 | end -------------------------------------------------------------------------------- /spec/fixtures/feeds.yml: -------------------------------------------------------------------------------- 1 | feed1: 2 | name: chrome 3 | title: Google Chrome 4 | url: http://chrome.google.com/appcast.xml 5 | 6 | inactive: 7 | name: old 8 | title: Feed v1 9 | url: http://archive.org/feed.xml 10 | inactive: true 11 | 12 | private: 13 | name: secret 14 | title: Secret Project 15 | url: http://foo.bar.pl 16 | public_stats: false 17 | 18 | public: 19 | name: hive 20 | title: Hive Mac 21 | url: http://mac.hivewallet.com/feed 22 | public_stats: true 23 | public_counts: true 24 | 25 | public_no_counts: 26 | name: adium 27 | title: Adium IM 28 | url: http://adium.im/feed.xml 29 | public_stats: true 30 | public_counts: false 31 | 32 | -------------------------------------------------------------------------------- /spec/models/feed_report_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe FeedReport do 4 | fixtures :feeds 5 | 6 | let(:feed) { feeds(:feed1) } 7 | let(:options) {{ }} 8 | let(:report_types) { nil } 9 | let(:report) { FeedReport.new(feed, options.merge(report_types: report_types)) } 10 | let(:saver) { StatisticSaver.new(feed) } 11 | 12 | it 'should generate reports' do 13 | report.reports.should be_an(Array) 14 | report.reports.should_not be_empty 15 | end 16 | 17 | context 'if counts should be included' do 18 | let(:options) {{ include_counts: true }} 19 | let(:report_types) {{ 'Counts' => { field: 'a', only_counts: true }, 'Stats' => { field: 'b' } }} 20 | 21 | it 'should include reports that only list absolute counts' do 22 | report.reports.map { |r| r[:title] }.should == ['Counts', 'Stats'] 23 | end 24 | 25 | it 'should include only absolute amounts in reports with only_counts flag' do 26 | saver.save_param(Date.today, 'a', '1') 27 | saver.save_param(Date.today, 'b', '2') 28 | 29 | report.reports[0][:series][0][:amounts].should be_an(Array) 30 | report.reports[0][:series][0][:normalized].should be_nil 31 | end 32 | 33 | it 'should include both absolute amounts and percentages in reports without only_counts flag' do 34 | saver.save_param(Date.today, 'a', '1') 35 | saver.save_param(Date.today, 'b', '2') 36 | 37 | report.reports[1][:series][0][:amounts].should be_an(Array) 38 | report.reports[1][:series][0][:normalized].should be_an(Array) 39 | end 40 | end 41 | 42 | context 'if counts should not be included' do 43 | let(:report_types) {{ 'Counts' => { field: 'a', only_counts: true }, 'Stats' => { field: 'b' } }} 44 | 45 | it 'should not include reports that only list absolute counts' do 46 | report.reports.map { |r| r[:title] }.should == ['Stats'] 47 | end 48 | 49 | it 'should include only percentages in reports without only_counts flag' do 50 | saver.save_param(Date.today, 'a', '1') 51 | saver.save_param(Date.today, 'b', '2') 52 | 53 | report.reports[0][:series][0][:amounts].should be_nil 54 | report.reports[0][:series][0][:normalized].should be_an(Array) 55 | end 56 | end 57 | 58 | context "if necessary properties don't exist yet" do 59 | let(:report_types) {{ 'Foo' => { field: 'appSize' }}} 60 | 61 | it 'should create them' do 62 | Property.find_by_name('appSize').should be_nil 63 | 64 | report 65 | 66 | Property.find_by_name('appSize').should_not be_nil 67 | end 68 | end 69 | 70 | it 'should copy title from definition' do 71 | report = FeedReport.new(feed, report_types: { 'Foo' => { field: 'a' }}) 72 | 73 | report.reports.first[:title].should == 'Foo' 74 | end 75 | 76 | it 'should copy :is_downloads flag from definition' do 77 | report = FeedReport.new(feed, report_types: { 'Foo' => { field: 'a', is_downloads: true }}) 78 | 79 | report.reports.first[:is_downloads].should == true 80 | end 81 | 82 | it 'should copy :show_other => false flag from definition' do 83 | report = FeedReport.new(feed, report_types: { 'Foo' => { field: 'a', show_other: false }}) 84 | 85 | report.reports.first[:show_other].should == false 86 | end 87 | 88 | it 'should calculate list of months with any statistics for the given feed, including empty months' do 89 | saver.save_param(Date.new(2015, 1, 3), 'color', 'red') 90 | saver.save_param(Date.new(2015, 3, 3), 'color', 'blue') 91 | saver.save_param(Date.new(2015, 3, 3), 'color', 'red') 92 | saver.save_param(Date.new(2015, 6, 1), 'color', 'green') 93 | saver.save_param(Date.new(2015, 6, 3), 'version', '1.0') 94 | 95 | report = FeedReport.new(feed, report_types: { 'Foo' => { field: 'color' }}) 96 | 97 | report.reports.first[:months].should == ['2015-01', '2015-02', '2015-03', '2015-04', '2015-05', '2015-06'] 98 | end 99 | 100 | describe ':initial_range' do 101 | let(:report_types) {{ 'Foo' => { field: 'color' }}} 102 | 103 | context "if there's only one month of data" do 104 | before do 105 | saver.save_param(Date.new(2015, 1, 3), 'color', 'red') 106 | end 107 | 108 | it 'should be set to "month"' do 109 | report.reports.first[:initial_range].should == 'month' 110 | end 111 | end 112 | 113 | context "if there are several months of data (but less than 12)" do 114 | before do 115 | 1.upto(10) { |i| saver.save_param(Date.new(2014, i, 1), 'color', 'red') } 116 | end 117 | 118 | it 'should be set to "year"' do 119 | report.reports.first[:initial_range].should == 'year' 120 | end 121 | end 122 | 123 | context "if there are more than 12 months of data" do 124 | before do 125 | 1.upto(12) { |i| saver.save_param(Date.new(2014, i, 1), 'color', 'red') } 126 | 1.upto(3) { |i| saver.save_param(Date.new(2015, i, 1), 'color', 'red') } 127 | end 128 | 129 | it 'should be set to "all"' do 130 | report.reports.first[:initial_range].should == 'all' 131 | end 132 | end 133 | end 134 | 135 | describe ':series' do 136 | subject { report.reports.first[:series] } 137 | 138 | let(:report_types) {{ 'Foo' => { field: 'value' }}} 139 | let(:options) {{ include_counts: true }} 140 | 141 | it 'should calculate amounts and percentages for all months' do 142 | saver.save_param(Date.new(2015, 1, 3), 'value', 'iMac') 143 | saver.save_param(Date.new(2015, 1, 5), 'value', 'iMac') 144 | saver.save_param(Date.new(2015, 1, 13), 'value', 'MacBook') 145 | saver.save_param(Date.new(2015, 1, 13), 'value', 'iMac') 146 | saver.save_param(Date.new(2015, 1, 13), 'value', 'iMac') 147 | saver.save_param(Date.new(2015, 1, 30), 'value', 'iMac') 148 | saver.save_param(Date.new(2015, 2, 1), 'value', 'iMac') 149 | saver.save_param(Date.new(2015, 4, 3), 'value', 'MacBook') 150 | 151 | subject.detect { |l| l[:title] == 'iMac' }[:amounts].should == [5, 1, 0, 0] 152 | subject.detect { |l| l[:title] == 'MacBook' }[:amounts].should == [1, 0, 0, 1] 153 | 154 | subject.detect { |l| l[:title] == 'iMac' }[:normalized].should == [83.3, 100.0, 0.0, 0.0] 155 | subject.detect { |l| l[:title] == 'MacBook' }[:normalized].should == [16.7, 0.0, 0.0, 100.0] 156 | end 157 | 158 | it "should delete options that don't have any non-zero amounts" do 159 | saver.save_param(Date.today, 'value', 'red') 160 | saver.save_param(Date.today, 'value', 'blue') 161 | 162 | Property.find_by_name('value').options.create!(name: 'yellow') 163 | Property.find_by_name('value').options.create!(name: 'black') 164 | 165 | subject.map { |l| l[:title] }.should == ['blue', 'red'] 166 | end 167 | 168 | context 'if grouping is defined' do 169 | let(:report_types) {{ 'Foo' => { field: 'value', group_by: proc { |x| x =~ /^iP/ ? 'mobile' : 'computer' }}}} 170 | 171 | it 'should group labels using the defined function' do 172 | ['iPhone', 'iPad', 'MacBook', 'iPhone', 'iMac', 'MacBook', 'iPhone', 'iPhone', 'iMac'].each do |name| 173 | saver.save_param(Date.today, 'value', name) 174 | end 175 | 176 | subject[0][:title].should == 'computer' 177 | subject[0][:amounts].should == [4] 178 | subject[0][:normalized].should == [44.4] 179 | subject[1][:title].should == 'mobile' 180 | subject[1][:amounts].should == [5] 181 | subject[1][:normalized].should == [55.6] 182 | end 183 | end 184 | 185 | describe 'sorting' do 186 | it 'should sort labels alphabetically' do 187 | saver.save_param(Date.today, 'value', 'red') 188 | saver.save_param(Date.today, 'value', 'blue') 189 | saver.save_param(Date.today, 'value', 'green') 190 | saver.save_param(Date.today, 'value', 'FFE0E0') 191 | 192 | subject.map { |l| l[:title] }.should == ['blue', 'FFE0E0', 'green', 'red'] 193 | end 194 | 195 | it 'should try to sort labels numerically if possible' do 196 | saver.save_param(Date.today, 'value', '1 MB') 197 | saver.save_param(Date.today, 'value', '50 MB') 198 | saver.save_param(Date.today, 'value', '10 MB') 199 | saver.save_param(Date.today, 'value', '2 MB') 200 | 201 | subject.map { |l| l[:title] }.should == ['1 MB', '2 MB', '10 MB', '50 MB'] 202 | end 203 | 204 | context 'if a custom sorter is defined' do 205 | let(:report_types) {{ 'Foo' => { field: 'value', sort_by: lambda { |x| x.length }}}} 206 | 207 | it 'should sort the values using the defined function' do 208 | saver.save_param(Date.today, 'value', 'tiny') 209 | saver.save_param(Date.today, 'value', 'small') 210 | saver.save_param(Date.today, 'value', 'average') 211 | saver.save_param(Date.today, 'value', 'big') 212 | 213 | subject.map { |l| l[:title] }.should == ['big', 'tiny', 'small', 'average'] 214 | end 215 | end 216 | 217 | context 'if grouping is also defined' do 218 | let(:report_types) {{ 'Foo' => { 219 | field: 'value', 220 | sort_by: lambda { |x| x.reverse }, 221 | group_by: lambda { |x| x[0..1].upcase } 222 | }}} 223 | 224 | it 'should do the grouping before the sorting' do 225 | saver.save_param(Date.today, 'value', 'france') 226 | saver.save_param(Date.today, 'value', 'portugal') 227 | saver.save_param(Date.today, 'value', 'norway') 228 | saver.save_param(Date.today, 'value', 'hungary') 229 | saver.save_param(Date.today, 'value', 'poland') 230 | 231 | subject.map { |l| l[:title] }.should == ['NO', 'PO', 'FR', 'HU'] 232 | end 233 | end 234 | 235 | context 'if converting proc is also defined' do 236 | let(:report_types) {{ 'Foo' => { 237 | field: 'value', 238 | sort_by: lambda { |x| x[1..-1] }, 239 | options: lambda { |x| x.reverse } 240 | }}} 241 | 242 | it 'should do the converting before the sorting' do 243 | saver.save_param(Date.today, 'value', 'france') 244 | saver.save_param(Date.today, 'value', 'norway') 245 | saver.save_param(Date.today, 'value', 'poland') 246 | 247 | subject.map { |l| l[:title] }.should == ['yawron', 'ecnarf', 'dnalop'] 248 | end 249 | end 250 | end 251 | 252 | describe 'converting labels' do 253 | context 'if a proc is given' do 254 | let(:report_types) {{ 'Foo' => { field: 'value', options: lambda { |x| x.upcase }}}} 255 | 256 | it 'should pass the labels through the proc' do 257 | saver.save_param(Date.today, 'value', 'foo') 258 | saver.save_param(Date.today, 'value', 'bar') 259 | 260 | subject.map { |l| l[:title] }.should == ['BAR', 'FOO'] 261 | end 262 | end 263 | 264 | context 'if a hash is given' do 265 | let(:report_types) {{ 'Foo' => { field: 'value', options: { 'foo' => 'a', 'bar' => 'b' }}}} 266 | 267 | it 'should use the hash to map labels to proper titles' do 268 | saver.save_param(Date.today, 'value', 'foo') 269 | saver.save_param(Date.today, 'value', 'bar') 270 | 271 | subject.map { |l| l[:title] }.should == ['a', 'b'] 272 | end 273 | 274 | it "should use original labels if they don't appear in the hash" do 275 | saver.save_param(Date.today, 'value', 'foo') 276 | saver.save_param(Date.today, 'value', 'bar') 277 | saver.save_param(Date.today, 'value', 'baz') 278 | 279 | subject.map { |l| l[:title] }.should == ['a', 'b', 'baz'] 280 | end 281 | end 282 | 283 | context 'if grouping is also defined' do 284 | let(:report_types) {{ 'Foo' => { 285 | field: 'value', 286 | options: lambda { |x| x.gsub(/Book(\w)/, 'Book \1') }, 287 | group_by: lambda { |x| x[/^[a-z]+/i] } 288 | }}} 289 | 290 | it 'should do the grouping before the converting' do 291 | saver.save_param(Date.today, 'value', 'MacBookPro1,2') 292 | saver.save_param(Date.today, 'value', 'MacBookAir1,3') 293 | saver.save_param(Date.today, 'value', 'MacBookPro5,6') 294 | 295 | subject.map { |l| l[:title] }.should == ['MacBook Air', 'MacBook Pro'] 296 | end 297 | end 298 | end 299 | 300 | context 'if threshold is set' do 301 | let(:report_types) {{ 'Foo' => { 302 | field: 'color', 303 | threshold: 20.0 304 | }}} 305 | 306 | it 'should group datasets that never get above percent into an "Other" dataset' do 307 | saver.save_param(Date.new(2015, 1, 1), 'color', 'red') 308 | saver.save_param(Date.new(2015, 1, 2), 'color', 'blue') 309 | saver.save_param(Date.new(2015, 1, 3), 'color', 'green') 310 | 311 | saver.save_param(Date.new(2015, 2, 1), 'color', 'yellow') 312 | saver.save_param(Date.new(2015, 2, 1), 'color', 'red') 313 | 314 | saver.save_param(Date.new(2015, 3, 1), 'color', 'red') 315 | saver.save_param(Date.new(2015, 3, 2), 'color', 'blue') 316 | saver.save_param(Date.new(2015, 3, 2), 'color', 'red') 317 | saver.save_param(Date.new(2015, 3, 3), 'color', 'red') 318 | saver.save_param(Date.new(2015, 3, 4), 'color', 'green') 319 | saver.save_param(Date.new(2015, 3, 5), 'color', 'black') 320 | saver.save_param(Date.new(2015, 3, 10), 'color', 'white') 321 | 322 | subject.map { |l| l[:title] }.should == ['blue', 'green', 'red', 'yellow', 'Other'] 323 | 324 | subject.last[:amounts].should == [0, 0, 2] 325 | subject.last[:normalized].should == [0, 0, 28.6] 326 | subject.last[:is_other].should == true 327 | end 328 | end 329 | end 330 | end 331 | -------------------------------------------------------------------------------- /spec/models/feed_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe Feed do 4 | fixtures :feeds 5 | 6 | let(:feed) { Feed.first } 7 | let(:last_version) { '2.0' } 8 | let(:xml) { %( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | )} 18 | 19 | describe '#load_contents' do 20 | it 'should load feed contents' do 21 | stub_request(:get, feed.url).to_return(body: xml) 22 | 23 | feed.load_contents 24 | 25 | feed.contents.should == xml 26 | end 27 | 28 | it 'should reset the error property' do 29 | feed.load_error = StandardError.new 30 | 31 | stub_request(:get, feed.url).to_return(body: xml) 32 | 33 | feed.load_contents 34 | 35 | feed.load_error.should be_nil 36 | end 37 | 38 | context 'if the location is a local path' do 39 | before { feed.update_attributes(url: File.expand_path(__FILE__)) } 40 | 41 | it 'should load the file from there' do 42 | expect { feed.load_contents }.not_to raise_error 43 | 44 | feed.load_error.should be_nil 45 | feed.contents.should == File.read(__FILE__) 46 | end 47 | end 48 | 49 | context 'if the location redirects from HTTP to HTTPS' do 50 | it 'should follow the redirect' do 51 | https_url = feed.url.gsub(/http:/, 'https:') 52 | stub_request(:get, feed.url).to_return(:status => 301, :headers => { 'Location' => https_url }) 53 | stub_request(:get, https_url).to_return(body: xml) 54 | 55 | expect { feed.load_contents }.not_to raise_error 56 | 57 | feed.load_error.should be_nil 58 | feed.contents.should == xml 59 | end 60 | end 61 | 62 | it 'should parse last version number from the feed' do 63 | stub_request(:get, feed.url).to_return(body: xml) 64 | 65 | feed.load_contents 66 | 67 | feed.last_version.should == last_version 68 | end 69 | 70 | context 'if items include both version and build number' do 71 | let(:xml) { %( 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | )} 81 | 82 | it 'should take the short version string' do 83 | stub_request(:get, feed.url).to_return(body: xml) 84 | 85 | feed.load_contents 86 | 87 | feed.last_version.should == last_version 88 | end 89 | end 90 | 91 | context 'if items include version string in a tag' do 92 | let(:xml) { %( 93 | 94 | 95 | #{last_version} 96 | 97 | 98 | 0.1 99 | 100 | 101 | )} 102 | 103 | it 'should parse the version correctly' do 104 | stub_request(:get, feed.url).to_return(body: xml) 105 | 106 | feed.load_contents 107 | 108 | feed.last_version.should == last_version 109 | end 110 | end 111 | 112 | context "if the feed doesn't contain any items" do 113 | let(:xml) { "" } 114 | 115 | it 'should not raise any errors' do 116 | stub_request(:get, feed.url).to_return(body: xml) 117 | 118 | expect { feed.load_contents }.not_to raise_error 119 | 120 | feed.contents.should == "" 121 | end 122 | 123 | it 'should not set the error property' do 124 | stub_request(:get, feed.url).to_return(body: xml) 125 | 126 | feed.load_contents 127 | 128 | feed.load_error.should be_nil 129 | end 130 | 131 | it 'should set last_version to nil' do 132 | stub_request(:get, feed.url).to_return(body: xml) 133 | 134 | feed.load_contents 135 | 136 | feed.last_version.should be_nil 137 | end 138 | end 139 | 140 | context 'if the versions are not sorted newest to oldest' do 141 | let(:xml) { %( 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | )} 151 | 152 | it 'should find the newest release on the list' do 153 | stub_request(:get, feed.url).to_return(body: xml) 154 | 155 | feed.load_contents 156 | 157 | feed.last_version.should == '1.7' 158 | end 159 | end 160 | 161 | context "if feed can't be loaded" do 162 | it 'should not raise an exception' do 163 | stub_request(:get, feed.url).to_raise(SocketError) 164 | 165 | expect { feed.load_contents }.not_to raise_error 166 | end 167 | 168 | it 'should set the error property' do 169 | stub_request(:get, feed.url).to_raise(SocketError) 170 | 171 | feed.load_contents 172 | 173 | feed.load_error.should_not be_nil 174 | end 175 | 176 | context 'if data was previously loaded' do 177 | before { feed.contents = 'ffffff' } 178 | 179 | it 'should not reset it' do 180 | stub_request(:get, feed.url).to_raise(SocketError) 181 | 182 | feed.load_contents 183 | 184 | feed.contents.should_not be_nil 185 | end 186 | end 187 | end 188 | 189 | context "if the feed doesn't include sparkle namespace" do 190 | let(:xml) { %( 191 | 192 | 193 | #{last_version} 194 | 195 | 196 | 0.1 197 | 198 | 199 | )} 200 | 201 | it 'should not raise an exception' do 202 | stub_request(:get, feed.url).to_return(body: xml) 203 | 204 | expect { feed.load_contents }.not_to raise_error 205 | end 206 | 207 | it 'should set the error property' do 208 | stub_request(:get, feed.url).to_return(body: xml) 209 | 210 | feed.load_contents 211 | 212 | feed.load_error.should_not be_nil 213 | end 214 | end 215 | end 216 | 217 | describe '#load_if_needed' do 218 | context 'if feed is not loaded yet' do 219 | it 'should load it' do 220 | data = stub_request(:get, feed.url) 221 | 222 | feed.load_if_needed 223 | 224 | data.should have_been_requested 225 | end 226 | end 227 | 228 | context 'if feed is already loaded' do 229 | before { feed.contents = 'xxxxx' } 230 | 231 | it 'should not load it' do 232 | data = stub_request(:get, feed.url) 233 | 234 | feed.load_if_needed 235 | 236 | data.should_not have_been_requested 237 | end 238 | end 239 | end 240 | 241 | context 'when feed is edited' do 242 | before do 243 | feed.contents = 'xxxxxxx' 244 | feed.last_version = '1.0' 245 | feed.load_error = Exception.new 246 | feed.save! 247 | end 248 | 249 | context 'if url is changed' do 250 | it 'should reset cached data' do 251 | feed.update_attributes(url: 'http://gazeta.pl') 252 | 253 | feed.contents.should be_nil 254 | feed.last_version.should be_nil 255 | feed.load_error.should be_nil 256 | end 257 | end 258 | 259 | context 'if url is not changed' do 260 | it 'should not reset cached data' do 261 | feed.update_attributes(name: 'blblbllb') 262 | 263 | feed.contents.should_not be_nil 264 | feed.last_version.should_not be_nil 265 | feed.load_error.should_not be_nil 266 | end 267 | end 268 | end 269 | end 270 | -------------------------------------------------------------------------------- /spec/models/statistic_saver_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe StatisticSaver do 4 | fixtures :feeds 5 | 6 | let(:feed) { Feed.first } 7 | let(:date) { Date.today } 8 | subject { StatisticSaver.new(feed) } 9 | 10 | describe '#save_param' do 11 | context "if a given property doesn't exist" do 12 | it 'should create it' do 13 | Property.find_by_name('foobars').should be_nil 14 | 15 | subject.save_param(date, 'foobars', '1000') 16 | 17 | Property.find_by_name('foobars').should_not be_nil 18 | end 19 | end 20 | 21 | context "if a given option doesn't exist" do 22 | it 'should create it' do 23 | Option.find_by(name: '1000').should be_nil 24 | 25 | subject.save_param(date, 'foobars', '1000') 26 | 27 | property = Property.find_by_name('foobars') 28 | property.options.find_by(name: '1000').should_not be_nil 29 | end 30 | end 31 | 32 | context "if a statistic for a given option doesn't exist yet" do 33 | it 'should create it with counter set to 1' do 34 | subject.save_param(date, 'foobars', '1000') 35 | 36 | property = Property.find_by_name('foobars') 37 | option = property.options.find_by_name('1000') 38 | 39 | statistic = Statistic.find_by(property: property, option: option, date: date) 40 | statistic.should_not be_nil 41 | statistic.counter.should == 1 42 | end 43 | end 44 | 45 | context 'if a statistic for a given option already exists' do 46 | it 'should not create it again' do 47 | subject.save_param(date, 'foobars', '1000') 48 | 49 | expect { subject.save_param(date, 'foobars', '1000') }.not_to change(Statistic, :count) 50 | end 51 | 52 | it 'should increase its counter' do 53 | subject.save_param(date, 'foobars', '1000') 54 | subject.save_param(date, 'foobars', '1000') 55 | 56 | property = Property.find_by_name('foobars') 57 | option = property.options.find_by_name('1000') 58 | 59 | statistic = Statistic.find_by(property: property, option: option, date: date) 60 | statistic.counter.should == 2 61 | end 62 | end 63 | end 64 | 65 | describe '#save_params' do 66 | let(:user_agent) { 'Sparkler/1.0 foo/5' } 67 | 68 | it 'should create statistics based on the params' do 69 | params = { 'color' => 'green', 'size' => 'XL' } 70 | 71 | subject.save_params(params, user_agent) 72 | 73 | statistics = feed.statistics.order('id') 74 | 75 | color = statistics.detect { |s| s.property.name == 'color' } 76 | color.should_not be_nil 77 | color.option.name.should == 'green' 78 | color.counter.should == 1 79 | 80 | size = statistics.detect { |s| s.property.name == 'size' } 81 | size.should_not be_nil 82 | size.option.name.should == 'XL' 83 | size.counter.should == 1 84 | end 85 | 86 | it "should save statistics with today's date" do 87 | params = { 'color' => 'green' } 88 | subject.save_params(params, user_agent) 89 | 90 | feed.statistics.each { |s| s.date.should == Date.today } 91 | end 92 | 93 | it 'should ignore parameters added internally by Rails' do 94 | params = { 'controller' => 'feeds', 'action' => 'show', 'id' => '123', 'color' => 'green' } 95 | subject.save_params(params, user_agent) 96 | 97 | feed.statistics.detect { |s| s.property.name == 'controller' }.should be_nil 98 | feed.statistics.detect { |s| s.property.name == 'action' }.should be_nil 99 | feed.statistics.detect { |s| s.property.name == 'id' }.should be_nil 100 | end 101 | 102 | it 'should ignore appName parameter' do 103 | params = { 'appName' => 'Sparkler', 'version' => '2.0' } 104 | subject.save_params(params, user_agent) 105 | 106 | feed.statistics.detect { |s| s.property.name == 'appName' }.should be_nil 107 | end 108 | 109 | it 'should extract version number from user agent and save it as appVersionShort' do 110 | subject.save_params({}, 'app/10.5 Sparkle/0.9') 111 | 112 | version = feed.statistics.detect { |s| s.property.name == 'appVersionShort' } 113 | version.should_not be_nil 114 | version.option.name.should == '10.5' 115 | end 116 | 117 | context 'if there is a cpusubtype field' do 118 | context 'if there is also a cputype field' do 119 | it 'should prefix the cpusubtype option name with the cpu type' do 120 | subject.save_params({ 'cputype' => '5', 'cpusubtype' => '15' }, user_agent) 121 | 122 | subtype = feed.statistics.detect { |s| s.property.name == 'cpusubtype' } 123 | subtype.should_not be_nil 124 | subtype.option.name.should == '5.15' 125 | end 126 | end 127 | 128 | context 'if there is no cputype field' do 129 | it 'should not prefix the cpusubtype option' do 130 | subject.save_params({ 'cpusubtype' => '15' }, user_agent) 131 | 132 | subtype = feed.statistics.detect { |s| s.property.name == 'cpusubtype' } 133 | subtype.should_not be_nil 134 | subtype.option.name.should == '15' 135 | end 136 | end 137 | end 138 | 139 | context 'if there is no cpusubtype field' do 140 | it 'should not save it' do 141 | subject.save_params({ 'cputype' => '5' }, user_agent) 142 | 143 | subtype = feed.statistics.detect { |s| s.property.name == 'cpusubtype' } 144 | subtype.should be_nil 145 | end 146 | end 147 | 148 | it 'should not modify the original hash' do 149 | params = { 'appName' => 'Sparkler', 'version' => '1.0' } 150 | 151 | subject.save_params(params, user_agent) 152 | 153 | params.keys.should == ['appName', 'version'] 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | describe User do 4 | describe '.find_admin' do 5 | context 'if a user exists' do 6 | let!(:user) { User.create!(password: 'aaaaaa', password_confirmation: 'aaaaaa') } 7 | 8 | it 'should return that user' do 9 | User.find_admin.should == user 10 | end 11 | 12 | it 'should not create new users' do 13 | expect { User.find_admin }.not_to change(User, :count) 14 | end 15 | end 16 | 17 | context 'if no users exist' do 18 | it 'should return a new user' do 19 | User.find_admin.should be_a(User) 20 | end 21 | 22 | it 'should create one user' do 23 | User.find_admin 24 | User.count.should == 1 25 | end 26 | 27 | context 'new user' do 28 | it 'should not have a password' do 29 | user = User.find_admin 30 | user.password_digest.should be_nil 31 | end 32 | end 33 | end 34 | end 35 | 36 | describe '#has_password?' do 37 | subject { user.has_password? } 38 | 39 | context 'if the user has a password' do 40 | let!(:user) { User.create(password: 'password', password_confirmation: 'password') } 41 | 42 | it { should == true } 43 | end 44 | 45 | context 'if the user has no password' do 46 | let!(:user) { User.find_admin } 47 | 48 | it { should == false } 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | ENV['RAILS_ENV'] ||= 'test' 3 | require 'spec_helper' 4 | require File.expand_path('../../config/environment', __FILE__) 5 | require 'rspec/rails' 6 | require 'webmock/rspec' 7 | 8 | # Add additional requires below this line. Rails is not loaded until this point! 9 | 10 | # Requires supporting ruby files with custom matchers and macros, etc, in 11 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 12 | # run as spec files by default. This means that files in spec/support that end 13 | # in _spec.rb will both be required and run as specs, causing the specs to be 14 | # run twice. It is recommended that you do not name files matching this glob to 15 | # end with _spec.rb. You can configure this pattern with the --pattern 16 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 17 | # 18 | # The following line is provided for convenience purposes. It has the downside 19 | # of increasing the boot-up time by auto-requiring all files in the support 20 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 21 | # require only the support files necessary. 22 | # 23 | # Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } 24 | 25 | # Checks for pending migrations before tests are run. 26 | # If you are not using ActiveRecord, you can remove this line. 27 | ActiveRecord::Migration.maintain_test_schema! 28 | 29 | RSpec.configure do |config| 30 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 31 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 32 | 33 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 34 | # examples within a transaction, remove the following line or assign false 35 | # instead of true. 36 | config.use_transactional_fixtures = true 37 | 38 | # RSpec Rails can automatically mix in different behaviours to your tests 39 | # based on their file location, for example enabling you to call `get` and 40 | # `post` in specs under `spec/controllers`. 41 | # 42 | # You can disable this behaviour by removing the line below, and instead 43 | # explicitly tag your specs with their type, e.g.: 44 | # 45 | # RSpec.describe UsersController, :type => :controller do 46 | # # ... 47 | # end 48 | # 49 | # The different available types are documented in the features, such as in 50 | # https://relishapp.com/rspec/rspec-rails/docs 51 | config.infer_spec_type_from_file_location! 52 | end 53 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # The `.rspec` file also contains a few flags that are not defaults but that 16 | # users commonly want. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | 20 | RSpec.configure do |config| 21 | # rspec-expectations config goes here. You can use an alternate 22 | # assertion/expectation library such as wrong or the stdlib/minitest 23 | # assertions if you prefer. 24 | 25 | config.expect_with :rspec do |expectations| 26 | # This option will default to `true` in RSpec 4. It makes the `description` 27 | # and `failure_message` of custom matchers include text for helper methods 28 | # defined using `chain`, e.g.: 29 | # be_bigger_than(2).and_smaller_than(4).description 30 | # # => "be bigger than 2 and smaller than 4" 31 | # ...rather than: 32 | # # => "be bigger than 2" 33 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 34 | expectations.syntax = [:should, :expect] 35 | end 36 | 37 | # rspec-mocks config goes here. You can use an alternate test double 38 | # library (such as bogus or mocha) by changing the `mock_with` option here. 39 | 40 | config.mock_with :rspec do |mocks| 41 | # Prevents you from mocking or stubbing a method that does not exist on 42 | # a real object. This is generally recommended, and will default to 43 | # `true` in RSpec 4. 44 | mocks.verify_partial_doubles = true 45 | mocks.syntax = [:should, :expect] 46 | end 47 | 48 | # The settings below are suggested to provide a good initial experience 49 | # with RSpec, but feel free to customize to your heart's content. 50 | 51 | # These two settings work together to allow you to limit a spec run 52 | # to individual examples or groups you care about by tagging them with 53 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 54 | # get run. 55 | config.filter_run :focus 56 | config.run_all_when_everything_filtered = true 57 | 58 | # Limits the available syntax to the non-monkey patched syntax that is 59 | # recommended. For more details, see: 60 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 61 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 62 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3 63 | # config.disable_monkey_patching! 64 | 65 | # Many RSpec users commonly either run the entire suite or an individual 66 | # file, and it's useful to allow more verbose output when running an 67 | # individual spec file. 68 | if config.files_to_run.one? 69 | # Use the documentation formatter for detailed output, 70 | # unless a formatter has already been configured 71 | # (e.g. via a command-line flag). 72 | config.default_formatter = 'doc' 73 | end 74 | 75 | # Print the 10 slowest examples and example groups at the 76 | # end of the spec run, to help surface which specs are running 77 | # particularly slow. 78 | ## config.profile_examples = 10 79 | 80 | # Run specs in random order to surface order dependencies. If you find an 81 | # order dependency and want to debug it, you can fix the order by providing 82 | # the seed, which is printed after each run. 83 | # --seed 1234 84 | config.order = :random 85 | 86 | # Seed global randomization in this process using the `--seed` CLI option. 87 | # Setting this allows you to use `--seed` to deterministically reproduce 88 | # test failures related to randomization by passing the same `--seed` value 89 | # as the one that triggered the failure. 90 | Kernel.srand config.seed 91 | end 92 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackuba/sparkler/830e19fe581201d760eeb87b4a9df63ebc9594ca/vendor/assets/stylesheets/.keep -------------------------------------------------------------------------------- /vendor/assets/stylesheets/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } 428 | --------------------------------------------------------------------------------