├── .browserslistrc ├── .github ├── FUNDING.yml └── workflows │ └── test.yml ├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── manifest.js │ ├── images │ │ ├── .keep │ │ ├── clean.png │ │ ├── escaping.svg │ │ ├── favicon.ico │ │ ├── karthik.jpg │ │ ├── logo.svg │ │ ├── maximilian.png │ │ ├── mobile-clean.jpg │ │ ├── mobile-packed.jpg │ │ ├── opened_tabs.svg │ │ ├── organized_content.svg │ │ ├── packed.png │ │ ├── share_online.svg │ │ ├── sharing_articles.svg │ │ └── tabs.png │ └── stylesheets │ │ ├── application.css │ │ ├── flash.scss │ │ ├── freshreader.scss │ │ └── normalize.css ├── channels │ └── application_cable │ │ ├── channel.rb │ │ └── connection.rb ├── controllers │ ├── api │ │ └── v1 │ │ │ ├── articles_controller.rb │ │ │ ├── base_controller.rb │ │ │ └── users_controller.rb │ ├── application_controller.rb │ ├── articles_controller.rb │ ├── billing_controller.rb │ ├── concerns │ │ └── .keep │ ├── pages_controller.rb │ ├── sessions_controller.rb │ └── users_controller.rb ├── helpers │ ├── application_helper.rb │ └── request_helper.rb ├── javascript │ ├── channels │ │ ├── consumer.js │ │ └── index.js │ └── packs │ │ ├── application.js │ │ └── stripe.js ├── jobs │ └── application_job.rb ├── mailers │ └── application_mailer.rb ├── models │ ├── application_record.rb │ ├── article.rb │ ├── concerns │ │ └── .keep │ └── user.rb └── views │ ├── articles │ └── list.html.erb │ ├── layouts │ ├── application.html.erb │ ├── mailer.html.erb │ └── mailer.text.erb │ ├── pages │ ├── index.html.erb │ ├── privacy.html.erb │ └── transparency.html.erb │ ├── sessions │ └── login.html.erb │ └── users │ └── show.html.erb ├── babel.config.js ├── bin ├── bundle ├── rails ├── rake ├── setup ├── spring ├── webpack ├── webpack-dev-server └── yarn ├── config.ru ├── config ├── application.rb ├── boot.rb ├── cable.yml ├── credentials.yml.enc ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── application_controller_renderer.rb │ ├── assets.rb │ ├── backtrace_silencers.rb │ ├── content_security_policy.rb │ ├── cookies_serializer.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── rack_attack.rb │ ├── stripe.rb │ └── wrap_parameters.rb ├── locales │ └── en.yml ├── puma.rb ├── routes.rb ├── spring.rb ├── storage.yml ├── webpack │ ├── development.js │ ├── environment.js │ ├── production.js │ └── test.js └── webpacker.yml ├── db ├── migrate │ ├── 20200314232813_create_users.rb │ ├── 20200314233044_create_articles.rb │ ├── 20200315203723_add_title_to_articles.rb │ ├── 20200503164250_add_api_auth_token_to_user.rb │ ├── 20200626161253_add_early_adopter_flag_to_user.rb │ ├── 20200628011938_add_stripe_customer_id_to_user.rb │ └── 20200628131227_add_stripe_subscription_id_to_user.rb ├── schema.rb └── seeds.rb ├── lib ├── assets │ └── .keep └── tasks │ ├── .keep │ └── delete_old_articles.rake ├── log └── .keep ├── package.json ├── postcss.config.js ├── public ├── 404.html ├── 422.html ├── 500.html ├── apks │ └── freshreader-v1.0.0.apk ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico └── robots.txt ├── storage └── .keep ├── test ├── application_system_test_case.rb ├── channels │ └── application_cable │ │ └── connection_test.rb ├── controllers │ ├── .keep │ ├── api │ │ └── v1 │ │ │ ├── articles_controller_test.rb │ │ │ └── users_controller_test.rb │ ├── articles_controller_test.rb │ ├── pages_controller_test.rb │ └── sessions_controller_test.rb ├── fixtures │ ├── .keep │ └── files │ │ └── .keep ├── helpers │ ├── .keep │ └── request_helper_test.rb ├── integration │ └── .keep ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── article_test.rb │ └── user_test.rb ├── system │ └── .keep ├── tasks │ └── delete_old_articles_test.rb └── test_helper.rb ├── tmp └── .keep ├── vendor └── .keep └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [maximevaillancourt] 4 | # patreon: # Replace with a single Patreon username 5 | # open_collective: # Replace with a single Open Collective username 6 | # ko_fi: # Replace with a single Ko-fi username 7 | # tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | # community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | # liberapay: # Replace with a single Liberapay username 10 | # issuehunt: # Replace with a single IssueHunt username 11 | # otechie: # Replace with a single Otechie username 12 | # custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - 'master' 7 | push: 8 | branches: 9 | - 'master' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | services: 16 | postgres: 17 | image: postgres:11.5 18 | ports: ["5432:5432"] 19 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v2 24 | 25 | - name: Set up Ruby from .ruby-version file 26 | uses: ruby/setup-ruby@v1 27 | with: 28 | ruby-version: .ruby-version 29 | 30 | - name: Print Ruby version 31 | run: ruby -v 32 | 33 | - name: Set up Bundler cache 34 | uses: actions/cache@v1 35 | with: 36 | path: vendor/bundle 37 | key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} 38 | restore-keys: | 39 | ${{ runner.os }}-gems- 40 | 41 | - name: Find Yarn cache location 42 | id: yarn-cache 43 | run: echo "::set-output name=dir::$(yarn cache dir)" 44 | 45 | - name: Setup Yarn cache 46 | uses: actions/cache@v1 47 | with: 48 | path: ${{ steps.yarn-cache.outputs.dir }} 49 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 50 | restore-keys: | 51 | ${{ runner.os }}-yarn- 52 | 53 | - name: Install PostgreSQL 11 client 54 | run: | 55 | sudo apt-get -yqq install libpq-dev 56 | 57 | - name: Install Ruby gems 58 | run: | 59 | bundle config path vendor/bundle 60 | bundle install --jobs 4 --retry 3 61 | 62 | - name: Install Yarn packages 63 | run: | 64 | yarn install --pure-lockfile 65 | 66 | - name: Run Webpacker 67 | env: 68 | RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} 69 | run: | 70 | NODE_ENV=test bundle exec rails webpacker:compile 71 | 72 | - name: Setup database 73 | env: 74 | PGHOST: localhost 75 | PGUSER: postgres 76 | RAILS_ENV: test 77 | RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} 78 | run: | 79 | bin/rails db:setup 80 | 81 | - name: Run tests 82 | env: 83 | PGHOST: localhost 84 | PGUSER: postgres 85 | RAILS_ENV: test 86 | RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} 87 | run: | 88 | bundle exec rake test 89 | bundle exec rake test:system 90 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.rbc 2 | capybara-*.html 3 | .rspec 4 | /db/*.sqlite3 5 | /db/*.sqlite3-journal 6 | /db/*.sqlite3-[0-9]* 7 | /public/system 8 | /coverage/ 9 | /spec/tmp 10 | *.orig 11 | rerun.txt 12 | pickle-email-*.html 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/* 16 | /tmp/* 17 | !/log/.keep 18 | !/tmp/.keep 19 | 20 | config/initializers/secret_token.rb 21 | /config/master.key 22 | 23 | # Only include if you have production secrets in this file, which is no longer a Rails default 24 | # config/secrets.yml 25 | 26 | # dotenv 27 | .env 28 | 29 | ## Environment normalization: 30 | /.bundle 31 | /vendor/bundle 32 | 33 | # these should all be checked in to normalize the environment: 34 | # Gemfile.lock, .ruby-version, .ruby-gemset 35 | 36 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 37 | .rvmrc 38 | 39 | # if using bower-rails ignore default bower_components path bower.json files 40 | /vendor/assets/bower_components 41 | *.bowerrc 42 | bower.json 43 | 44 | # Ignore pow environment settings 45 | .powenv 46 | 47 | # Ignore Byebug command history file. 48 | .byebug_history 49 | 50 | # Ignore node_modules 51 | node_modules/ 52 | 53 | # Ignore precompiled javascript packs 54 | /public/packs 55 | /public/packs-test 56 | /public/assets 57 | 58 | # Ignore yarn files 59 | /yarn-error.log 60 | yarn-debug.log* 61 | .yarn-integrity 62 | 63 | # Ignore uploaded files in development 64 | /storage/* 65 | !/storage/.keep 66 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.0.2 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | ruby '3.0.2' 5 | 6 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 7 | gem 'rails', '~> 6.0.2', '>= 6.0.2.1' 8 | # Use postgresql as the database for Active Record 9 | gem 'pg', '>= 0.18', '< 2.0' 10 | # Use Puma as the app server 11 | gem 'puma', '~> 4.3' 12 | # Use SCSS for stylesheets 13 | gem 'sass-rails', '>= 6' 14 | # Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker 15 | gem 'webpacker', '~> 4.0' 16 | # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks 17 | gem 'turbolinks', '~> 5' 18 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 19 | gem 'jbuilder', '~> 2.7' 20 | # Use Redis adapter to run Action Cable in production 21 | # gem 'redis', '~> 4.0' 22 | # Use Active Model has_secure_password 23 | # gem 'bcrypt', '~> 3.1.7' 24 | 25 | # Use Active Storage variant 26 | # gem 'image_processing', '~> 1.2' 27 | 28 | # Reduces boot times through caching; required in config/boot.rb 29 | gem 'bootsnap', '>= 1.4.2', require: false 30 | 31 | group :development, :test do 32 | # Call 'byebug' anywhere in the code to stop execution and get a debugger console 33 | gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] 34 | gem 'pry-rails' 35 | gem 'pry-nav' 36 | end 37 | 38 | group :development do 39 | # Access an interactive console on exception pages or by calling 'console' anywhere in the code. 40 | gem 'web-console', '>= 3.3.0' 41 | gem 'listen', '>= 3.0.5', '< 3.2' 42 | # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring 43 | gem 'spring' 44 | gem 'spring-watcher-listen', '~> 2.0.0' 45 | end 46 | 47 | group :test do 48 | # Adds support for Capybara system testing and selenium driver 49 | gem 'capybara', '>= 2.15' 50 | gem 'selenium-webdriver' 51 | # Easy installation and use of web drivers to run system tests with browsers 52 | gem 'webdrivers' 53 | end 54 | 55 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 56 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] 57 | 58 | # Provides URL validation in ActiveRecord 59 | gem "validate_url" 60 | 61 | gem "htmlentities", "~> 4.3" 62 | 63 | gem 'rack-attack' 64 | 65 | gem "httparty", "~> 0.18.0" 66 | 67 | gem 'stripe' 68 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (6.0.4.7) 5 | actionpack (= 6.0.4.7) 6 | nio4r (~> 2.0) 7 | websocket-driver (>= 0.6.1) 8 | actionmailbox (6.0.4.7) 9 | actionpack (= 6.0.4.7) 10 | activejob (= 6.0.4.7) 11 | activerecord (= 6.0.4.7) 12 | activestorage (= 6.0.4.7) 13 | activesupport (= 6.0.4.7) 14 | mail (>= 2.7.1) 15 | actionmailer (6.0.4.7) 16 | actionpack (= 6.0.4.7) 17 | actionview (= 6.0.4.7) 18 | activejob (= 6.0.4.7) 19 | mail (~> 2.5, >= 2.5.4) 20 | rails-dom-testing (~> 2.0) 21 | actionpack (6.0.4.7) 22 | actionview (= 6.0.4.7) 23 | activesupport (= 6.0.4.7) 24 | rack (~> 2.0, >= 2.0.8) 25 | rack-test (>= 0.6.3) 26 | rails-dom-testing (~> 2.0) 27 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 28 | actiontext (6.0.4.7) 29 | actionpack (= 6.0.4.7) 30 | activerecord (= 6.0.4.7) 31 | activestorage (= 6.0.4.7) 32 | activesupport (= 6.0.4.7) 33 | nokogiri (>= 1.8.5) 34 | actionview (6.0.4.7) 35 | activesupport (= 6.0.4.7) 36 | builder (~> 3.1) 37 | erubi (~> 1.4) 38 | rails-dom-testing (~> 2.0) 39 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 40 | activejob (6.0.4.7) 41 | activesupport (= 6.0.4.7) 42 | globalid (>= 0.3.6) 43 | activemodel (6.0.4.7) 44 | activesupport (= 6.0.4.7) 45 | activerecord (6.0.4.7) 46 | activemodel (= 6.0.4.7) 47 | activesupport (= 6.0.4.7) 48 | activestorage (6.0.4.7) 49 | actionpack (= 6.0.4.7) 50 | activejob (= 6.0.4.7) 51 | activerecord (= 6.0.4.7) 52 | marcel (~> 1.0.0) 53 | activesupport (6.0.4.7) 54 | concurrent-ruby (~> 1.0, >= 1.0.2) 55 | i18n (>= 0.7, < 2) 56 | minitest (~> 5.1) 57 | tzinfo (~> 1.1) 58 | zeitwerk (~> 2.2, >= 2.2.2) 59 | addressable (2.8.0) 60 | public_suffix (>= 2.0.2, < 5.0) 61 | bindex (0.8.1) 62 | bootsnap (1.11.1) 63 | msgpack (~> 1.2) 64 | builder (3.2.4) 65 | byebug (11.1.3) 66 | capybara (3.36.0) 67 | addressable 68 | matrix 69 | mini_mime (>= 0.1.3) 70 | nokogiri (~> 1.8) 71 | rack (>= 1.6.0) 72 | rack-test (>= 0.6.3) 73 | regexp_parser (>= 1.5, < 3.0) 74 | xpath (~> 3.2) 75 | childprocess (4.1.0) 76 | coderay (1.1.3) 77 | concurrent-ruby (1.1.9) 78 | crass (1.0.6) 79 | erubi (1.10.0) 80 | ffi (1.15.5) 81 | globalid (1.0.0) 82 | activesupport (>= 5.0) 83 | htmlentities (4.3.4) 84 | httparty (0.18.1) 85 | mime-types (~> 3.0) 86 | multi_xml (>= 0.5.2) 87 | i18n (1.10.0) 88 | concurrent-ruby (~> 1.0) 89 | jbuilder (2.11.5) 90 | actionview (>= 5.0.0) 91 | activesupport (>= 5.0.0) 92 | listen (3.0.8) 93 | rb-fsevent (~> 0.9, >= 0.9.4) 94 | rb-inotify (~> 0.9, >= 0.9.7) 95 | loofah (2.15.0) 96 | crass (~> 1.0.2) 97 | nokogiri (>= 1.5.9) 98 | mail (2.7.1) 99 | mini_mime (>= 0.1.1) 100 | marcel (1.0.2) 101 | matrix (0.4.2) 102 | method_source (1.0.0) 103 | mime-types (3.4.1) 104 | mime-types-data (~> 3.2015) 105 | mime-types-data (3.2022.0105) 106 | mini_mime (1.1.2) 107 | minitest (5.15.0) 108 | msgpack (1.4.5) 109 | multi_xml (0.6.0) 110 | nio4r (2.5.8) 111 | nokogiri (1.13.4-x86_64-linux) 112 | racc (~> 1.4) 113 | pg (1.3.4) 114 | pry (0.14.1) 115 | coderay (~> 1.1) 116 | method_source (~> 1.0) 117 | pry-nav (1.0.0) 118 | pry (>= 0.9.10, < 0.15) 119 | pry-rails (0.3.9) 120 | pry (>= 0.10.4) 121 | public_suffix (4.0.6) 122 | puma (4.3.12) 123 | nio4r (~> 2.0) 124 | racc (1.6.0) 125 | rack (2.2.3) 126 | rack-attack (6.6.0) 127 | rack (>= 1.0, < 3) 128 | rack-proxy (0.7.2) 129 | rack 130 | rack-test (1.1.0) 131 | rack (>= 1.0, < 3) 132 | rails (6.0.4.7) 133 | actioncable (= 6.0.4.7) 134 | actionmailbox (= 6.0.4.7) 135 | actionmailer (= 6.0.4.7) 136 | actionpack (= 6.0.4.7) 137 | actiontext (= 6.0.4.7) 138 | actionview (= 6.0.4.7) 139 | activejob (= 6.0.4.7) 140 | activemodel (= 6.0.4.7) 141 | activerecord (= 6.0.4.7) 142 | activestorage (= 6.0.4.7) 143 | activesupport (= 6.0.4.7) 144 | bundler (>= 1.3.0) 145 | railties (= 6.0.4.7) 146 | sprockets-rails (>= 2.0.0) 147 | rails-dom-testing (2.0.3) 148 | activesupport (>= 4.2.0) 149 | nokogiri (>= 1.6) 150 | rails-html-sanitizer (1.4.2) 151 | loofah (~> 2.3) 152 | railties (6.0.4.7) 153 | actionpack (= 6.0.4.7) 154 | activesupport (= 6.0.4.7) 155 | method_source 156 | rake (>= 0.8.7) 157 | thor (>= 0.20.3, < 2.0) 158 | rake (13.0.6) 159 | rb-fsevent (0.11.1) 160 | rb-inotify (0.10.1) 161 | ffi (~> 1.0) 162 | regexp_parser (2.2.1) 163 | rexml (3.2.5) 164 | rubyzip (2.3.2) 165 | sass-rails (6.0.0) 166 | sassc-rails (~> 2.1, >= 2.1.1) 167 | sassc (2.4.0) 168 | ffi (~> 1.9) 169 | sassc-rails (2.1.2) 170 | railties (>= 4.0.0) 171 | sassc (>= 2.0) 172 | sprockets (> 3.0) 173 | sprockets-rails 174 | tilt 175 | selenium-webdriver (4.1.0) 176 | childprocess (>= 0.5, < 5.0) 177 | rexml (~> 3.2, >= 3.2.5) 178 | rubyzip (>= 1.2.2) 179 | spring (2.1.1) 180 | spring-watcher-listen (2.0.1) 181 | listen (>= 2.7, < 4.0) 182 | spring (>= 1.2, < 3.0) 183 | sprockets (4.0.3) 184 | concurrent-ruby (~> 1.0) 185 | rack (> 1, < 3) 186 | sprockets-rails (3.4.2) 187 | actionpack (>= 5.2) 188 | activesupport (>= 5.2) 189 | sprockets (>= 3.0.0) 190 | stripe (5.45.0) 191 | thor (1.2.1) 192 | thread_safe (0.3.6) 193 | tilt (2.0.10) 194 | turbolinks (5.2.1) 195 | turbolinks-source (~> 5.2) 196 | turbolinks-source (5.2.0) 197 | tzinfo (1.2.9) 198 | thread_safe (~> 0.1) 199 | validate_url (1.0.13) 200 | activemodel (>= 3.0.0) 201 | public_suffix 202 | web-console (4.2.0) 203 | actionview (>= 6.0.0) 204 | activemodel (>= 6.0.0) 205 | bindex (>= 0.4.0) 206 | railties (>= 6.0.0) 207 | webdrivers (5.0.0) 208 | nokogiri (~> 1.6) 209 | rubyzip (>= 1.3.0) 210 | selenium-webdriver (~> 4.0) 211 | webpacker (4.3.0) 212 | activesupport (>= 4.2) 213 | rack-proxy (>= 0.6.1) 214 | railties (>= 4.2) 215 | websocket-driver (0.7.5) 216 | websocket-extensions (>= 0.1.0) 217 | websocket-extensions (0.1.5) 218 | xpath (3.2.0) 219 | nokogiri (~> 1.8) 220 | zeitwerk (2.5.4) 221 | 222 | PLATFORMS 223 | x86_64-linux 224 | 225 | DEPENDENCIES 226 | bootsnap (>= 1.4.2) 227 | byebug 228 | capybara (>= 2.15) 229 | htmlentities (~> 4.3) 230 | httparty (~> 0.18.0) 231 | jbuilder (~> 2.7) 232 | listen (>= 3.0.5, < 3.2) 233 | pg (>= 0.18, < 2.0) 234 | pry-nav 235 | pry-rails 236 | puma (~> 4.3) 237 | rack-attack 238 | rails (~> 6.0.2, >= 6.0.2.1) 239 | sass-rails (>= 6) 240 | selenium-webdriver 241 | spring 242 | spring-watcher-listen (~> 2.0.0) 243 | stripe 244 | turbolinks (~> 5) 245 | tzinfo-data 246 | validate_url 247 | web-console (>= 3.3.0) 248 | webdrivers 249 | webpacker (~> 4.0) 250 | 251 | RUBY VERSION 252 | ruby 3.0.2p107 253 | 254 | BUNDLED WITH 255 | 2.2.22 256 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Maxime Vaillancourt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bin/rails server -p $PORT -e $RAILS_ENV 2 | release: rake db:migrate 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](https://user-images.githubusercontent.com/8457808/77265724-d5123300-6c73-11ea-96fd-e3a56177ada7.png) 2 | 3 | # Freshreader 4 | 5 | Save content to read later. Read what's most interesting. **Get a fresh start every week.** 6 | 7 | Drawing inspiration from Instapaper and Pocket, Freshreader aims to be the place where you save content from around the Web to enjoy later. The major difference is this: Freshreader automatically lets go of saved content after 7 days. No more massive reading backlog: just what is still relevant. 8 | 9 | Don't worry — if it's important, it'll somehow come back into your life. 10 | 11 | Made using Ruby on Rails. 12 | 13 | ## Screenshot 14 | 15 | ![screenshot](https://user-images.githubusercontent.com/8457808/77265722-d4799c80-6c73-11ea-873f-1aad3d82629b.png) 16 | 17 | ## Dependencies 18 | 19 | At the time of writing this, the following dependencies are recommended: 20 | 21 | - Ruby 2.7.0 22 | - Rails 6.0.2.2 23 | - PostgreSQL 10.12 24 | 25 | ## Getting started 26 | 27 | 1. Setup two PostgreSQL database: one named `freshreader_development`, the other `freshreader_test`. 28 | 2. Install project dependencies using `bundle`. 29 | 3. Start the application using `rails s`. 30 | 31 | ### Deploying 32 | 33 | You're free to deploy the application wherever/however you see fit. https://freshreader.app/ is deployed on Heroku. 34 | 35 | ## Contributing 36 | 37 | For bug fixes, documentation changes, and small features: 38 | 39 | 1. [Fork it](https://github.com/maximevaillancourt/freshreader/fork) 40 | 2. Create your feature branch (`git checkout -b my-new-feature`) 41 | 3. Commit your changes (`git commit -am 'Add some feature'`) 42 | 4. Push to the branch (`git push origin my-new-feature`) 43 | 5. Create a new [Pull Request](https://github.com/maximevaillancourt/freshreader/compare) 44 | 45 | ## Licensing 46 | 47 | The code in this project is licensed under MIT license. See the [LICENSE](LICENSE). 48 | -------------------------------------------------------------------------------- /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_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/images/clean.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/clean.png -------------------------------------------------------------------------------- /app/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/favicon.ico -------------------------------------------------------------------------------- /app/assets/images/karthik.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/karthik.jpg -------------------------------------------------------------------------------- /app/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/assets/images/maximilian.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/maximilian.png -------------------------------------------------------------------------------- /app/assets/images/mobile-clean.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/mobile-clean.jpg -------------------------------------------------------------------------------- /app/assets/images/mobile-packed.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/mobile-packed.jpg -------------------------------------------------------------------------------- /app/assets/images/opened_tabs.svg: -------------------------------------------------------------------------------- 1 | opened_tabs 2 | -------------------------------------------------------------------------------- /app/assets/images/organized_content.svg: -------------------------------------------------------------------------------- 1 | organized_content -------------------------------------------------------------------------------- /app/assets/images/packed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/packed.png -------------------------------------------------------------------------------- /app/assets/images/share_online.svg: -------------------------------------------------------------------------------- 1 | share_online -------------------------------------------------------------------------------- /app/assets/images/sharing_articles.svg: -------------------------------------------------------------------------------- 1 | sharing_articles -------------------------------------------------------------------------------- /app/assets/images/tabs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/assets/images/tabs.png -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | 13 | *= require normalize 14 | *= require freshreader 15 | *= require flash 16 | *= require_* 17 | */ 18 | -------------------------------------------------------------------------------- /app/assets/stylesheets/flash.scss: -------------------------------------------------------------------------------- 1 | .flash { 2 | padding: 0.4em 0.8em; 3 | background: #e8e8e8; 4 | color: #575757; 5 | border-radius: 3px; 6 | display: inline-block; 7 | } 8 | 9 | .flash_error { 10 | background: #ffb6b6; 11 | color: #930d0d; 12 | } 13 | 14 | .flash_warning { 15 | background: #fff2d9; 16 | color: #6f5917; 17 | } 18 | 19 | .flash_notice { 20 | background: #d9edff; 21 | color: #415b77; 22 | } 23 | 24 | .flash_success { 25 | background: hsl(129.6, 67%, 70.6%); 26 | color: hsl(129.6, 67%, 22.6%); 27 | } 28 | -------------------------------------------------------------------------------- /app/assets/stylesheets/freshreader.scss: -------------------------------------------------------------------------------- 1 | $color-primary: #48c660; 2 | $color-bg: white; 3 | $color-text: #333333; 4 | $color-border: #dddddd; 5 | 6 | $font-family-sans-serif: -apple-system, system-ui, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif; 7 | $font-family-monospace: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; 8 | 9 | $color-text-light: lighten($color-text, 30); 10 | $focus-color: #ffe8bc; 11 | $hover-color: #fffaf1; 12 | 13 | * { 14 | box-sizing: border-box; 15 | transition: background-color 100ms, color 100ms, border-color 100ms, opacity 100ms !important; 16 | } 17 | 18 | *:before, *:after { 19 | box-sizing: border-box; 20 | } 21 | 22 | html, 23 | body { 24 | height: 100%; 25 | position: relative; 26 | } 27 | 28 | #main-container { 29 | min-height: 100vh; /* will cover the 100% of viewport */ 30 | overflow: hidden; 31 | display: block; 32 | position: relative; 33 | padding-bottom: 8em; 34 | } 35 | 36 | .active { 37 | font-weight: bold; 38 | } 39 | 40 | body { 41 | color: $color-text; 42 | font-family: $font-family-sans-serif; 43 | line-height: 1.6; 44 | background: $color-bg; 45 | @media (min-width: 768px) { 46 | font-size: 1.3em; 47 | } 48 | @media (max-width: 767px) { 49 | font-size: 1em; 50 | } 51 | } 52 | 53 | #logo-text { 54 | @media (max-width: 30em) { 55 | display: none; 56 | } 57 | } 58 | 59 | h1, h2, h3, h4, h5, h6 { 60 | line-height: 1.3; 61 | } 62 | 63 | .x-y-center { 64 | display: flex; 65 | align-items: center; 66 | min-height: 75vh; 67 | } 68 | 69 | .wrapper { 70 | max-width: 65rem; 71 | margin: 0 auto; 72 | padding: 0 1.2rem; 73 | } 74 | 75 | .nav { 76 | padding: 0; 77 | } 78 | 79 | #flashes-wrapper { 80 | margin-bottom: 4vh; 81 | } 82 | 83 | .account-number { 84 | font-family: $font-family-monospace; 85 | font-size: 1.2em; 86 | padding: 0.5em 1em; 87 | background: #ffecd6; 88 | border-radius: 3px; 89 | border: 3px dashed #dfc9af; 90 | font-weight: bold; 91 | margin: 1em 0; 92 | color: #533d23; 93 | } 94 | 95 | .nav-link{ 96 | font-size: 0.8em; 97 | margin: 0 1.2em 0 0; 98 | &:last-of-type { 99 | margin: 0; 100 | } 101 | } 102 | 103 | a { 104 | text-decoration: none; 105 | 106 | &:not(.no-border-bottom) { 107 | border-bottom: 1px solid $color-border; 108 | // @media (prefers-color-scheme: dark) { 109 | // border-color: #303030; 110 | // } 111 | } 112 | 113 | // @media (prefers-color-scheme: dark) { 114 | // color: #eeeeee; 115 | // } 116 | 117 | color: black; 118 | &:hover { 119 | color: black; 120 | background-color: $hover-color; 121 | // @media (prefers-color-scheme: dark) { 122 | // background-color: #303030; 123 | // color: white; 124 | // } 125 | } 126 | &:after { 127 | position: relative; 128 | top: -0.5em; 129 | font-size: 0.7em; 130 | content: "↗"; 131 | color: #aaaaaa; 132 | } 133 | &.internal-link:after { 134 | content: ""; 135 | } 136 | } 137 | 138 | *:focus { 139 | background: $focus-color !important; 140 | color: black !important; 141 | border-color: black !important; 142 | 143 | // @media (prefers-color-scheme: dark) { 144 | // background-color: lighten(#d9aa3b, 10) !important; 145 | // } 146 | } 147 | 148 | .flex { 149 | display: flex; 150 | align-items: center; 151 | } 152 | 153 | .flex-1 { 154 | flex: 1; 155 | } 156 | 157 | .glow-primary { 158 | box-shadow: 0 0 18px $color-primary; 159 | animation: blinker 2s linear infinite; 160 | @keyframes blinker { 161 | 50% { 162 | box-shadow: 0 0 18px transparent; 163 | } 164 | } 165 | } 166 | 167 | .how-it-works-box { 168 | font-size: 0.9em; 169 | &:nth-child(2) { 170 | padding: 0em 2em; 171 | } 172 | } 173 | 174 | input[type="text"] { 175 | border: 1px solid $color-border; 176 | padding: 0.5em; 177 | margin: 0 -1px 0 0; 178 | background-color: white; 179 | 180 | // @media (prefers-color-scheme: dark) { 181 | // background-color: #0d0d0d; 182 | // border: 1px solid #4f4f4f; 183 | // color: #aaaaaa; 184 | // } 185 | } 186 | 187 | #url-input-wrapper { 188 | margin: 1vh 0; 189 | } 190 | 191 | .url-input-label { 192 | font-size: 0.8em; 193 | color: #787878; 194 | } 195 | 196 | #url-input { 197 | margin-right: 0px; 198 | border-radius: 3px 0 0px 3px; 199 | } 200 | 201 | hr { 202 | border: none; 203 | height: 1px; 204 | background: $color-border; 205 | margin: 2em 0; 206 | } 207 | 208 | .btn, 209 | button, 210 | input[type="submit"] { 211 | display: inline-block; 212 | $btn-bg-color: #eaeaea; 213 | background: $btn-bg-color; 214 | border: 1px solid transparent; 215 | padding: 0.5em 1em; 216 | border-color: $color-border; 217 | &:hover { 218 | background-color: darken($btn-bg-color, 5); 219 | } 220 | 221 | color: #222; 222 | cursor: pointer; 223 | border-radius: 3px; 224 | 225 | &:not(.no-margin) { 226 | margin: 0 0.5em 0.5em 0; 227 | } 228 | 229 | &.btn-danger { 230 | color: white; 231 | background: #bd1c1c; 232 | border-color: transparent; 233 | padding: 0.5em 1em; 234 | &:hover { 235 | background-color: darken(#bd1c1c, 5); 236 | color: white; 237 | } 238 | } 239 | &.btn-input-group { 240 | border-radius: 0 3px 3px 0; 241 | padding: 0.5em 1em; 242 | border-left: none; 243 | } 244 | 245 | &.btn-success { 246 | color: white; 247 | $btn-success-bg-color: $color-primary; 248 | background: $btn-success-bg-color; 249 | border-color: transparent; 250 | &:hover { 251 | background-color: darken($btn-success-bg-color, 5); 252 | color: white; 253 | } 254 | } 255 | 256 | &.btn-full-width { 257 | width: 100%; 258 | } 259 | } 260 | 261 | .btn, button, input { 262 | padding: 0.3em 1em; 263 | } 264 | 265 | .btn-large { 266 | font-size: 1.3em; 267 | } 268 | 269 | .btn-small { 270 | font-size: 0.8em; 271 | } 272 | 273 | .check { 274 | text-decoration: none; 275 | border: none; 276 | font-size: 1.2em; 277 | padding: 0.4em; 278 | font-weight: bold; 279 | border-radius: 3px; 280 | margin-right: 0.5em; 281 | } 282 | 283 | .faded { 284 | color: #666; 285 | } 286 | 287 | .text-small { 288 | font-size: 0.8em; 289 | } 290 | 291 | .articles-wrapper { 292 | margin: 2em 0; 293 | } 294 | 295 | .day-0 { 296 | opacity: 1; 297 | } 298 | 299 | .day-1 { 300 | opacity: 0.9; 301 | } 302 | 303 | .day-2 { 304 | opacity: 0.8; 305 | } 306 | 307 | .day-3 { 308 | opacity: 0.7; 309 | } 310 | 311 | .day-4 { 312 | opacity: 0.6; 313 | } 314 | 315 | .day-5 { 316 | opacity: 0.5; 317 | } 318 | 319 | .day-6 { 320 | opacity: 0.35; 321 | } 322 | 323 | .day-7 { 324 | opacity: 0.2; 325 | } 326 | 327 | // shouldn't be possible, but hey, you never know 328 | .day-8 { 329 | opacity: 0.1; 330 | } 331 | 332 | .card { 333 | margin-bottom: 2em; 334 | padding: 1em 1.2em; 335 | 336 | &.card-sm { 337 | margin-bottom: 1em; 338 | padding: 0.8em 1em; 339 | } 340 | 341 | background: white; 342 | box-shadow: 0 2px 10px 0 hsla(0, 0%, 10%, 0.1); 343 | border-radius: 4px; 344 | border: 1px solid $color-border; 345 | 346 | &.card-dimmed { 347 | background: transparent; 348 | box-shadow: none; 349 | border: 1px dashed #7c7c7c; 350 | color: #7c7c7c; 351 | } 352 | 353 | &:hover { 354 | opacity: 1; 355 | } 356 | 357 | // @media (prefers-color-scheme: dark) { 358 | // background-color: #181818; 359 | // } 360 | 361 | p:last-child { 362 | margin-bottom: 0; 363 | } 364 | } 365 | 366 | .card-title { 367 | font-size: 1.3em; 368 | font-weight: bold; 369 | } 370 | 371 | .article-timestamp { 372 | font-size: 0.8em; 373 | color: $color-text-light; 374 | } 375 | 376 | #above-the-fold { 377 | min-height: 70vh; 378 | align-items: center; 379 | justify-content: center; 380 | display: flex; 381 | flex-direction: column; 382 | } 383 | 384 | .hero-benefits { 385 | background: white; 386 | box-shadow: 0em 0.5em 2em #00000020; 387 | border-radius: 5px; 388 | padding: 0.4em 1.5em; 389 | margin: 1.5em -1.5em; 390 | 391 | @media (min-width: 768px) { 392 | & > .desktop { display: block; } 393 | & > .mobile { display: none; } 394 | } 395 | @media (max-width: 767px) { 396 | & > .desktop { display: none; } 397 | & > .mobile { display: block; } 398 | } 399 | } 400 | 401 | #hero-image { 402 | margin-top: 2em; 403 | max-height: 50vh; 404 | max-width: 80%; 405 | } 406 | 407 | footer { 408 | font-size: 0.8em; 409 | color: $color-text-light; 410 | padding: 1.5em 0; 411 | position: absolute; 412 | bottom: 0; 413 | width: 100%; 414 | } 415 | 416 | .lead { 417 | font-size: 1.25em; 418 | margin: 0; 419 | } 420 | 421 | #nav-logo { 422 | width: 1.4em; 423 | position: relative; 424 | top: 0.3em; 425 | } 426 | 427 | code { 428 | font-family: $font-family-monospace; 429 | padding: 0.1em 0.2em; 430 | background: #e8e8e8; 431 | border-radius: 3px; 432 | } 433 | 434 | // @media (prefers-color-scheme: dark) { 435 | // body { 436 | // background: #000000; 437 | // color: #cccccc; 438 | // } 439 | // 440 | // code { 441 | // background: #303030; 442 | // } 443 | // 444 | // .btn, 445 | // button, 446 | // input[type="submit"] { 447 | // background: #303030; 448 | // border: 1px solid #404040; 449 | // color: #979797; 450 | // &:hover { 451 | // background-color: darken(#303030, 5); 452 | // } 453 | // } 454 | // 455 | // .account-number { 456 | // background: #352e26; 457 | // border-radius: 3px; 458 | // border: 3px dashed #574d42; 459 | // color: #b3a18d; 460 | // } 461 | // 462 | // hr { 463 | // background: #404040; 464 | // } 465 | // } 466 | 467 | .testimonial { 468 | max-width: 30em; 469 | margin: 0 auto; 470 | 471 | display: flex; 472 | flex-direction: column; 473 | justify-content: center; 474 | 475 | p:first-child { 476 | margin-top: 0; 477 | } 478 | } 479 | 480 | .testimonial-img { 481 | border-radius: 100%; 482 | width: 40px; 483 | vertical-align: middle; 484 | margin-right: 0.3em; 485 | } 486 | 487 | .testimonial-name { 488 | vertical-align: middle; 489 | font-size: 0.9em; 490 | } 491 | 492 | #subscription-form__stripe { 493 | margin: 0.5em 0 0.7em; 494 | padding: 0.5em; 495 | background: #f7f7f7; 496 | border-radius: 4px; 497 | } 498 | 499 | img { 500 | max-width: 100%; 501 | } -------------------------------------------------------------------------------- /app/assets/stylesheets/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } 350 | 351 | -------------------------------------------------------------------------------- /app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /app/controllers/api/v1/articles_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V1::ArticlesController < Api::V1::BaseController 2 | def index 3 | articles = Article.where(user: current_user).sort_by(&:created_at).reverse 4 | render json: articles 5 | end 6 | 7 | def create 8 | ActiveRecord::Base.transaction do 9 | if !(current_user.subscribed? || current_user.early_adopter?) && current_user.articles.size >= Article::ARTICLES_LIMIT_ON_FREE_PLAN 10 | return render json: "You cannot save more than #{Article::ARTICLES_LIMIT_ON_FREE_PLAN} items on the free plan. Upgrade to Freshreader Pro to save more items.", status: :forbidden 11 | end 12 | 13 | url = RequestHelper.url_from_param(params[:url]) 14 | title, fetched_url = RequestHelper.extract_title_from_page(url) 15 | 16 | new_article = Article.new(user: current_user, url: fetched_url, title: title) 17 | 18 | if new_article.save 19 | render json: new_article, status: :created 20 | else 21 | render json: new_article.errors, status: :unprocessable_entity 22 | end 23 | end 24 | rescue 25 | render json: "There was an issue saving this URL.", status: :unprocessable_entity 26 | end 27 | 28 | def destroy 29 | article = Article.find_by(id: params[:id], user: current_user) 30 | 31 | unless article 32 | return render json: { error: 'article not found' }, status: :not_found 33 | end 34 | 35 | if article.destroy 36 | head :no_content 37 | else 38 | render json: article.errors, status: :internal_server_error 39 | end 40 | end 41 | end 42 | 43 | -------------------------------------------------------------------------------- /app/controllers/api/v1/base_controller.rb: -------------------------------------------------------------------------------- 1 | module Api 2 | module V1 3 | class BaseController < ActionController::Base 4 | before_action :authenticate 5 | protect_from_forgery with: :null_session 6 | 7 | private 8 | 9 | def authenticate 10 | authenticate_or_request_with_http_token do |provided_token, options| 11 | return unless (user = User.find_by(account_number: options[:account_number])) 12 | 13 | if valid_auth_token?(user, provided_token) 14 | user 15 | else 16 | # Maybe the auth token is expired, let's generate a new one for clients to use 17 | user.regenerate_api_auth_token_if_expired! 18 | false 19 | end 20 | end 21 | end 22 | 23 | def valid_auth_token?(user, provided_token) 24 | user && 25 | user.api_auth_token_expires_at > Time.now && 26 | ActiveSupport::SecurityUtils.secure_compare(user.api_auth_token, provided_token) 27 | end 28 | 29 | def current_user 30 | @current_user ||= authenticate 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/controllers/api/v1/users_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::V1::UsersController < Api::V1::BaseController 2 | skip_before_action :authenticate, only: [:show, :create] 3 | 4 | def show 5 | user = User.find_by(account_number: params[:account_number].delete(' ')) 6 | 7 | if user 8 | user.regenerate_api_auth_token_if_expired! 9 | render json: user 10 | else 11 | render json: {}, status: :not_found 12 | end 13 | end 14 | 15 | def create 16 | user = User.new( 17 | account_number: User.generate_account_number, 18 | api_auth_token: User.generate_api_auth_token, 19 | api_auth_token_expires_at: Time.now + 30.second, 20 | ) 21 | 22 | if user.save 23 | render json: user, status: :created 24 | else 25 | render json: user.errors, status: :internal_server_error 26 | end 27 | end 28 | 29 | def destroy 30 | user = current_user 31 | if user.destroy 32 | head :no_content 33 | else 34 | render json: user.errors, status: :internal_server_error 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | before_action :authorized 3 | helper_method :current_user 4 | helper_method :logged_in? 5 | 6 | def current_user 7 | User.find_by(id: session[:user_id]) 8 | end 9 | 10 | def logged_in? 11 | !current_user.nil? 12 | end 13 | 14 | def authorized 15 | redirect_to '/login' unless logged_in? 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/controllers/articles_controller.rb: -------------------------------------------------------------------------------- 1 | class ArticlesController < ApplicationController 2 | skip_before_action :authorized, only: [:save_mobile, :save_bookmarklet] 3 | 4 | def index 5 | @articles = Article.where(user: current_user).sort_by(&:created_at).reverse 6 | 7 | @new_article = Article.new 8 | 9 | render :list 10 | end 11 | 12 | def save_bookmarklet 13 | unless logged_in? 14 | flash[:warning] = 'You need to log in before saving this URL.' 15 | return redirect_to login_url(return_to: "#{request.protocol + request.host}/save?url=#{params[:url]}") 16 | end 17 | 18 | return if check_save_limit 19 | 20 | url = RequestHelper.url_from_param(params[:url]) 21 | title, fetched_url = RequestHelper.extract_title_from_page(url) 22 | 23 | new_article = Article.new(user: current_user, url: fetched_url, title: title) 24 | 25 | if new_article.save 26 | flash[:success] = 'Saved successfully.' 27 | else 28 | flash[:error] = new_article.errors.full_messages.to_sentence 29 | end 30 | redirect_to :articles 31 | rescue 32 | flash[:error] = 'There was an issue saving this URL.' 33 | redirect_to :articles 34 | end 35 | 36 | def save_mobile 37 | unless user = User.find_by(account_number: params[:account_number].delete(' ')) 38 | return render json: 'This user does not exist.', status: :unauthorized 39 | end 40 | 41 | if !(user.subscribed? || user.early_adopter?) && user.articles.size >= Article::ARTICLES_LIMIT_ON_FREE_PLAN 42 | return render json: "You cannot save more than #{Article::ARTICLES_LIMIT_ON_FREE_PLAN} items on the free plan. Upgrade to Pro to save more items.", status: :forbidden 43 | end 44 | 45 | url = RequestHelper.url_from_param(params[:url]) 46 | title, fetched_url = RequestHelper.extract_title_from_page(url) 47 | new_article = Article.new(user: user, url: fetched_url, title: title) 48 | 49 | if new_article.save 50 | head :ok 51 | else 52 | render json: { error: new_article.errors.full_messages.to_sentence }, status: :internal_server_error 53 | end 54 | rescue 55 | render json: { error: 'There was an issue saving this URL.' }, status: :internal_server_error 56 | end 57 | 58 | def create 59 | return if check_save_limit 60 | 61 | url = RequestHelper.url_from_param(params.dig(:article, :url)) 62 | title, fetched_url = RequestHelper.extract_title_from_page(url) 63 | 64 | new_article = Article.new(user: current_user, url: fetched_url, title: title) 65 | 66 | if new_article.save 67 | flash[:success] = 'Saved successfully.' 68 | else 69 | flash[:error] = new_article.errors.full_messages.to_sentence 70 | end 71 | redirect_to :articles 72 | rescue => e 73 | flash[:error] = "There was an issue saving this URL. Please try again." 74 | redirect_to :articles 75 | end 76 | 77 | def destroy 78 | @article = Article.find_by(id: params[:id], user: current_user) 79 | if @article&.destroy 80 | flash[:success] = 'Marked as read successfully.' 81 | else 82 | flash[:error] = 'There was an issue marking this article as read.' 83 | end 84 | redirect_to :articles 85 | end 86 | 87 | private 88 | 89 | def check_save_limit 90 | return unless current_user 91 | 92 | if !(current_user.subscribed? || current_user.early_adopter?) && current_user.articles.size >= Article::ARTICLES_LIMIT_ON_FREE_PLAN 93 | flash[:warning] = "You cannot save more than #{Article::ARTICLES_LIMIT_ON_FREE_PLAN} items on the free plan. #{view_context.link_to('Upgrade to Pro', "/account#subscription", { :class => "internal-link" })} to save more items." 94 | redirect_to :articles 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /app/controllers/billing_controller.rb: -------------------------------------------------------------------------------- 1 | class BillingController < ApplicationController 2 | skip_before_action :verify_authenticity_token, only: :webhooks 3 | skip_before_action :authorized, only: [:webhooks] 4 | 5 | def create_subscription 6 | return unless current_user 7 | return if current_user.subscribed? || current_user.early_adopter? 8 | 9 | data = JSON.parse(request.body.read) 10 | 11 | unless current_user.stripe_customer_id 12 | stripe_customer = Stripe::Customer.create( 13 | metadata: { 14 | freshreader_account_number: current_user.account_number 15 | } 16 | ) 17 | current_user.update(stripe_customer_id: stripe_customer.id) 18 | end 19 | 20 | payment_method_id = data['paymentMethodId'] 21 | customer_id = current_user.stripe_customer_id 22 | 23 | # Attach the payment method to the customer 24 | begin 25 | Stripe::PaymentMethod.attach( 26 | payment_method_id, 27 | { customer: customer_id } 28 | ) 29 | rescue Stripe::CardError => e 30 | return render json: { 'error': { message: e.error.message } }.to_json, status: :bad_request 31 | end 32 | 33 | # Set the default payment method on the customer 34 | Stripe::Customer.update( 35 | customer_id, 36 | invoice_settings: { 37 | default_payment_method: payment_method_id 38 | } 39 | ) 40 | 41 | # Create the subscription 42 | subscription = Stripe::Subscription.create( 43 | customer: customer_id, 44 | items: [ 45 | { 46 | price: FRESHREADER_PRO_MONTHLY_PRICE_ID 47 | } 48 | ], 49 | expand: ['latest_invoice.payment_intent'] 50 | ) 51 | 52 | render json: subscription.to_json 53 | end 54 | 55 | def retry_invoice 56 | data = JSON.parse(request.body.read) 57 | 58 | unless current_user.stripe_customer_id 59 | stripe_customer = Stripe::Customer.create( 60 | name: current_user.account_number 61 | ) 62 | current_user.update(stripe_customer_id: stripe_customer.id) 63 | end 64 | 65 | begin 66 | Stripe::PaymentMethod.attach( 67 | data['paymentMethodId'], 68 | { customer: current_user.stripe_customer_id } 69 | ) 70 | rescue Stripe::CardError => e 71 | return render json: { 'error': { message: e.error.message } }.to_json, status: :bad_request 72 | end 73 | 74 | # Set the default payment method on the customer 75 | Stripe::Customer.update( 76 | current_user.stripe_customer_id, 77 | invoice_settings: { 78 | default_payment_method: data['paymentMethodId'] 79 | } 80 | ) 81 | 82 | invoice = Stripe::Invoice.retrieve({ 83 | id: data['invoiceId'], 84 | expand: ['payment_intent', 'subscription'] 85 | }) 86 | 87 | render json: invoice.to_json 88 | end 89 | 90 | def subscription_callback 91 | data = JSON.parse(request.body.read) 92 | 93 | # TODO: ensure subscription is active before saving it 94 | 95 | if current_user.stripe_customer_id == data.dig('subscription', 'customer') 96 | current_user.update(stripe_subscription_id: data.dig('subscription', 'id')) 97 | end 98 | end 99 | 100 | def cancel_subscription 101 | return unless current_user 102 | 103 | deleted_subscription = Stripe::Subscription.delete(current_user.stripe_subscription_id) 104 | 105 | if deleted_subscription 106 | current_user.update(stripe_subscription_id: nil) 107 | flash[:success] = 'Downgraded to Freshreader Free successfully.' 108 | else 109 | flash[:error] = 'Could not cancel subscription. Please try again, or reach out to me on Twitter (@vaillancourtmax).' 110 | end 111 | 112 | redirect_to :account 113 | end 114 | 115 | def webhooks 116 | # You can use webhooks to receive information about asynchronous payment events. 117 | # For more about our webhook events check out https://stripe.com/docs/webhooks. 118 | payload = request.body.read 119 | 120 | if !STRIPE_WEBHOOK_SECRET.empty? 121 | # Retrieve the event by verifying the signature using the raw body and secret if webhook signing is configured. 122 | sig_header = request.env['HTTP_STRIPE_SIGNATURE'] 123 | event = nil 124 | 125 | begin 126 | event = Stripe::Webhook.construct_event( 127 | payload, sig_header, STRIPE_WEBHOOK_SECRET 128 | ) 129 | rescue JSON::ParserError => e 130 | # Invalid payload 131 | head 400 132 | return 133 | rescue Stripe::SignatureVerificationError => e 134 | # Invalid signature 135 | puts '⚠️ Webhook signature verification failed.' 136 | head 400 137 | return 138 | end 139 | else 140 | data = JSON.parse(payload, symbolize_names: true) 141 | event = Stripe::Event.construct_from(data) 142 | end 143 | 144 | event_type = event['type'] 145 | 146 | if event_type == 'invoice.payment_succeeded' 147 | stripe_customer_id = JSON.parse(payload).dig('data', 'object', 'customer') 148 | 149 | user = User.find_by(stripe_customer_id: stripe_customer_id) 150 | return unless user 151 | 152 | user.update(stripe_subscription_id: JSON.parse(payload).dig('data', 'object', 'subscription')) 153 | end 154 | 155 | if event_type == 'invoice.payment_failed' 156 | stripe_customer_id = JSON.parse(payload).dig('data', 'object', 'customer') 157 | 158 | user = User.find_by(stripe_customer_id: stripe_customer_id) 159 | return unless user 160 | 161 | user.update(stripe_subscription_id: nil) 162 | end 163 | 164 | if event_type == 'customer.subscription.deleted' 165 | deleted_subscription_stripe_customer_id = JSON.parse(payload).dig('data', 'object', 'customer') 166 | 167 | user = User.find_by(stripe_customer_id: deleted_subscription_stripe_customer_id) 168 | return unless user 169 | 170 | user.update(stripe_subscription_id: nil) 171 | end 172 | 173 | if event_type == 'customer.deleted' 174 | deleted_stripe_customer_id = JSON.parse(payload).dig('data', 'object', 'id') 175 | 176 | user = User.find_by(stripe_customer_id: deleted_stripe_customer_id) 177 | return unless user 178 | 179 | user.update(stripe_customer_id: nil, stripe_subscription_id: nil) 180 | end 181 | 182 | render json: { status: 'success' }.to_json 183 | end 184 | end 185 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/pages_controller.rb: -------------------------------------------------------------------------------- 1 | class PagesController < ApplicationController 2 | skip_before_action :authorized 3 | 4 | def index 5 | end 6 | 7 | def privacy 8 | end 9 | 10 | def transparency 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | skip_before_action :authorized, only: [:new, :create] 3 | 4 | def new 5 | return redirect_to :articles if logged_in? 6 | 7 | @user = User.new 8 | render :login 9 | end 10 | 11 | def create 12 | if @user = User.find_by(account_number: params[:account_number].delete(' ')) 13 | session[:user_id] = @user.id 14 | if params[:return_to].present? 15 | redirect_to params[:return_to] 16 | else 17 | redirect_to :articles 18 | end 19 | else 20 | redirect_to :login, flash: { error: "This account number does not exist." } 21 | end 22 | end 23 | 24 | def destroy 25 | session[:user_id] = nil 26 | redirect_to '/' 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | skip_before_action :authorized, only: [:new, :create] 3 | 4 | def show 5 | if params.key?('subscription_complete') 6 | subscription_complete = params['subscription_complete'] 7 | 8 | if subscription_complete == 'true' 9 | flash[:success] = 'Welcome to Freshreader Pro! Thank you for your support. Enjoy! ❤️' 10 | redirect_to :articles 11 | elsif subscription_complete == 'false' 12 | 10.times do 13 | if current_user.reload.subscribed? 14 | flash[:success] = 'Welcome to Freshreader Pro! Thank you for your support. Enjoy! ❤️' 15 | return redirect_to :account 16 | end 17 | sleep(1) 18 | end 19 | 20 | flash[:info] = 'The payment method is taking a while to process, refresh in a few minutes.' 21 | return redirect_to :account 22 | end 23 | end 24 | @user = current_user 25 | end 26 | 27 | def create 28 | @user = User.new(account_number: User.generate_account_number) 29 | 30 | if @user.save 31 | session[:user_id] = @user.id 32 | flash[:success] = "Welcome to Freshreader! Start below. 👇" 33 | redirect_to :account 34 | else 35 | flash[:error] = @user.errors.full_messages.to_sentence 36 | redirect_to :login 37 | end 38 | end 39 | 40 | def destroy 41 | user = current_user 42 | return unless user 43 | 44 | if user.subscribed? 45 | Stripe::Subscription.delete(user.stripe_subscription_id) 46 | end 47 | 48 | if user.destroy 49 | flash[:success] = 'Account deleted successfully.' 50 | else 51 | flash[:error] = 'There was an issue deleting your account.' 52 | end 53 | redirect_to :index 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def controller?(*controller) 3 | controller.include?(params[:controller]) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/helpers/request_helper.rb: -------------------------------------------------------------------------------- 1 | require 'httparty' 2 | 3 | module RequestHelper 4 | extend self 5 | 6 | def extract_title_from_page(url) 7 | response = fetch_with_fallback(url) 8 | title = title_from_response_body(response.body) 9 | [title, response.request.last_uri.to_s] 10 | end 11 | 12 | def title_from_response_body(body) 13 | title = body&.match(/(.*?)<\/title>/m)&.[](1)&.strip&.force_encoding('UTF-8') 14 | HTMLEntities.new.decode(title) if title 15 | end 16 | 17 | def url_from_param(param_value) 18 | URI::Parser.new.unescape(param_value.strip) 19 | end 20 | 21 | private 22 | 23 | def fetch_with_fallback(uri_str) 24 | do_fetch(uri_str.sub('http://', 'https://')) 25 | rescue 26 | do_fetch(uri_str) 27 | end 28 | 29 | def do_fetch(uri_str) 30 | unless uri_str.start_with?('http://') || uri_str.start_with?('https://') || uri_str.start_with?('//') 31 | uri_str = "https://#{uri_str}" 32 | end 33 | 34 | HTTParty.get(URI::Parser.new.escape(uri_str)) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/javascript/channels/consumer.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `rails generate channel` command. 3 | 4 | import { createConsumer } from "@rails/actioncable" 5 | 6 | export default createConsumer() 7 | -------------------------------------------------------------------------------- /app/javascript/channels/index.js: -------------------------------------------------------------------------------- 1 | // Load all the channels within this directory and all subdirectories. 2 | // Channel files must be named *_channel.js. 3 | 4 | const channels = require.context('.', true, /_channel\.js$/) 5 | channels.keys().forEach(channels) 6 | -------------------------------------------------------------------------------- /app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | // This file is automatically compiled by Webpack, along with any other files 2 | // present in this directory. You're encouraged to place your actual application logic in 3 | // a relevant structure within app/javascript and only use these pack files to reference 4 | // that code so it'll be compiled. 5 | 6 | require("@rails/ujs").start() 7 | require("turbolinks").start() 8 | require("@rails/activestorage").start() 9 | require("channels") 10 | 11 | // Uncomment to copy all static images under ../images to the output folder and reference 12 | // them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>) 13 | // or the `imagePath` JavaScript helper below. 14 | // 15 | // const images = require.context('../images', true) 16 | // const imagePath = (name) => images(name, true) 17 | -------------------------------------------------------------------------------- /app/javascript/packs/stripe.js: -------------------------------------------------------------------------------- 1 | const stripe_public_key = document.getElementById('stripe_public_key').dataset.attrs; 2 | 3 | var stripe = Stripe(stripe_public_key); 4 | var elements = stripe.elements({ 5 | locale: 'en', 6 | }); 7 | 8 | var style = { 9 | base: { 10 | color: "#32325d", 11 | fontFamily: '"Helvetica Neue", Helvetica, sans-serif', 12 | fontSmoothing: "antialiased", 13 | fontSize: "22px", 14 | "::placeholder": { 15 | color: "#aab7c4" 16 | } 17 | }, 18 | invalid: { 19 | color: "#fa755a", 20 | iconColor: "#fa755a" 21 | } 22 | }; 23 | 24 | const user_is_on_free_plan = document.getElementById('user_is_on_free_plan').dataset.attrs == 'true'; 25 | if (user_is_on_free_plan) { 26 | var cardElement = elements.create("card", { style: style }); 27 | cardElement.mount("#card-element"); 28 | cardElement.on('change', showCardError) 29 | 30 | let submitButton = document.getElementById('submit-button'); 31 | let subscriptionForm = document.getElementById('subscription-form'); 32 | if (subscriptionForm) { 33 | subscriptionForm.addEventListener('submit', function (evt) { 34 | evt.preventDefault(); 35 | 36 | submitButton.textContent = 'Processing...'; 37 | 38 | // If a previous payment was attempted, get the lastest invoice 39 | const latestInvoicePaymentIntentStatus = localStorage.getItem( 40 | 'latestInvoicePaymentIntentStatus' 41 | ); 42 | 43 | if (latestInvoicePaymentIntentStatus === 'requires_payment_method') { 44 | const invoiceId = localStorage.getItem('latestInvoiceId'); 45 | const isPaymentRetry = true; 46 | 47 | // Create new payment method & retry payment on invoice with new payment method 48 | createPaymentMethod( 49 | cardElement, 50 | isPaymentRetry, 51 | invoiceId 52 | ); 53 | } else { 54 | // Create new payment method & create subscription 55 | createPaymentMethod(cardElement); 56 | } 57 | }); 58 | } 59 | 60 | function showCardError(event) { 61 | submitButton.textContent = 'Upgrade'; 62 | let displayError = document.getElementById('card-errors'); 63 | if (event.error) { 64 | displayError.textContent = event.error.message; 65 | } else { 66 | displayError.textContent = ''; 67 | } 68 | } 69 | 70 | function createPaymentMethod(cardElement, isRetry, invoiceId) { 71 | return stripe 72 | .createPaymentMethod({ 73 | type: 'card', 74 | card: cardElement, 75 | }) 76 | .then((result) => { 77 | if (result.error) { 78 | showCardError(result.error); 79 | } else { 80 | if (isRetry) { 81 | retryInvoiceWithNewPaymentMethod({ 82 | paymentMethodId: result.paymentMethod.id, 83 | invoiceId, 84 | }); 85 | } else { 86 | createSubscription(result.paymentMethod.id); 87 | } 88 | } 89 | }); 90 | } 91 | 92 | function clearCache() { 93 | localStorage.clear(); 94 | } 95 | 96 | function onSubscriptionComplete(result) { 97 | clearCache(); 98 | 99 | let subscription = result.subscription || invoice.subscription; 100 | 101 | if (subscription) { 102 | if (subscription.status === 'active') { 103 | fetch('/subscription_callback', { 104 | method: 'post', 105 | headers: { 106 | 'Content-Type': 'application/json', 107 | 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 108 | }, 109 | body: JSON.stringify({ 110 | subscription: subscription, 111 | }), 112 | }) 113 | .then((response) => { 114 | window.location = '/account?subscription_complete=true' 115 | }) 116 | } else if (subscription.status === 'incomplete') { 117 | window.location = '/account?subscription_complete=false' 118 | } 119 | } else { 120 | throw "Expected subscription, didn't receive any." 121 | } 122 | } 123 | 124 | function handleCustomerActionRequired({ 125 | subscription, 126 | invoice, 127 | priceId, 128 | paymentMethodId, 129 | isRetry, 130 | }) { 131 | if (subscription && subscription.status === 'active') { 132 | // Subscription is active, no customer actions required. 133 | return { subscription, priceId, paymentMethodId }; 134 | } 135 | 136 | // If it's a first payment attempt, the payment intent is on the subscription latest invoice. 137 | // If it's a retry, the payment intent will be on the invoice itself. 138 | let paymentIntent = invoice ? invoice.payment_intent : subscription.latest_invoice.payment_intent; 139 | 140 | if ( 141 | paymentIntent.status === 'requires_action' || 142 | (isRetry === true && paymentIntent.status === 'requires_payment_method') 143 | ) { 144 | return stripe 145 | .confirmCardPayment(paymentIntent.client_secret, { 146 | payment_method: paymentMethodId, 147 | }) 148 | .then((result) => { 149 | if (result.error) { 150 | throw result; 151 | } else { 152 | if (result.paymentIntent.status === 'succeeded') { 153 | return { 154 | priceId: priceId, 155 | subscription: subscription, 156 | invoice: invoice, 157 | paymentMethodId: paymentMethodId, 158 | }; 159 | } 160 | } 161 | }) 162 | .catch((error) => { 163 | showCardError(error); 164 | }); 165 | } else { 166 | return { invoice, subscription, priceId, paymentMethodId }; 167 | } 168 | } 169 | 170 | function handlePaymentMethodRequired({ 171 | subscription, 172 | paymentMethodId, 173 | priceId, 174 | }) { 175 | if (subscription.status === 'active') { 176 | // subscription is active, no customer actions required. 177 | return { subscription, priceId, paymentMethodId }; 178 | } else if ( 179 | subscription.latest_invoice.payment_intent.status === 180 | 'requires_payment_method' 181 | ) { 182 | localStorage.setItem('latestInvoiceId', subscription.latest_invoice.id); 183 | localStorage.setItem( 184 | 'latestInvoicePaymentIntentStatus', 185 | subscription.latest_invoice.payment_intent.status 186 | ); 187 | throw { error: { message: 'Your card was declined.' } }; 188 | } else { 189 | return { subscription, priceId, paymentMethodId }; 190 | } 191 | } 192 | 193 | function retryInvoiceWithNewPaymentMethod({ 194 | customerId, 195 | paymentMethodId, 196 | invoiceId, 197 | priceId 198 | }) { 199 | return ( 200 | fetch('/retry_invoice', { 201 | method: 'post', 202 | headers: { 203 | 'Content-type': 'application/json', 204 | 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 205 | }, 206 | body: JSON.stringify({ 207 | customerId: customerId, 208 | paymentMethodId: paymentMethodId, 209 | invoiceId: invoiceId, 210 | }), 211 | }) 212 | .then((response) => { 213 | return response.json(); 214 | }) 215 | .then((result) => { 216 | if (result.error) { 217 | throw result; 218 | } 219 | return result; 220 | }) 221 | .then((result) => { 222 | return { 223 | invoice: result, 224 | paymentMethodId: paymentMethodId, 225 | priceId: priceId, 226 | isRetry: true, 227 | }; 228 | }) 229 | .then(handleCustomerActionRequired) 230 | .then(onSubscriptionComplete) 231 | .catch((error) => { 232 | if (error) showCardError(error); 233 | }) 234 | ); 235 | } 236 | 237 | function createSubscription(paymentMethodId) { 238 | return ( 239 | fetch('/create_subscription', { 240 | method: 'post', 241 | headers: { 242 | 'Content-Type': 'application/json', 243 | 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), 244 | }, 245 | body: JSON.stringify({ 246 | paymentMethodId: paymentMethodId, 247 | }), 248 | }) 249 | .then((response) => { 250 | return response.json(); 251 | }) 252 | .then((result) => { 253 | if (result.error) { 254 | throw result; 255 | } 256 | return result; 257 | }) 258 | .then((result) => { 259 | return { 260 | paymentMethodId: paymentMethodId, 261 | priceId: result.plan.id, 262 | subscription: result, 263 | }; 264 | }) 265 | .then(handleCustomerActionRequired) 266 | .then(handlePaymentMethodRequired) 267 | .then(onSubscriptionComplete) 268 | .catch((error) => { 269 | if (error) showCardError(error); 270 | }) 271 | ); 272 | } 273 | } -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /app/models/article.rb: -------------------------------------------------------------------------------- 1 | class Article < ApplicationRecord 2 | ARTICLES_LIMIT_ON_FREE_PLAN = 3 3 | 4 | belongs_to :user 5 | validates :url, presence: true, url: true 6 | 7 | include ActiveModel::Serializers::JSON 8 | 9 | def attributes 10 | { 11 | 'id' => id, 12 | 'title' => title, 13 | 'url' => url, 14 | 'created_at' => created_at, 15 | } 16 | end 17 | 18 | def age_in_days 19 | ((Time.now.utc - self.created_at) / 1.day).round 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | class User < ApplicationRecord 4 | has_many :articles, dependent: :destroy 5 | validates :account_number, uniqueness: true, presence: true 6 | validates_format_of :account_number, :with => /\A\d{16}\z/ 7 | 8 | include ActiveModel::Serializers::JSON 9 | 10 | def attributes 11 | { 12 | 'id' => id, 13 | 'account_number' => account_number, 14 | 'api_auth_token' => api_auth_token, 15 | } 16 | end 17 | 18 | def subscribed? 19 | stripe_subscription_id.present? 20 | end 21 | 22 | def early_adopter? 23 | is_early_adopter? 24 | end 25 | 26 | def pretty_account_number 27 | account_number.chars.each_slice(4).map(&:join).join(' ') 28 | end 29 | 30 | def self.generate_account_number 31 | new_account_number = generate_16_digit_number 32 | 33 | while find_by(account_number: new_account_number) 34 | new_account_number = generate_16_digit_number 35 | end 36 | 37 | new_account_number 38 | end 39 | 40 | def self.generate_api_auth_token 41 | loop do 42 | token = SecureRandom.hex(32) 43 | break token unless User.where(api_auth_token: token).exists? 44 | end 45 | end 46 | 47 | def regenerate_api_auth_token_if_expired! 48 | return if api_auth_token.present? && api_auth_token_expires_at > Time.now 49 | self.api_auth_token = User.generate_api_auth_token 50 | self.api_auth_token_expires_at = Time.now + 1.day 51 | self.save 52 | end 53 | 54 | private_class_method def self.generate_16_digit_number 55 | SecureRandom.random_number(10**16).to_s.rjust(16, '0') 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /app/views/articles/list.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Reading list

5 |
6 | 7 | Add by URL + 8 | 9 |
10 | 11 | 20 | 21 |
22 | <% if @articles.size > 0 %> 23 | <% @articles.each do |article| %> 24 |
25 |
26 | 27 | 34 | 37 |
38 |
39 | <% end %> 40 | <% else %> 41 |
42 |
Your list is empty. Take a moment. 🍃
43 |

If you're curious to learn something new, try <%= link_to "adding a random featured Wikipedia article", articles_path(article: { url: 'https://en.wikipedia.org/wiki/Special:RandomInCategory/Featured_articles' }), method: :post, class: 'internal-link' %>. 📙

44 |
45 | <% end %> 46 | 47 | <% if @articles.size < 3 %> 48 |
49 |
How to save content to your list
50 | 51 |

Use one of the following methods to save content to your Freshreader list:

52 | 66 |
67 | <% end %> 68 |
69 |
70 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Freshreader 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | 9 | 10 | <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %> 11 | <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %> 12 | <%= favicon_link_tag %> 13 | 14 | 15 | 16 |
17 |

Heads up: Freshreader will shut down on November 27th, 2022. 👋

You may consider using Pocket along with this little tool I built. Thank you for using Freshreader over the past couple of years!

18 | 40 | 41 |
42 | <% flash.each do |name, msg| %> 43 |
44 | <%= msg.html_safe %> 45 |
46 | <% end %> 47 |
48 | 49 |
50 | <%= yield %> 51 |
52 | 53 |
54 |
55 |

56 |

57 |

58 | Built by Maxime Vaillancourt in Montreal, Canada ✌️ 59 |

60 |
61 | <%= link_to "🍃 A few words on privacy", '/privacy', class: "internal-link" %> 62 | · 63 | <%= link_to "📖 Transparency", '/transparency', class: "internal-link" %> 64 | · 65 | <%= link_to "👨‍💻 Open source", 'https://github.com/freshreader' %> 66 |
67 |
68 |
69 |
70 | 71 | 72 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /app/views/pages/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 |

The reading list for tab hoarders.

5 | 6 |

7 | Let's be honest: you'll never read most of your currently open tabs. And that's okay. 🙂 8 |

9 | 10 | 11 |
12 |

13 | Save your open tabs to your Freshreader list to go from this mess... 14 |

15 | 16 |
17 |

18 | " /> 19 |

20 |
21 |
22 |

23 | " /> 24 |

25 |
26 | 27 | 28 |

... to a place of focus and clarity.

29 | 30 |
31 |

32 | " /> 33 |

34 |
35 |
36 |

37 | " /> 38 |

39 |
40 |
41 | 42 |
43 |

44 | Now, simply moving everything into your Freshreader list wouldn't help, so here's the twist: 45 |

46 | 47 |

48 | Freshreader lets go of saved items after 7 days. This way, your list doesn't grow infinitely. 49 |

50 | 51 |

52 | Letting go is key. If it's important, trust that it'll somehow make its way back into your life. 🦉 53 |

54 |
55 |

56 | 57 |

58 | <% if logged_in? %> 59 | <%= link_to "View reading list", '/articles', class: "glow-primary no-margin btn btn-large btn-success internal-link" %> 60 |

(you're already logged in and ready to go) 🚀
61 | <% else %> 62 | <%= link_to "Create an account", '/signup', method: :post, class: "glow-primary no-margin btn btn-large btn-success internal-link" %> 63 |
(literally takes a second, no email required—really, try it!)
64 | <% end %> 65 |

66 |
67 |
68 | 69 |
70 |
71 |
72 |

73 | It's such a good idea and definitely the twist I was looking for as I often find myself saving articles and never looking back at them. Thanks for creating this 🙂 74 |

75 | 76 |
77 | "> 78 | 79 | Karthik Nandula, blogger 80 | 81 |
82 |
83 |
84 |

85 | I looove freshreader, it really was something that came to my life unexpected, but was something I needed. Keep up the great work!! 86 |

87 | 88 |
89 | "> 90 | 91 | Maximilian Jendrall, developer 92 | 93 |
94 |
95 |
96 |
97 | -------------------------------------------------------------------------------- /app/views/pages/privacy.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

A few words on privacy 🍃

3 |

TL;DR

4 |

Put simply, Freshreader stores your random 16-digit account number and the list of URLs you saved, which are deleted automatically 7 days after being saved (for real!). When you delete your account, it is immediately deleted from the database along with your remaining saved URLs (again, for real!).

5 | 6 |


7 | 8 |

A non-technical overview

9 |

10 | Hi, I'm Maxime. I made Freshreader. I'm a performance- and privacy-conscious software developer, and in an age where privacy-greedy applications are commonplace, I aim to build software that requires as little information as necessary from the people using it to function properly. 11 |

12 | 13 |

14 | I don't need your email address, nor your name or even an arbitrary username, nor a picture of you, nor your location, or anything of that matter for Freshreader to do its job, so I simply don't ask for it. I think it's better for everyone this way: you don't have to worry about what I'm doing with this information, and I don't have to worry about mishandling your personal information. 15 |

16 | 17 |

18 | Sure, the application may feel a bit "dry" because it's not particularly "personalized to your taste", but I think that's an acceptable downside considering the privacy win for everyone. 19 |

20 | 21 |

22 | Feedback is always welcome, either on GitHub or Twitter. ✌️ 23 |

24 | 25 |
26 | 27 |

The techy bits

28 |

29 | Freshreader is a really simple Rails-based web application backed by only 2 database tables: users and articles. Here's a short explanation of the database schema as of 2020-05-03: 30 | 31 |

articles table

32 | 52 | 53 |

users table

54 | 77 | 78 |

79 | As for cookies, Freshreader uses a single cookie named _freshreader_session to keep your Freshreader session active (otherwise you'd have to type in your account number every time you'd want to see your list, or save an article). This cookie is only used on the freshreader.app domain, and expires when you close your browser. I don't track you across other websites, because I think that's wrong, and because Freshreader doesn't need this to work. 80 |

81 | 82 |
83 | 84 |

85 | In a way, this application is a bit naive, and that's how I want it to be. No shadow profiles, no "soft-deleted" records, no shady practices. Just clear intentions and open source implementation. 86 |

87 |

88 | I hope Freshreader is adding value to your life. I'm always available to chat on Twitter, and feedback is welcome on GitHub. 👋 89 |

90 |
91 | -------------------------------------------------------------------------------- /app/views/pages/transparency.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Transparency

3 | 4 |

Accounts

5 | 16 |

These numbers do not include deleted accounts.

17 | 18 |

Saved items

19 | 24 |
25 | -------------------------------------------------------------------------------- /app/views/sessions/login.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Login

3 | <%= form_tag '/login' do %> 4 | <%= label_tag :account_number, 'Account number', class: 'url-input-label' %> 5 | 6 | 7 |
8 | <%= text_field_tag :account_number, "", placeholder: '0000 0000 0000 0000', class: "flex-1", autofocus: 'autofocus' %> 9 | <%= submit_tag "Login", class: 'btn-input-group btn-success no-margin' %> 10 |
11 | 12 |

13 | Don't have an account yet? 14 | <%= link_to "Create one now", '/signup', method: :post, class: "internal-link" %> 15 | (literally takes a second, no email required). 16 |

17 | <% end %> 18 |
19 | -------------------------------------------------------------------------------- /app/views/users/show.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Your account

3 | 4 |
5 |
Account number
6 |

7 | This is your account number. Use it to log in to Freshreader. Store it somewhere safe.
8 |

9 | 10 | 13 | 14 |

15 | That's right. No username, no password. Simple as that. 16 |

17 |
18 | 19 |
20 |
How to save content to your list
21 | 22 |

Use one of the following methods to save content to your Freshreader list:

23 | 37 | 38 |

39 | Now that you know how to save content, let's take a look at your reading list. 40 |

41 |

42 | Oh, and before you go: it's okay not to read everything. 🍃 43 |

44 | 45 |

46 | <%= link_to "View reading list", '/articles', class: "btn btn-success internal-link" %> 47 |

48 |
49 | 50 |
51 |
Pro subscription
52 | <% if @user.subscribed? %> 53 |

You are on the Pro plan. Thank you for your support!

54 |
    55 |
  • You help keep Freshreader up and running without ads ❤️
  • 56 |
  • You can save unlimited items to your list ➕
  • 57 | 61 |
62 | 63 |

Looking to downgrade to the Free plan? This button will downgrade your account immediately.

64 | <%= button_to "Cancel subscription", '/cancel_subscription', method: :post, class: "btn-default internal-link", onclick: "return confirm('Are you sure you want to downgrade to the Free plan?');" %> 65 | <% elsif @user.early_adopter? %> 66 |

You were an early adopter, meaning you get the Pro plan at no charge. Thank you for your support! ❤️

67 |
    68 |
  • You can save unlimited items to your list ➕
  • 69 | 73 |
74 | <% else %> 75 |

You are on the free plan, which is limited to <%= Article::ARTICLES_LIMIT_ON_FREE_PLAN %> saved items.

76 | 77 | You can upgrade to Freshreader Pro. For a tiny USD $3/month... 78 | 79 |
    80 |
  • You help keep Freshreader up and running without ads ❤️
  • 81 |
  • You can save unlimited items to your list ➕
  • 82 | 86 |
87 | 88 |

Let's upgrade your account! ✨

89 | 90 |
91 |
92 |
93 | 94 |
95 |
96 | 97 | 98 | 99 | 100 | 101 |
102 | <% end %> 103 |
104 | 105 |
106 |
Danger zone
107 | <% if @user.subscribed? %> 108 |

You are currently subscribed to the Pro plan. Deleting your account will stop your subscription.

109 | <% elsif @user.early_adopter? %> 110 |

Your account is an Early Adopter account, meaning you have access to the Pro plan at no charge. You will lose your Early Adopter status by deleting your account.

111 | <% end %> 112 | 113 |

114 | This button deletes your account number and any articles left in your list, forever. 115 |

116 | 117 | <%= button_to 'Delete my account', :account, method: :delete, class: 'btn-danger btn-small internal-link', onclick: "return confirm('Are you sure you want to delete your Freshreader account?');"%> 118 |
119 |
120 | 121 |
122 |
123 | 124 | <%= javascript_pack_tag 'stripe', 'data-turbolinks-track': 'reload' %> -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function(api) { 2 | var validEnv = ['development', 'test', 'production'] 3 | var currentEnv = api.env() 4 | var isDevelopmentEnv = api.env('development') 5 | var isProductionEnv = api.env('production') 6 | var isTestEnv = api.env('test') 7 | 8 | if (!validEnv.includes(currentEnv)) { 9 | throw new Error( 10 | 'Please specify a valid `NODE_ENV` or ' + 11 | '`BABEL_ENV` environment variables. Valid values are "development", ' + 12 | '"test", and "production". Instead, received: ' + 13 | JSON.stringify(currentEnv) + 14 | '.' 15 | ) 16 | } 17 | 18 | return { 19 | presets: [ 20 | isTestEnv && [ 21 | '@babel/preset-env', 22 | { 23 | targets: { 24 | node: 'current' 25 | } 26 | } 27 | ], 28 | (isProductionEnv || isDevelopmentEnv) && [ 29 | '@babel/preset-env', 30 | { 31 | forceAllTransforms: true, 32 | useBuiltIns: 'entry', 33 | corejs: 3, 34 | modules: false, 35 | exclude: ['transform-typeof-symbol'] 36 | } 37 | ] 38 | ].filter(Boolean), 39 | plugins: [ 40 | 'babel-plugin-macros', 41 | '@babel/plugin-syntax-dynamic-import', 42 | isTestEnv && 'babel-plugin-dynamic-import-node', 43 | '@babel/plugin-transform-destructuring', 44 | [ 45 | '@babel/plugin-proposal-class-properties', 46 | { 47 | loose: true 48 | } 49 | ], 50 | [ 51 | '@babel/plugin-proposal-object-rest-spread', 52 | { 53 | useBuiltIns: true 54 | } 55 | ], 56 | [ 57 | '@babel/plugin-transform-runtime', 58 | { 59 | helpers: false, 60 | regenerator: true, 61 | corejs: false 62 | } 63 | ], 64 | [ 65 | '@babel/plugin-transform-regenerator', 66 | { 67 | async: false 68 | } 69 | ] 70 | ].filter(Boolean) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= 65 | env_var_version || cli_arg_version || 66 | lockfile_version 67 | end 68 | 69 | def bundler_requirement 70 | return "#{Gem::Requirement.default}.a" unless bundler_version 71 | 72 | bundler_gem_version = Gem::Version.new(bundler_version) 73 | 74 | requirement = bundler_gem_version.approximate_recommendation 75 | 76 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0") 77 | 78 | requirement += ".a" if bundler_gem_version.prerelease? 79 | 80 | requirement 81 | end 82 | 83 | def load_bundler! 84 | ENV["BUNDLE_GEMFILE"] ||= gemfile 85 | 86 | activate_bundler 87 | end 88 | 89 | def activate_bundler 90 | gem_error = activation_error_handling do 91 | gem "bundler", bundler_requirement 92 | end 93 | return if gem_error.nil? 94 | require_error = activation_error_handling do 95 | require "bundler/version" 96 | end 97 | return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 98 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" 99 | exit 42 100 | end 101 | 102 | def activation_error_handling 103 | yield 104 | nil 105 | rescue StandardError, LoadError => e 106 | e 107 | end 108 | end 109 | 110 | m.load_bundler! 111 | 112 | if m.invoked_as_script? 113 | load Gem.bin_path("bundler", "bundle") 114 | end 115 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | APP_PATH = File.expand_path('../config/application', __dir__) 8 | require_relative '../config/boot' 9 | require 'rails/commands' 10 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path('../spring', __FILE__) 4 | rescue LoadError => e 5 | raise unless e.message.include?('spring') 6 | end 7 | require_relative '../config/boot' 8 | require 'rake' 9 | Rake.application.run 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to setup or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies 21 | # system('bin/yarn') 22 | 23 | # puts "\n== Copying sample files ==" 24 | # unless File.exist?('config/database.yml') 25 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 26 | # end 27 | 28 | puts "\n== Preparing database ==" 29 | system! 'bin/rails db:prepare' 30 | 31 | puts "\n== Removing old logs and tempfiles ==" 32 | system! 'bin/rails log:clear tmp:clear' 33 | 34 | puts "\n== Restarting application server ==" 35 | system! 'bin/rails restart' 36 | end 37 | -------------------------------------------------------------------------------- /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 | lockfile = Bundler::LockfileParser.new(Bundler.default_lockfile.read) 11 | spring = lockfile.specs.detect { |spec| spec.name == 'spring' } 12 | if spring 13 | Gem.use_paths Gem.dir, Bundler.bundle_path.to_s, *Gem.path 14 | gem 'spring', spring.version 15 | require 'spring/binstub' 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/webpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/webpack_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::WebpackRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/webpack-dev-server: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | ENV["RAILS_ENV"] ||= ENV["RACK_ENV"] || "development" 4 | ENV["NODE_ENV"] ||= "development" 5 | 6 | require "pathname" 7 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 8 | Pathname.new(__FILE__).realpath) 9 | 10 | require "bundler/setup" 11 | 12 | require "webpacker" 13 | require "webpacker/dev_server_runner" 14 | 15 | APP_ROOT = File.expand_path("..", __dir__) 16 | Dir.chdir(APP_ROOT) do 17 | Webpacker::DevServerRunner.run(ARGV) 18 | end 19 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | begin 5 | exec "yarnpkg", *ARGV 6 | rescue Errno::ENOENT 7 | $stderr.puts "Yarn executable was not detected in the system." 8 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 9 | exit 1 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require 'rails/all' 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Freshreader 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 6.0 13 | 14 | # Settings in config/environments/* take precedence over those specified here. 15 | # Application configuration can go into files in config/initializers 16 | # -- all .rb files in that directory are automatically loaded after loading 17 | # the framework and any gems in your application. 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /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 | require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 5 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: freshreader_production 11 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | XnqsZ4IHOUZS4vioUAYamKBijSxl3Prt7JpzF/rJEmyHofPmJAvF5UDTbssogtkCeYRYJX4rg/os+g3vO7mFu1s26e4i+EXVIilahmCnh1DRBlGWFL4JJW1+M8nxeD+d9kFmINQcRCdUk2ln0mAR7pELofrPefW1qvs1+OiT3y2N9Wf6gs6MIxSH6fDYZ69D7+lvQnljdy19qt4PlHT6QP/tzNqr8w6IWyx9jR2DL1eaMFy7nF5T7taoVcdM2nJPdZH5QoRBVwFnElai6JSYVVDo+wV8DF0sHjhtbzlqcInCfgq0BE5lBipBIVwRGPss5N+B2OFt8ONrV2N6hWuD5kvfTHN82kCdi6he/nqHdwBUzKXa/J9QEl3MUnlw9JB8iZRg4Lh4M10HinT8U9xFz/ZJmXBMtnOU9DTVCcOHMhAkW5bqDHm89CGXOJ3/S4vPATcTgx0u/gtSgGz0VWh5BPP/WC8l4kYuwb3mzUaCI3rIcp5gtFxhrVAaLhuu+AFBIqDW//NZ88uG55Fgj4v5duBwdoJyTOMffdkE4nzrtjQX+7G/gyEU/o0gowe+hGARP205PK0Qz/nao5od85rEvWi9PhJ3g+x1x8BKxV4laldcKzWXfxI7dgxASvEt+RF4J74Lh9aKjGsn4SbQI9wR0F05kHLxMJk//jo0HoducqENWEZnIEzaE+Be0Pkz6c+RphiAHLBKhwh7qkexEUbkd1m31MaWwkj0LTMq9XoaDyHkTQLnQoW4mWveqsW1aSa05lORyBDE9s74YVhvymc+lf5V9t2mzUMbsuZ8oSpu0RVXnw2XWHgyoGzkGrflxh1VvMGTToJ/G/4fi6xFhgBTO1HSijQWvwyu2OF1/zzFvnH5pI00kwyoe7BrHxc1mrHrs3bBIDxmyLpb9ZoBh0bE8vTVqhnJ1JiRaqoqJaGXeCjqSZ7fMtEInWwYVQWtoVY82qkTJo67ObCy+IbHrFrRf7njLHeGKtW8iH4du1/zpgrUiTGmvjDd1X1D2R1abomNrwikwFPkVKfJOjBzMh/bWJFUZ5inXtUxein4iRahl4JLi5nOnUyRBkOpZ4YxAhkYhR6RRY0qwbBEhxbxQj3ldQmx619RgGC57YU2b38zXwZdlIYOG3CJKa71Gj/euR/g4Bq/ZDQ9iGi5Cm+HCyONlYjdcxRRzARgNxmJIRQoZeh4xxHEqn/wqGF9IG3BX5oQ6zjnr4snLq9Hxp4pY5qkK2HTy3+XbqT08R8EEot8dYRNEq7OCdYXlqTs6qgbNWZzWFaYfxJZ95V3OORtuZbu1iJ1p0XasinsKcpGQeRejVnyUWP/nhFzfUW7PHWFEfTK+B2B88GdRPhh0wJXBJc6qfumLilg5Q6h43OWs1wdyFaj7cN6SEos828MV4FaN1WazuhKX865Fx42sUJvF5Ba1yC6OB4i7lRiaiaG1WewhU4G4/gW9Zhp275Q--OqTpCfAvPXyc5S1l--opLSSxOTK7ga92Loj++6mA== -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 9.3 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On macOS with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On macOS with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem 'pg' 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: unicode 20 | # For details on connection pooling, see Rails configuration guide 21 | # https://guides.rubyonrails.org/configuring.html#database-pooling 22 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 23 | 24 | development: 25 | <<: *default 26 | database: freshreader_development 27 | 28 | # The specified database role being used to connect to postgres. 29 | # To create additional roles in postgres see `$ createuser --help`. 30 | # When left blank, postgres will use the default role. This is 31 | # the same name as the operating system user that initialized the database. 32 | #username: freshreader 33 | 34 | # The password associated with the postgres role (username). 35 | #password: 36 | 37 | # Connect on a TCP socket. Omitted by default since the client uses a 38 | # domain socket that doesn't need configuration. Windows does not have 39 | # domain sockets, so uncomment these lines. 40 | #host: localhost 41 | 42 | # The TCP port the server listens on. Defaults to 5432. 43 | # If your server runs on a different port number, change accordingly. 44 | #port: 5432 45 | 46 | # Schema search path. The server defaults to $user,public 47 | #schema_search_path: myapp,sharedapp,public 48 | 49 | # Minimum log levels, in increasing order: 50 | # debug5, debug4, debug3, debug2, debug1, 51 | # log, notice, warning, error, fatal, and panic 52 | # Defaults to warning. 53 | #min_messages: notice 54 | 55 | # Warning: The database defined as "test" will be erased and 56 | # re-generated from your development database when you run "rake". 57 | # Do not set this db to the same as development or production. 58 | test: 59 | <<: *default 60 | database: freshreader_test 61 | 62 | # As with config/credentials.yml, you never want to store sensitive information, 63 | # like your database password, in your source code. If your source code is 64 | # ever seen by anyone, they now have access to your database. 65 | # 66 | # Instead, provide the password as a unix environment variable when you boot 67 | # the app. Read https://guides.rubyonrails.org/configuring.html#configuring-a-database 68 | # for a full rundown on how to provide these environment variables in a 69 | # production deployment. 70 | # 71 | # On Heroku and other platform providers, you may have a full connection URL 72 | # available as an environment variable. For example: 73 | # 74 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 75 | # 76 | # You can use this database configuration with: 77 | # 78 | # production: 79 | # url: <%= ENV['DATABASE_URL'] %> 80 | # 81 | production: 82 | <<: *default 83 | database: freshreader_production 84 | username: freshreader 85 | password: <%= ENV['FRESHREADER_DATABASE_PASSWORD'] %> 86 | -------------------------------------------------------------------------------- /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 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 18 | config.action_controller.perform_caching = true 19 | config.action_controller.enable_fragment_cache_logging = true 20 | 21 | config.cache_store = :memory_store 22 | config.public_file_server.headers = { 23 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 24 | } 25 | else 26 | config.action_controller.perform_caching = false 27 | 28 | config.cache_store = :null_store 29 | end 30 | 31 | # Store uploaded files on the local file system (see config/storage.yml for options). 32 | config.active_storage.service = :local 33 | 34 | # Don't care if the mailer can't send. 35 | config.action_mailer.raise_delivery_errors = false 36 | 37 | config.action_mailer.perform_caching = false 38 | 39 | # Print deprecation notices to the Rails logger. 40 | config.active_support.deprecation = :log 41 | 42 | # Raise an error on page load if there are pending migrations. 43 | config.active_record.migration_error = :page_load 44 | 45 | # Highlight code that triggered database queries in logs. 46 | config.active_record.verbose_query_logs = true 47 | 48 | # Debug mode disables concatenation and preprocessing of assets. 49 | # This option may cause significant delays in view rendering with a large 50 | # number of complex assets. 51 | config.assets.debug = true 52 | 53 | # Suppress logger output for asset requests. 54 | config.assets.quiet = true 55 | 56 | # Raises error for missing translations. 57 | # config.action_view.raise_on_missing_translations = true 58 | 59 | # Use an evented file watcher to asynchronously detect changes in source code, 60 | # routes, locales, etc. This feature depends on the listen gem. 61 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 62 | end 63 | -------------------------------------------------------------------------------- /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 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 18 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 19 | # config.require_master_key = true 20 | 21 | # Disable serving static files from the `/public` folder by default since 22 | # Apache or NGINX already handles this. 23 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 24 | 25 | # Compress CSS using a preprocessor. 26 | # config.assets.css_compressor = :sass 27 | 28 | # Do not fallback to assets pipeline if a precompiled asset is missed. 29 | config.assets.compile = false 30 | 31 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 32 | # config.action_controller.asset_host = 'http://assets.example.com' 33 | 34 | # Specifies the header that your server uses for sending files. 35 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 36 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 37 | 38 | # Store uploaded files on the local file system (see config/storage.yml for options). 39 | config.active_storage.service = :local 40 | 41 | # Mount Action Cable outside main process or domain. 42 | # config.action_cable.mount_path = nil 43 | # config.action_cable.url = 'wss://example.com/cable' 44 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 45 | 46 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 47 | config.force_ssl = true 48 | 49 | # Use the lowest log level to ensure availability of diagnostic information 50 | # when problems arise. 51 | config.log_level = :debug 52 | 53 | # Prepend all log lines with the following tags. 54 | config.log_tags = [ :request_id ] 55 | 56 | # Use a different cache store in production. 57 | # config.cache_store = :mem_cache_store 58 | 59 | # Use a real queuing backend for Active Job (and separate queues per environment). 60 | # config.active_job.queue_adapter = :resque 61 | # config.active_job.queue_name_prefix = "freshreader_production" 62 | 63 | config.action_mailer.perform_caching = false 64 | 65 | # Ignore bad email addresses and do not raise email delivery errors. 66 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 67 | # config.action_mailer.raise_delivery_errors = false 68 | 69 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 70 | # the I18n.default_locale when a translation cannot be found). 71 | config.i18n.fallbacks = true 72 | 73 | # Send deprecation notices to registered listeners. 74 | config.active_support.deprecation = :notify 75 | 76 | # Use default logging formatter so that PID and timestamp are not suppressed. 77 | config.log_formatter = ::Logger::Formatter.new 78 | 79 | # Use a different logger for distributed setups. 80 | # require 'syslog/logger' 81 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 82 | 83 | if ENV["RAILS_LOG_TO_STDOUT"].present? 84 | logger = ActiveSupport::Logger.new(STDOUT) 85 | logger.formatter = config.log_formatter 86 | config.logger = ActiveSupport::TaggedLogging.new(logger) 87 | end 88 | 89 | # Do not dump schema after migrations. 90 | config.active_record.dump_schema_after_migration = false 91 | 92 | # Inserts middleware to perform automatic connection switching. 93 | # The `database_selector` hash is used to pass options to the DatabaseSelector 94 | # middleware. The `delay` is used to determine how long to wait after a write 95 | # to send a subsequent read to the primary. 96 | # 97 | # The `database_resolver` class is used by the middleware to determine which 98 | # database is appropriate to use based on the time delay. 99 | # 100 | # The `database_resolver_context` class is used by the middleware to set 101 | # timestamps for the last write to the primary. The resolver uses the context 102 | # class timestamps to determine how long to wait before reading from the 103 | # replica. 104 | # 105 | # By default Rails will store a last write timestamp in the session. The 106 | # DatabaseSelector middleware is designed as such you can define your own 107 | # strategy for connection switching and pass that into the middleware through 108 | # these configuration options. 109 | # config.active_record.database_selector = { delay: 2.seconds } 110 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 111 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 112 | end 113 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. This avoids loading your whole application 12 | # just for the purpose of running a single test. If you are using a tool that 13 | # preloads Rails for running tests, you may have to set it to true. 14 | config.eager_load = false 15 | 16 | # Configure public file server for tests with Cache-Control for performance. 17 | config.public_file_server.enabled = true 18 | config.public_file_server.headers = { 19 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 20 | } 21 | 22 | # Show full error reports and disable caching. 23 | config.consider_all_requests_local = true 24 | config.action_controller.perform_caching = false 25 | config.cache_store = :null_store 26 | 27 | # Raise exceptions instead of rendering exception templates. 28 | config.action_dispatch.show_exceptions = false 29 | 30 | # Disable request forgery protection in test environment. 31 | config.action_controller.allow_forgery_protection = false 32 | 33 | # Store uploaded files on the local file system in a temporary directory. 34 | config.active_storage.service = :test 35 | 36 | config.action_mailer.perform_caching = false 37 | 38 | # Tell Action Mailer not to deliver emails to the real world. 39 | # The :test delivery method accumulates sent emails in the 40 | # ActionMailer::Base.deliveries array. 41 | config.action_mailer.delivery_method = :test 42 | 43 | # Print deprecation notices to the stderr. 44 | config.active_support.deprecation = :stderr 45 | 46 | # Raises error for missing translations. 47 | # config.action_view.raise_on_missing_translations = true 48 | end 49 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /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/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | # # If you are using webpack-dev-server then specify webpack-dev-server host 15 | # policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development? 16 | 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | 21 | # If you are using UJS then enable automatic nonce generation 22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 23 | 24 | # Set the nonce only to specific directives 25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 26 | 27 | # Report CSP violations to a specified URI 28 | # For further information see the following documentation: 29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 30 | # Rails.application.config.content_security_policy_report_only = true 31 | -------------------------------------------------------------------------------- /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 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /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/rack_attack.rb: -------------------------------------------------------------------------------- 1 | class Rack::Attack 2 | ### Configure Cache ### 3 | 4 | # If you don't want to use Rails.cache (Rack::Attack's default), then 5 | # configure it here. 6 | # 7 | # Note: The store is only used for throttling (not blocklisting and 8 | # safelisting). It must implement .increment and .write like 9 | # ActiveSupport::Cache::Store 10 | 11 | # Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new 12 | 13 | ### Throttle Spammy Clients ### 14 | 15 | # If any single client IP is making tons of requests, then they're 16 | # probably malicious or a poorly-configured scraper. Either way, they 17 | # don't deserve to hog all of the app server's CPU. Cut them off! 18 | # 19 | # Note: If you're serving assets through rack, those requests may be 20 | # counted by rack-attack and this throttle may be activated too 21 | # quickly. If so, enable the condition to exclude them from tracking. 22 | 23 | # Throttle all requests by IP (40rpm) 24 | # 25 | # Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}" 26 | throttle('req/ip', limit: 200, period: 5.minutes) do |req| 27 | req.ip # unless req.path.start_with?('/assets') 28 | end 29 | 30 | ### Prevent Brute-Force Login Attacks ### 31 | 32 | # The most common brute-force login attack is a brute-force password 33 | # attack where an attacker simply tries a large number of emails and 34 | # passwords to see if any credentials match. 35 | # 36 | # Another common method of attack is to use a swarm of computers with 37 | # different IPs to try brute-forcing a password for a specific account. 38 | 39 | # Throttle POST requests to /login by IP address 40 | # 41 | # Key: "rack::attack:#{Time.now.to_i/:period}:logins/ip:#{req.ip}" 42 | throttle('logins/ip', limit: 5, period: 20.seconds) do |req| 43 | if req.path == '/login' && req.post? 44 | req.ip 45 | end 46 | end 47 | 48 | # Throttle POST requests to /login by account_number param 49 | # 50 | # Key: "rack::attack:#{Time.now.to_i/:period}:logins/account_number:#{req.account_number}" 51 | # 52 | # Note: This creates a problem where a malicious user could intentionally 53 | # throttle logins for another user and force their login requests to be 54 | # denied, but that's not very common and shouldn't happen to you. (Knock 55 | # on wood!) 56 | throttle("logins/account_number", limit: 5, period: 20.seconds) do |req| 57 | if req.path == '/login' && req.post? 58 | # return the account_number if present, nil otherwise 59 | req.params['account_number'].presence 60 | end 61 | end 62 | 63 | throttle("api/v1/users", limit: 5, period: 60.seconds) do |req| 64 | if req.path.include?('/api/v1/users') 65 | req.ip 66 | end 67 | end 68 | 69 | ### Custom Throttle Response ### 70 | 71 | # By default, Rack::Attack returns an HTTP 429 for throttled responses, 72 | # which is just fine. 73 | # 74 | # If you want to return 503 so that the attacker might be fooled into 75 | # believing that they've successfully broken your app (or you just want to 76 | # customize the response), then uncomment these lines. 77 | # self.throttled_response = lambda do |env| 78 | # [ 503, # status 79 | # {}, # headers 80 | # ['']] # body 81 | # end 82 | end 83 | -------------------------------------------------------------------------------- /config/initializers/stripe.rb: -------------------------------------------------------------------------------- 1 | Stripe.api_key = Rails.application.credentials[Rails.env.to_sym][:stripe][:secret_key] 2 | STRIPE_WEBHOOK_SECRET = Rails.application.credentials[Rails.env.to_sym][:stripe][:webhook_secret] 3 | FRESHREADER_PRO_MONTHLY_PRICE_ID = Rails.application.credentials[Rails.env.to_sym][:stripe][:freshreader_pro_monthly_price_id] -------------------------------------------------------------------------------- /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/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 12 | # 13 | port ENV.fetch("PORT") { 3000 } 14 | 15 | # Specifies the `environment` that Puma will run in. 16 | # 17 | environment ENV.fetch("RAILS_ENV") { "development" } 18 | 19 | # Specifies the `pidfile` that Puma will use. 20 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 21 | 22 | # Specifies the number of `workers` to boot in clustered mode. 23 | # Workers are forked web server processes. If using threads and workers together 24 | # the concurrency of the application would be max `threads` * `workers`. 25 | # Workers do not work on JRuby or Windows (both of which do not support 26 | # processes). 27 | # 28 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 29 | 30 | # Use the `preload_app!` method when specifying a `workers` number. 31 | # This directive tells Puma to first boot the application and load code 32 | # before forking the application. This takes advantage of Copy On Write 33 | # process behavior so workers use less memory. 34 | # 35 | # preload_app! 36 | 37 | # Allow puma to be restarted by `rails restart` command. 38 | plugin :tmp_restart 39 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # API routes 3 | namespace :api do 4 | namespace :v1 do 5 | # Articles 6 | get '/articles', to: 'articles#index' 7 | post '/articles', to: 'articles#create' 8 | delete '/articles/:id', to: 'articles#destroy' 9 | 10 | # Users 11 | get '/users/:account_number', to: 'users#show' 12 | post '/users', to: 'users#create' 13 | delete '/users', to: 'users#destroy' 14 | end 15 | end 16 | 17 | resources :articles 18 | 19 | get '/save', to: 'articles#save_bookmarklet', as: :save_bookmarklet 20 | get '/save-mobile', to: 'articles#save_mobile', as: :save_mobile 21 | 22 | get '/', to: 'pages#index', as: :index 23 | get 'privacy', to: 'pages#privacy' 24 | get 'transparency', to: 'pages#transparency' 25 | 26 | get 'login', to: 'sessions#new' 27 | post 'login', to: 'sessions#create' 28 | post 'logout', to: 'sessions#destroy' 29 | 30 | post 'signup', to: 'users#create' 31 | 32 | get 'account', to: 'users#show' 33 | delete 'account', to: 'users#destroy' 34 | 35 | post 'create_subscription', to: 'billing#create_subscription' 36 | post 'retry_invoice', to: 'billing#retry_invoice' 37 | post 'stripe/webhooks', to: 'billing#webhooks' 38 | post 'subscription_callback', to: 'billing#subscription_callback' 39 | post 'cancel_subscription', to: 'billing#cancel_subscription' 40 | end 41 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt" 6 | ) 7 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /config/webpack/development.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /config/webpack/environment.js: -------------------------------------------------------------------------------- 1 | const { environment } = require('@rails/webpacker') 2 | 3 | module.exports = environment 4 | -------------------------------------------------------------------------------- /config/webpack/production.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /config/webpack/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /config/webpacker.yml: -------------------------------------------------------------------------------- 1 | # Note: You must restart bin/webpack-dev-server for changes to take effect 2 | 3 | default: &default 4 | source_path: app/javascript 5 | source_entry_path: packs 6 | public_root_path: public 7 | public_output_path: packs 8 | cache_path: tmp/cache/webpacker 9 | check_yarn_integrity: false 10 | webpack_compile_output: true 11 | 12 | # Additional paths webpack should lookup modules 13 | # ['app/assets', 'engine/foo/app/assets'] 14 | resolved_paths: [] 15 | 16 | # Reload manifest.json on all requests so we reload latest compiled packs 17 | cache_manifest: false 18 | 19 | # Extract and emit a css file 20 | extract_css: false 21 | 22 | static_assets_extensions: 23 | - .jpg 24 | - .jpeg 25 | - .png 26 | - .gif 27 | - .tiff 28 | - .ico 29 | - .svg 30 | - .eot 31 | - .otf 32 | - .ttf 33 | - .woff 34 | - .woff2 35 | 36 | extensions: 37 | - .mjs 38 | - .js 39 | - .sass 40 | - .scss 41 | - .css 42 | - .module.sass 43 | - .module.scss 44 | - .module.css 45 | - .png 46 | - .svg 47 | - .gif 48 | - .jpeg 49 | - .jpg 50 | 51 | development: 52 | <<: *default 53 | compile: true 54 | 55 | # Verifies that correct packages and versions are installed by inspecting package.json, yarn.lock, and node_modules 56 | check_yarn_integrity: true 57 | 58 | # Reference: https://webpack.js.org/configuration/dev-server/ 59 | dev_server: 60 | https: false 61 | host: localhost 62 | port: 3035 63 | public: localhost:3035 64 | hmr: false 65 | # Inline should be set to true if using HMR 66 | inline: true 67 | overlay: true 68 | compress: true 69 | disable_host_check: true 70 | use_local_ip: false 71 | quiet: false 72 | pretty: false 73 | headers: 74 | 'Access-Control-Allow-Origin': '*' 75 | watch_options: 76 | ignored: '**/node_modules/**' 77 | 78 | 79 | test: 80 | <<: *default 81 | compile: true 82 | 83 | # Compile test packs to a separate directory 84 | public_output_path: packs-test 85 | 86 | production: 87 | <<: *default 88 | 89 | # Production depends on precompilation of packs prior to booting for performance. 90 | compile: false 91 | 92 | # Extract and emit a css file 93 | extract_css: true 94 | 95 | # Cache manifest.json for performance 96 | cache_manifest: true 97 | -------------------------------------------------------------------------------- /db/migrate/20200314232813_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table :users do |t| 4 | t.string :account_number 5 | t.timestamps 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20200314233044_create_articles.rb: -------------------------------------------------------------------------------- 1 | class CreateArticles < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table :articles do |t| 4 | t.text :url 5 | t.timestamps 6 | end 7 | 8 | add_reference :articles, :user, foreign_key: true 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20200315203723_add_title_to_articles.rb: -------------------------------------------------------------------------------- 1 | class AddTitleToArticles < ActiveRecord::Migration[6.0] 2 | def change 3 | add_column :articles, :title, :text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200503164250_add_api_auth_token_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddApiAuthTokenToUser < ActiveRecord::Migration[6.0] 2 | def change 3 | add_column :users, :api_auth_token, :string 4 | add_column :users, :api_auth_token_expires_at, :datetime 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20200626161253_add_early_adopter_flag_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddEarlyAdopterFlagToUser < ActiveRecord::Migration[6.0] 2 | def change 3 | change_table :users do |t| 4 | t.boolean :is_early_adopter, null: false, default: false 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200628011938_add_stripe_customer_id_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddStripeCustomerIdToUser < ActiveRecord::Migration[6.0] 2 | def change 3 | change_table :users do |t| 4 | t.string :stripe_customer_id, limit: 50, null: true 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200628131227_add_stripe_subscription_id_to_user.rb: -------------------------------------------------------------------------------- 1 | class AddStripeSubscriptionIdToUser < ActiveRecord::Migration[6.0] 2 | def change 3 | change_table :users do |t| 4 | t.string :stripe_subscription_id, null: true 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /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 | # This file is the source Rails uses to define your schema when running `rails 6 | # db:schema:load`. When creating a new database, `rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2020_06_28_131227) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "plpgsql" 17 | 18 | create_table "articles", force: :cascade do |t| 19 | t.text "url" 20 | t.datetime "created_at", precision: 6, null: false 21 | t.datetime "updated_at", precision: 6, null: false 22 | t.bigint "user_id" 23 | t.text "title" 24 | t.index ["user_id"], name: "index_articles_on_user_id" 25 | end 26 | 27 | create_table "users", force: :cascade do |t| 28 | t.string "account_number" 29 | t.datetime "created_at", precision: 6, null: false 30 | t.datetime "updated_at", precision: 6, null: false 31 | t.string "api_auth_token" 32 | t.datetime "api_auth_token_expires_at" 33 | t.boolean "is_early_adopter", default: false, null: false 34 | t.string "stripe_customer_id", limit: 50 35 | t.string "stripe_subscription_id" 36 | end 37 | 38 | add_foreign_key "articles", "users" 39 | end 40 | -------------------------------------------------------------------------------- /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 rails db:seed command (or created alongside the database with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) 7 | # Character.create(name: 'Luke', movie: movies.first) 8 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/lib/assets/.keep -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/lib/tasks/.keep -------------------------------------------------------------------------------- /lib/tasks/delete_old_articles.rake: -------------------------------------------------------------------------------- 1 | desc "Delete articles older than 7 days" 2 | task delete_old_articles: :environment do 3 | old_articles = Article.where('created_at < ?', 7.days.ago) 4 | articles_to_delete = old_articles.size 5 | old_articles.destroy_all 6 | puts "Destroyed #{articles_to_delete} article#{articles_to_delete == 1 ? '' : 's'}." 7 | end 8 | -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/log/.keep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freshreader", 3 | "private": true, 4 | "dependencies": { 5 | "@rails/actioncable": "^6.0.0", 6 | "@rails/activestorage": "^6.0.0", 7 | "@rails/ujs": "^6.0.0", 8 | "@rails/webpacker": "5.4.3", 9 | "dns-packet": "^1.3.2", 10 | "glob-parent": "5.1.2", 11 | "is-svg": "4.3.0", 12 | "nth-check": "2.0.1", 13 | "postcss": "^8.2.10", 14 | "serialize-javascript": "3.1.0", 15 | "set-value": "4.1.0", 16 | "ssri": "^7.1.1", 17 | "tar": "4.4.18", 18 | "trim-newlines": "3.0.1", 19 | "turbolinks": "^5.2.0", 20 | "ws": "^7.4.6", 21 | "y18n": "4.0.1" 22 | }, 23 | "version": "0.1.0", 24 | "devDependencies": { 25 | "webpack-dev-server": "^4.7.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('postcss-flexbugs-fixes'), 5 | require('postcss-preset-env')({ 6 | autoprefixer: { 7 | flexbox: 'no-2009' 8 | }, 9 | stage: 3 10 | }) 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /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/apks/freshreader-v1.0.0.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/public/apks/freshreader-v1.0.0.apk -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /storage/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/storage/.keep -------------------------------------------------------------------------------- /test/application_system_test_case.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationSystemTestCase < ActionDispatch::SystemTestCase 4 | driven_by :selenium, using: :chrome, screen_size: [1400, 1400] 5 | end 6 | -------------------------------------------------------------------------------- /test/channels/application_cable/connection_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase 4 | # test "connects with cookies" do 5 | # cookies.signed[:user_id] = 42 6 | # 7 | # connect 8 | # 9 | # assert_equal connection.user_id, "42" 10 | # end 11 | end 12 | -------------------------------------------------------------------------------- /test/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/test/controllers/.keep -------------------------------------------------------------------------------- /test/controllers/api/v1/articles_controller_test.rb: -------------------------------------------------------------------------------- 1 | class Api::V1::ArticlesControllerTest < ActionDispatch::IntegrationTest 2 | setup do 3 | @user = create_user 4 | @credentials = authenticate(@user.api_auth_token, @user.account_number) 5 | 6 | @user2 = create_user 7 | @credentials2 = authenticate(@user2.api_auth_token, @user2.account_number) 8 | end 9 | 10 | teardown do 11 | User.destroy_all 12 | end 13 | 14 | def test_get_articles_without_authentication_returns_401 15 | get "/api/v1/articles" 16 | assert_response(:unauthorized) 17 | assert_equal('HTTP Token: Access denied.', response.body.strip) 18 | end 19 | 20 | def test_get_articles_when_authenticated 21 | get "/api/v1/articles", headers: { "Authorization" => @credentials } 22 | assert_response(:success) 23 | assert_equal([], JSON.parse(response.body)) 24 | end 25 | 26 | def test_insert_article_then_get_all_articles_when_authenticated 27 | get "/api/v1/articles", headers: { "Authorization" => @credentials } 28 | assert_response(:success) 29 | assert_equal([], JSON.parse(response.body)) 30 | 31 | post( 32 | '/api/v1/articles', 33 | params: { url: 'https://example.com/' }, 34 | headers: { "Authorization" => @credentials }, 35 | ) 36 | assert_response(:created) 37 | 38 | get "/api/v1/articles", headers: { "Authorization" => @credentials } 39 | assert_response(:success) 40 | 41 | articles = JSON.parse(response.body) 42 | assert_equal(1, articles.size) 43 | 44 | saved_article = articles.first 45 | assert_equal(['id', 'title', 'url', 'created_at'], saved_article.keys) 46 | assert_equal('Example Domain', saved_article['title']) 47 | assert_equal('https://example.com/', saved_article['url']) 48 | end 49 | 50 | def test_save_invalid_url_returns_422 51 | assert_no_difference('Article.count') do 52 | post('/api/v1/articles', params: { article: { url: 'http' } }, headers: { "Authorization" => @credentials }) 53 | assert_response(:unprocessable_entity) 54 | end 55 | end 56 | 57 | def test_save_empty_url_returns_422 58 | assert_no_difference('Article.count') do 59 | post('/api/v1/articles', params: { article: { url: ' ' } }, headers: { "Authorization" => @credentials }) 60 | assert_response(:unprocessable_entity) 61 | end 62 | end 63 | 64 | def test_save_valid_url_succeeds 65 | assert_difference('Article.count') do 66 | post( 67 | '/api/v1/articles', 68 | params: { url: 'https://maximevaillancourt.com/why-i-use-a-thinkpad-x220-in-2019'}, 69 | headers: { "Authorization" => @credentials }, 70 | ) 71 | assert_response(:created) 72 | end 73 | end 74 | 75 | def test_delete_existing_article_succeeds 76 | inserted_article = assert_difference('Article.count', 1) do 77 | post( 78 | '/api/v1/articles', 79 | params: { url: 'https://example.com/' }, 80 | headers: { "Authorization" => @credentials }, 81 | ) 82 | assert_response(:created) 83 | Article.last 84 | end 85 | 86 | assert_equal(1, Article.count) 87 | 88 | assert_difference('Article.count', -1) do 89 | delete( 90 | "/api/v1/articles/#{inserted_article.id}", 91 | headers: { "Authorization" => @credentials }, 92 | ) 93 | assert_response(:no_content) 94 | end 95 | 96 | assert_equal(0, Article.count) 97 | end 98 | 99 | def test_delete_existing_article_from_another_user_fails 100 | inserted_article = assert_difference('Article.count', 1) do 101 | post( 102 | '/api/v1/articles', 103 | params: { url: 'https://example.com/' }, 104 | headers: { "Authorization" => @credentials }, 105 | ) 106 | assert_response(:created) 107 | Article.last 108 | end 109 | 110 | assert_equal(1, Article.count) 111 | 112 | assert_difference('Article.count', 0) do 113 | delete( 114 | "/api/v1/articles/#{inserted_article.id}", 115 | headers: { "Authorization" => @credentials2 }, 116 | ) 117 | assert_response(:not_found) 118 | end 119 | 120 | assert_equal(1, Article.count) 121 | end 122 | 123 | private 124 | 125 | def authenticate(token, account_number) 126 | ActionController::HttpAuthentication::Token.encode_credentials(token, account_number: account_number) 127 | end 128 | 129 | def create_user 130 | user = User.new( 131 | account_number: User.generate_account_number, 132 | api_auth_token: User.generate_api_auth_token, 133 | api_auth_token_expires_at: Time.now + 10.minute 134 | ) 135 | user.save 136 | user 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/controllers/api/v1/users_controller_test.rb: -------------------------------------------------------------------------------- 1 | class Api::V1::UsersControllerTest < ActionDispatch::IntegrationTest 2 | setup do 3 | @user = create_user 4 | @credentials = authenticate(@user.api_auth_token, @user.account_number) 5 | end 6 | 7 | teardown do 8 | User.destroy_all 9 | end 10 | 11 | def test_get_non_existing_user 12 | get "/api/v1/users/0001234" 13 | assert_response(:not_found) 14 | assert_equal({}, JSON.parse(response.body)) 15 | end 16 | 17 | def test_get_existing_user_with_spaces_in_account_number 18 | user = create_user('1111222233334444') 19 | credentials = authenticate(user.api_auth_token, user.account_number) 20 | 21 | get "/api/v1/users/1111%202222%203333%204444" 22 | assert_response(:success) 23 | user_json = JSON.parse(response.body) 24 | assert_equal(user.account_number, user_json['account_number']) 25 | end 26 | 27 | def test_get_existing_user 28 | get "/api/v1/users/#{@user.account_number}" 29 | assert_response(:success) 30 | user_json = JSON.parse(response.body) 31 | assert_equal(@user.account_number, user_json['account_number']) 32 | end 33 | 34 | def test_create_user_succeeds 35 | assert_difference('User.count', 1) do 36 | post "/api/v1/users/" 37 | assert_response(:created) 38 | user_json = JSON.parse(response.body) 39 | assert_equal(['id', 'account_number', 'api_auth_token'], user_json.keys) 40 | assert_not_equal('', user_json['id']) 41 | assert_not_equal('', user_json['account_number']) 42 | assert_not_equal('', user_json['api_auth_token']) 43 | end 44 | end 45 | 46 | def test_delete_account_succeeds 47 | other_user = User.new( 48 | account_number: '7777666655554444', 49 | api_auth_token: '11111', 50 | api_auth_token_expires_at: Time.now + 10.minute 51 | ) 52 | other_user.save 53 | 54 | assert_equal(2, User.count) 55 | 56 | delete( 57 | "/api/v1/users", 58 | headers: { "Authorization" => @credentials }, 59 | ) 60 | assert_response(:no_content) 61 | 62 | assert_equal(1, User.count) 63 | 64 | assert_includes(User.all, other_user) 65 | refute_includes(User.all, @user) 66 | end 67 | 68 | private 69 | 70 | def authenticate(token, account_number) 71 | ActionController::HttpAuthentication::Token.encode_credentials(token, account_number: account_number) 72 | end 73 | 74 | def create_user(account_number = '1234123412341234') 75 | user = User.new( 76 | account_number: account_number, 77 | api_auth_token: '12345', 78 | api_auth_token_expires_at: Time.now + 10.minute 79 | ) 80 | user.save 81 | user 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/controllers/articles_controller_test.rb: -------------------------------------------------------------------------------- 1 | class ArticlesControllerTest < ActionDispatch::IntegrationTest 2 | def test_get_articles_when_logged_out_redirects_to_login 3 | get(articles_url) 4 | assert_redirected_to(:login) 5 | end 6 | 7 | def test_get_articles_when_logged_in 8 | user = create_user(account_number: '1234123412341234') 9 | post(login_url, params: { account_number: user.account_number }) 10 | 11 | get(articles_url) 12 | assert_response(:success) 13 | assert_includes(response.body, 'Save new URL') 14 | end 15 | 16 | def test_save_invalid_url_redirects_to_articles 17 | user = create_user(account_number: '1234123412341234') 18 | post(login_url, params: { account_number: user.account_number }) 19 | 20 | assert_no_difference('Article.count') do 21 | post(articles_url, params: { article: { url: 'http' } }) 22 | assert_redirected_to(:articles) 23 | end 24 | end 25 | 26 | def test_save_empty_url_redirects_to_articles 27 | user = create_user(account_number: '1234123412341234') 28 | post(login_url, params: { account_number: user.account_number }) 29 | 30 | assert_no_difference('Article.count') do 31 | post(articles_url, params: { article: { url: ' ' } }) 32 | assert_redirected_to(:articles) 33 | end 34 | end 35 | 36 | def test_save_valid_url_succeeds 37 | user = create_user(account_number: '1234123412341234') 38 | post(login_url, params: { account_number: user.account_number }) 39 | 40 | assert_difference('Article.count') do 41 | post(articles_url, params: { article: { url: 'https://maximevaillancourt.com/why-i-use-a-thinkpad-x220-in-2019' } }) 42 | assert_redirected_to(:articles) 43 | end 44 | end 45 | 46 | def test_save_more_than_5_articles_on_free_plan_fails 47 | user = create_user(account_number: '6789678967896789', is_subscribed: false) 48 | User.find_by(account_number: '6789678967896789').articles.destroy_all 49 | 50 | post(login_url, params: { account_number: user.account_number }) 51 | 52 | assert_difference('Article.count', +Article::ARTICLES_LIMIT_ON_FREE_PLAN) do 53 | Article::ARTICLES_LIMIT_ON_FREE_PLAN.times do 54 | post(articles_url, params: { article: { url: 'https://maximevaillancourt.com/blog/why-i-use-a-thinkpad-x220-in-2019' } }) 55 | assert_redirected_to(:articles) 56 | end 57 | end 58 | 59 | assert_no_difference('Article.count') do 60 | post(articles_url, params: { article: { url: 'https://maximevaillancourt.com/blog/why-i-use-a-thinkpad-x220-in-2019' } }) 61 | assert_includes flash[:warning], "You cannot save more than #{Article::ARTICLES_LIMIT_ON_FREE_PLAN} items" 62 | assert_redirected_to(:articles) 63 | end 64 | end 65 | 66 | def test_save_valid_url_from_bookmarklet_succeeds 67 | user = create_user(account_number: '1234123412341234') 68 | post(login_url, params: { account_number: user.account_number }) 69 | 70 | assert_difference('Article.count') do 71 | get(save_bookmarklet_url, params: { url: 'https://maximevaillancourt.com/why-i-use-a-thinkpad-x220-in-2019' }) 72 | end 73 | end 74 | 75 | def test_save_valid_url_from_bookmarklet_without_logged_in_user_fails 76 | user = create_user(account_number: '1234123412341234') 77 | 78 | assert_no_difference('Article.count') do 79 | get(save_bookmarklet_url, params: { url: 'https://maximevaillancourt.com/why-i-use-a-thinkpad-x220-in-2019' }) 80 | assert_equal 302, response.status 81 | end 82 | end 83 | 84 | def test_save_valid_url_from_mobile_succeeds 85 | user = create_user(account_number: '1234123412341234') 86 | 87 | assert_difference('Article.count') do 88 | get(save_mobile_url, params: { url: 'https://maximevaillancourt.com/why-i-use-a-thinkpad-x220-in-2019', account_number: '1234123412341234' }) 89 | assert_equal 200, response.status 90 | end 91 | end 92 | 93 | def test_save_valid_url_from_mobile_with_invalid_user_fails 94 | user = create_user(account_number: '1234123412341234') 95 | 96 | assert_no_difference('Article.count') do 97 | get(save_mobile_url, params: { url: 'https://maximevaillancourt.com/why-i-use-a-thinkpad-x220-in-2019', account_number: '5555' }) 98 | assert_equal 401, response.status 99 | end 100 | end 101 | 102 | def test_save_valid_url_from_mobile_with_invalid_url_fails 103 | user = create_user(account_number: '1234123412341234') 104 | 105 | assert_no_difference('Article.count') do 106 | get(save_mobile_url, params: { url: 'not-a-valid-url', account_number: '1234123412341234' }) 107 | assert_equal 500, response.status 108 | end 109 | end 110 | 111 | def test_delete_existing_article_succeeds 112 | user = create_user(account_number: '1234123412341234') 113 | post(login_url, params: { account_number: user.account_number }) 114 | 115 | inserted_article = assert_difference('Article.count', 1) do 116 | post(articles_url, params: { article: { url: 'https://example.com/' } }) 117 | assert_redirected_to(:articles) 118 | Article.last 119 | end 120 | 121 | assert_difference('Article.count', -1) do 122 | delete(article_url(inserted_article)) 123 | assert_redirected_to(:articles) 124 | end 125 | end 126 | 127 | def test_delete_existing_article_from_another_user_fails 128 | user1 = create_user(account_number: '1234123412341234') 129 | 130 | post(login_url, params: { account_number: user1.account_number }) 131 | 132 | inserted_article = assert_difference('Article.count', 1) do 133 | post(articles_url, params: { article: { url: 'https://example.com/' } }) 134 | assert_redirected_to(:articles) 135 | Article.last 136 | end 137 | 138 | user2 = create_user(account_number: '2234123412341234') 139 | 140 | post(login_url, params: { account_number: user2.account_number }) 141 | 142 | assert_difference('Article.count', 0) do 143 | delete(article_url(inserted_article)) 144 | assert_redirected_to(:articles) 145 | end 146 | end 147 | 148 | def test_save_article_when_logged_out_redirects_to_login_then_saves 149 | user = create_user(account_number: '1234123412341234') 150 | 151 | assert_no_difference('Article.count') do 152 | get(save_bookmarklet_url, params: { url: 'https://freshreader.app' }) 153 | assert_redirected_to("http://www.example.com/login?return_to=#{CGI.escape('http://www.example.com/save?url=https://freshreader.app')}") 154 | end 155 | 156 | assert_difference('Article.count', 1) do 157 | post(login_url, params: { 158 | account_number: user.account_number, 159 | return_to: '/save?url=https://freshreader.app' 160 | }) 161 | assert_redirected_to('http://www.example.com/save?url=https://freshreader.app') 162 | get('http://www.example.com/save?url=https://freshreader.app') 163 | end 164 | end 165 | 166 | private 167 | 168 | def create_user(account_number:, is_subscribed: true, is_early_adopter: false) 169 | user = User.new( 170 | account_number: account_number, 171 | stripe_customer_id: "stripe_cus_1234", 172 | stripe_subscription_id: is_subscribed ? "stripe_sub_1234" : nil, 173 | is_early_adopter: is_early_adopter, 174 | ) 175 | user.save 176 | user 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /test/controllers/pages_controller_test.rb: -------------------------------------------------------------------------------- 1 | class PagesControllerTest < ActionDispatch::IntegrationTest 2 | def test_logged_out_index 3 | get('/') 4 | 5 | assert_includes(response.body, 'Log in') 6 | refute_includes(response.body, 'View list') 7 | assert_response(:success) 8 | end 9 | 10 | def test_logged_in_index 11 | user = User.new(account_number: '1234123412341234') 12 | user.save 13 | post(login_url, params: { account_number: user.account_number }) 14 | 15 | get('/') 16 | 17 | assert_includes(response.body, 'Reading list') 18 | refute_includes(response.body, 'Log in') 19 | assert_response(:success) 20 | end 21 | 22 | def test_privacy_page 23 | get('/privacy') 24 | assert_includes(response.body, 'A few words on privacy') 25 | assert_response(:success) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/controllers/sessions_controller_test.rb: -------------------------------------------------------------------------------- 1 | class SessionsControllerTest < ActionDispatch::IntegrationTest 2 | def test_login_succeeds_with_valid_account_number 3 | user = User.new(account_number: '1234123412341234') 4 | user.save 5 | post(login_url, params: { account_number: user.account_number }) 6 | 7 | assert_redirected_to(:articles) 8 | end 9 | 10 | def test_login_succeeds_with_whitespaced_valid_account_number 11 | user = User.new(account_number: '1234123412341234') 12 | user.save 13 | post(login_url, params: { account_number: ' 1234 123 4 1234 1234 ' }) 14 | 15 | assert_redirected_to(:articles) 16 | end 17 | 18 | def test_login_succeeds_with_valid_account_number_and_empty_return_to_param 19 | user = User.new(account_number: '1234123412341234') 20 | user.save 21 | post(login_url, params: { return_to: ' ', account_number: user.account_number }) 22 | 23 | assert_redirected_to(:articles) 24 | end 25 | 26 | def test_login_fails_with_non_existent_account_number 27 | user = User.new(account_number: '1234123412341234') 28 | user.save 29 | post(login_url, params: { account_number: 789 }) 30 | 31 | assert_redirected_to(:login) 32 | assert_equal 'This account number does not exist.', flash[:error] 33 | end 34 | 35 | def test_login_fails_with_empty_account_number 36 | user = User.new(account_number: '1234123412341234') 37 | user.save 38 | post(login_url, params: { account_number: '' }) 39 | 40 | assert_redirected_to(:login) 41 | assert_equal 'This account number does not exist.', flash[:error] 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/fixtures/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/test/fixtures/.keep -------------------------------------------------------------------------------- /test/fixtures/files/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/test/fixtures/files/.keep -------------------------------------------------------------------------------- /test/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/test/helpers/.keep -------------------------------------------------------------------------------- /test/helpers/request_helper_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | # Yes, I know, running tests that connect to the actual wild Internet 4 | # will burn me one day, but for now this is fine :this-is-fine-dog: 5 | 6 | class RequestHelperTest < ActiveSupport::TestCase 7 | test '.title_from_response_body parses non-utf8 characters' do 8 | expected = '«Nous devons agir maintenant», dit Theresa Tam' 9 | actual = RequestHelper.title_from_response_body("\xC2\xABNous devons agir maintenant\xC2\xBB, dit Theresa Tam") 10 | assert_equal expected, actual 11 | 12 | expected = 'Why I still use a ThinkPad X220 in 2019 — Maxime Vaillancourt' 13 | actual = RequestHelper.title_from_response_body("\n \n Why I still use a ThinkPad X220 in 2019 — Maxime Vaillancourt\n \n ") 14 | assert_equal expected, actual 15 | 16 | expected = 'Foobar' 17 | actual = RequestHelper.title_from_response_body("Foobar") 18 | assert_equal expected, actual 19 | end 20 | 21 | test '.url_from_param decodes URIs' do 22 | expected = "https://2pml.us17.list-manage.com/track/click?u=e5c9ff1dc004212156ddfb8ed&id=1b3cee3d59&e=17acf5a6c2" 23 | actual = RequestHelper.url_from_param('https%3A%2F%2F2pml.us17.list-manage.com%2Ftrack%2Fclick%3Fu%3De5c9ff1dc004212156ddfb8ed%26id%3D1b3cee3d59%26e%3D17acf5a6c2') 24 | assert_equal expected, actual 25 | 26 | expected = 'https://google.com' 27 | actual = RequestHelper.url_from_param('https://google.com') 28 | assert_equal expected, actual 29 | end 30 | 31 | test '.url_from_param decodes non-ASCII URIs' do 32 | expected = "https://www.nexojornal.com.br/expresso/2020/04/09/A-confissão-da-Ecovias-sobre-contratos-com-o-governo-paulista" 33 | actual = RequestHelper.url_from_param('https://www.nexojornal.com.br/expresso/2020/04/09/A-confiss%C3%A3o-da-Ecovias-sobre-contratos-com-o-governo-paulista') 34 | assert_equal expected, actual 35 | end 36 | 37 | test '.extract_title_from_page uses https if possible' do 38 | expected_title = "Vous Etes Perdu ?" 39 | expected_uri = "https://perdu.com/" 40 | 41 | actual_title, actual_uri = RequestHelper.extract_title_from_page('http://perdu.com/') 42 | 43 | assert_equal expected_title, actual_title 44 | assert_equal expected_uri, actual_uri.to_s 45 | end 46 | 47 | test '.extract_title_from_page handles relative redirections' do 48 | expected_title = "A Short History of Bi-Directional Links" 49 | expected_uri = "https://maggieappleton.com/bidirectionals" 50 | 51 | actual_title, actual_uri = RequestHelper.extract_title_from_page('https://maggieappleton.com/bidirectionals') 52 | 53 | assert_equal expected_title, actual_title 54 | assert_equal expected_uri, actual_uri.to_s 55 | end 56 | 57 | test '.extract_title_from_page handles multiple redirections' do 58 | expected_title = "The Business Value of Site Speed — And How to Analyze it Step by Step | by Ole Bossdorf | Project A Insights" 59 | expected_uri = "https://insights.project-a.com/the-business-value-of-site-speed-and-how-to-analyze-it-step-by-step" 60 | 61 | uri = 'https://calibreapp.us2.list-manage.com/track/click?u=9067434ef642e9c92aa7453d2&id=53148e0f59&e=df60486ca8' 62 | actual_title, actual_uri = RequestHelper.extract_title_from_page(uri) 63 | 64 | assert_equal expected_title, actual_title 65 | assert_includes actual_uri.to_s, expected_uri.to_s 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/test/integration/.keep -------------------------------------------------------------------------------- /test/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/test/mailers/.keep -------------------------------------------------------------------------------- /test/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/test/models/.keep -------------------------------------------------------------------------------- /test/models/article_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ArticleTest < ActiveSupport::TestCase 4 | def setup 5 | @user = User.new(account_number: User.generate_account_number) 6 | end 7 | 8 | test 'article is valid' do 9 | article = Article.new(url: 'https://freshreader.app/', user: @user) 10 | assert article.valid? 11 | end 12 | 13 | test 'article is invalid without invalid URL' do 14 | article = Article.new(url: 'not-an-url', user: @user) 15 | refute article.valid? 16 | assert_equal ["is not a valid URL"], article.errors[:url] 17 | end 18 | 19 | test 'article is invalid with nil user' do 20 | article = Article.new(url: 'https://freshreader.app/', user: nil) 21 | refute article.valid? 22 | assert_equal ['must exist'], article.errors[:user] 23 | end 24 | 25 | test 'article is invalid without user' do 26 | article = Article.new(url: 'https://freshreader.app/') 27 | refute article.valid? 28 | assert_equal ['must exist'], article.errors[:user] 29 | end 30 | 31 | test 'article is valid with title' do 32 | article = Article.new(url: 'https://freshreader.app/', title: 'Freshreader', user: @user) 33 | assert article.valid? 34 | end 35 | 36 | test 'article is valid with nil title' do 37 | article = Article.new(url: 'https://freshreader.app/', title: nil, user: @user) 38 | assert article.valid? 39 | end 40 | 41 | test 'article is valid with empty title' do 42 | article = Article.new(url: 'https://freshreader.app/', title: '', user: @user) 43 | assert article.valid? 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/models/user_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UserTest < ActiveSupport::TestCase 4 | test 'user is valid' do 5 | user = User.new(account_number: User.generate_account_number) 6 | assert user.valid? 7 | end 8 | 9 | test 'user is valid with integer account_number' do 10 | user = User.new(account_number: 123) 11 | refute user.valid? 12 | assert_equal ['is invalid'], user.errors[:account_number] 13 | end 14 | 15 | test 'user is invalid with invalid account_number type' do 16 | user = User.new(account_number: {}) 17 | refute user.valid? 18 | assert_equal ['is invalid'], user.errors[:account_number] 19 | end 20 | 21 | test 'user is invalid with nil account_number' do 22 | user = User.new(account_number: nil) 23 | refute user.valid? 24 | assert_equal ["can't be blank", 'is invalid'], user.errors[:account_number] 25 | end 26 | 27 | test 'user is invalid without account_number' do 28 | user = User.new 29 | refute user.valid? 30 | assert_equal ["can't be blank", 'is invalid'], user.errors[:account_number] 31 | end 32 | 33 | test 'by default, a new user is not considered as an early adopter' do 34 | user = User.new(account_number: User.generate_account_number) 35 | user.save 36 | 37 | refute user.early_adopter? 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/system/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/test/system/.keep -------------------------------------------------------------------------------- /test/tasks/delete_old_articles_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'rake' 3 | 4 | class DeleteOldArticlesTest < ActiveSupport::TestCase 5 | def setup 6 | Freshreader::Application.load_tasks if Rake::Task.tasks.empty? 7 | 8 | @user = User.new(account_number: '1234123412341234') 9 | @user.save 10 | end 11 | 12 | def teardown 13 | @user.destroy 14 | end 15 | 16 | test "task deletes articles created more than 7 days ago" do 17 | @article_created_8_days_ago = Article.new(url: 'https://freshreader.app/old', user: @user, created_at: 8.days.ago) 18 | assert @article_created_8_days_ago.save 19 | @article_created_6_days_ago = Article.new(url: 'https://freshreader.app/less-old', user: @user, created_at: 6.days.ago) 20 | assert @article_created_6_days_ago.save 21 | 22 | Rake::Task["delete_old_articles"].invoke 23 | 24 | remaining_articles = ::Article.all 25 | refute_includes remaining_articles, @article_created_8_days_ago 26 | assert_includes remaining_articles, @article_created_6_days_ago 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | require_relative '../config/environment' 3 | require 'rails/test_help' 4 | 5 | class ActiveSupport::TestCase 6 | # Run tests in parallel with specified workers 7 | parallelize(workers: :number_of_processors) 8 | 9 | # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. 10 | fixtures :all 11 | 12 | # Add more helper methods to be used by all tests here... 13 | end 14 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/tmp/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freshreader/core/7f96934a0e0277a82e05825a749173d33d4dfb06/vendor/.keep --------------------------------------------------------------------------------