diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 000000000..4fe1eb34e
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,126 @@
+version: 2.1
+
+orbs:
+  browser-tools: circleci/browser-tools@1.5.2
+  node: circleci/node@7.1.0
+
+jobs:
+  build:
+    parallelism: 1
+    docker:
+      - image: cimg/ruby:3.4.5-browsers
+        environment:
+          BUNDLE_JOBS: 3
+          BUNDLE_RETRY: 3
+          BUNDLE_PATH: vendor/bundle
+          PGHOST: 127.0.0.1
+          PGUSER: postgres
+          RACK_ENV: test
+          RAILS_ENV: test
+      - image: cimg/postgres:10.18
+        environment:
+          POSTGRES_USER: postgres
+          POSTGRES_DB: app_test
+          POSTGRES_PASSWORD:
+
+    steps:
+      - checkout
+      - browser-tools/install-browser-tools:
+          install-chrome: false
+          install-chromedriver: false
+
+      # - node/install:
+      #     node-version: 16.13.1
+      #     install-yarn: true
+
+      - run:
+          name: Which bundler?
+          command: bundle -v
+
+      # https://circleci.com/docs/2.0/caching/
+      - restore_cache:
+          keys:
+            - bundle-v1-{{ checksum "Gemfile.lock" }}
+            - bundle-v1-
+
+      - run: # Install Ruby dependencies
+          name: Bundle Install
+          command: |
+            bundle config set --local frozen 'true'
+            bundle install
+            bundle clean
+
+      - save_cache:
+          key: bundle-v1-{{ checksum "Gemfile.lock" }}
+          paths:
+            - vendor/bundle
+
+      # - restore_cache:
+      #     keys:
+      #       - yarn-{{ checksum "yarn.lock" }}
+      #       - yarn-
+
+      # - run:
+      #     name: Yarn Install
+      #     command: yarn install --cache-folder ~/.cache/yarn
+
+      # - save_cache:
+      #     key: yarn-{{ checksum "yarn.lock" }}
+      #     paths:
+      #       - ~/.cache/yarn
+
+      - run:
+          name: Wait for DB
+          command: dockerize -wait tcp://localhost:5432 -timeout 1m
+
+      - run:
+          name: Database setup
+          command: bundle exec rake db:create db:schema:load --trace
+
+      # - run:
+      #     name: Brakeman
+      #     command: bundle exec brakeman
+
+      # - run:
+      #     name: Stylelint
+      #     command: yarn stylelint
+
+      - run:
+          name: Rubocop
+          command: bundle exec rubocop
+
+      - run:
+          name: Run rspec in parallel
+          command: |
+            bundle exec rspec --exclude-pattern "spec/system/*_spec.rb"
+            # bundle exec rspec --profile 10 \
+            #                   --format RspecJunitFormatter \
+            #                   --out test_results/rspec.xml \
+            #                   --format progress \
+            #                   $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
+
+      - store_test_results: # https://circleci.com/docs/2.0/collect-test-data/
+          path: test_results
+
+      - run:
+          name: Run system tests
+          command: |
+            COVERAGE=false bundle exec rspec spec/system/
+
+workflows:
+  build:
+    jobs:
+      - build
+
+  # https://circleci.com/docs/2.0/workflows/#nightly-example
+  # https://circleci.com/docs/2.0/configuration-reference/#filters-1
+  repeat:
+    jobs:
+      - build
+    triggers:
+      - schedule:
+          cron: "0,20,40 * * * *"
+          filters:
+            branches:
+              only:
+                - /.*ci-repeat.*/
diff --git a/.dockerignore b/.dockerignore
index 745841df6..4a2324570 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,4 +1,3 @@
 .git/*
 .gitignore
 log/*
-tmp/*
diff --git a/.env.development b/.env.development
new file mode 100644
index 000000000..7ae8c1e97
--- /dev/null
+++ b/.env.development
@@ -0,0 +1,7 @@
+# you can use `rails secret` to generate this for production
+SECRET_KEY_BASE=dev_secret
+
+# you can use `rails db:encryption:init` to generate these for production
+ENCRYPTION_PRIMARY_KEY=dev_primary_key
+ENCRYPTION_DETERMINISTIC_KEY=dev_deterministic_key
+ENCRYPTION_KEY_DERIVATION_SALT=dev_derivation_salt
diff --git a/.env.test b/.env.test
new file mode 100644
index 000000000..b6f983942
--- /dev/null
+++ b/.env.test
@@ -0,0 +1,7 @@
+# you can use `rails secret` to generate this for production
+SECRET_KEY_BASE=test_secret
+
+# you can use `rails db:encryption:init` to generate these for production
+ENCRYPTION_PRIMARY_KEY=test_primary_key
+ENCRYPTION_DETERMINISTIC_KEY=test_deterministic_key
+ENCRYPTION_KEY_DERIVATION_SALT=test_derivation_salt
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 000000000..31eeee0b6
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,7 @@
+# See https://git-scm.com/docs/gitattributes for more about git attribute files.
+
+# Mark the database schema as having been generated.
+db/schema.rb linguist-generated
+
+# Mark any vendored files as having been vendored.
+vendor/* linguist-vendored
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 000000000..d916cd6ae
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1 @@
+github: mockdeep
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 000000000..937450997
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,57 @@
+name: Build Docker image and push to Dockerhub
+
+on:
+  push:
+  pull_request:
+  workflow_dispatch:
+
+env:
+  IMAGE_NAME: stringerrss/stringer
+
+jobs:
+  build_docker:
+    runs-on: ubuntu-22.04
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Set up QEMU
+        uses: docker/setup-qemu-action@v3
+
+      - name: Set up Docker Buildx
+        id: buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Extract Docker sha tag
+        id: get-tag-sha
+        uses: docker/metadata-action@v4
+        with:
+          images: ${{ env.IMAGE_NAME }}
+          tags: type=sha
+
+      - name: Extract Docker latest tag
+        id: get-tag-latest
+        uses: docker/metadata-action@v5
+        with:
+          images: ${{ env.IMAGE_NAME }}
+          tags: type=raw, value=latest
+
+      - name: Log in to Docker Hub
+        if: ${{ github.ref_name == 'main' }}
+        id: login
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Build and Push
+        id: build-and-push
+        uses: docker/build-push-action@v6
+        with:
+          context: .
+          file: ./Dockerfile
+          platforms: linux/amd64,linux/arm64
+          push: ${{ github.ref_name == 'main' }}
+          tags: |
+            ${{ steps.get-tag-latest.outputs.tags }}
+            ${{ steps.get-tag-sha.outputs.tags }}
diff --git a/.gitignore b/.gitignore
index e35bb576d..541965950 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,25 +1,31 @@
-*.gem
-*.rbc
-.bundle
-.config
-.ruby-gemset
-coverage
-InstalledFiles
-lib/bundler/man
-pkg
-rdoc
-spec/reports
-test/tmp
-test/version_tmp
-tmp
-log
+# See https://help.github.com/articles/ignoring-files for more about ignoring files.
+#
+# If you find yourself ignoring temporary files generated by your text editor
+# or operating system, you probably want to add a global ignore instead:
+#   git config --global core.excludesfile '~/.gitignore_global'
 
-# YARD artifacts
-.yardoc
-_yardoc
-doc/
-bin/
+# Ignore bundler config.
+/.bundle
 
-db/*.sqlite
-.DS_Store
-.localeapp
+# Ignore all logfiles and tempfiles.
+/log/*
+/tmp/*
+!/log/.keep
+!/tmp/.keep
+
+# Ignore pidfiles, but keep the directory.
+/tmp/pids/*
+!/tmp/pids/
+!/tmp/pids/.keep
+
+/coverage
+/public/assets
+
+# Ignore master key for decrypting credentials and more.
+/config/master.key
+
+spec/examples.txt
+
+# Ignore local .env files
+*.local
+.env
diff --git a/.rspec b/.rspec
index cf6add7ea..a35c44f4b 100644
--- a/.rspec
+++ b/.rspec
@@ -1 +1 @@
---colour
\ No newline at end of file
+--require rails_helper
diff --git a/.rubocop.yml b/.rubocop.yml
index a7403fc37..816628fa0 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -1,31 +1,77 @@
 inherit_from: .rubocop_todo.yml
+inherit_mode: { merge: [Exclude] }
+
+plugins:
+  - rubocop-capybara
+  - rubocop-factory_bot
+  - rubocop-rails
+  - rubocop-rake
+  - rubocop-rspec
+  - rubocop-rspec_rails
 
 AllCops:
+  DisplayCopNames: true
+  EnabledByDefault: true
   Exclude:
     - 'db/schema.rb'
     - 'vendor/**/*'
-  TargetRubyVersion: 2.3
-
-Metrics/LineLength:
-  Max: 120
-
-Metrics/MethodLength:
-  Max: 15
 
-Style/Documentation:
-  Enabled: false
-
-Style/DoubleNegation:
-  Enabled: false
-
-Style/IfUnlessModifier:
-  MaxLineLength: 120
+Capybara/ClickLinkOrButtonStyle: { EnforcedStyle: link_or_button }
+Layout/LineLength: { Max: 80, Exclude: [db/migrate/*.rb] }
+Layout/RedundantLineBreak: { InspectBlocks: true }
+Metrics/AbcSize: { Exclude: [db/migrate/*.rb], CountRepeatedAttributes: false }
+Metrics/BlockLength:
+  Exclude: [config/**/*.rb, db/migrate/*.rb, spec/**/*.rb, db/seeds/**/*.rb]
+Metrics/MethodLength: { Exclude: [db/migrate/*.rb] }
+Rails/SkipsModelValidations: { AllowedMethods: [update_all] }
+RSpec/DescribeClass: { Exclude: [spec/system/**/*] }
+RSpec/MessageExpectation:
+  EnforcedStyle: expect
+  Exclude: [spec/support/matchers/**/*.rb]
+RSpec/MessageSpies: { EnforcedStyle: receive }
+RSpec/MultipleMemoizedHelpers: { AllowSubject: false, Max: 0 }
+Style/ClassAndModuleChildren: { EnforcedStyle: compact }
+Style/MethodCallWithArgsParentheses:
+  AllowedMethods:
+    - and
+    - to
+    - not_to
+    - describe
+    - require
+    - task
+  Exclude:
+    - db/**/*.rb
+Style/StringLiterals: { EnforcedStyle: double_quotes }
+Style/SymbolArray: { EnforcedStyle: brackets }
+Style/WordArray: { EnforcedStyle: brackets }
 
-Style/NumericLiterals:
-  Enabled: false
+# want to enable these, but they don't work right when using `.rubocop_todo.yml`
+Style/DocumentationMethod: { Enabled: false }
+Style/Documentation: { Enabled: false }
 
-Style/StringLiterals:
-  EnforcedStyle: double_quotes
+################################################################################
+#
+# Rules we don't want to enable
+#
+################################################################################
 
-Style/WhileUntilModifier:
-  MaxLineLength: 120
+Bundler/GemComment: { Enabled: false }
+Bundler/GemVersion: { Enabled: false }
+Capybara/AmbiguousClick: { Enabled: false }
+Layout/SingleLineBlockChain: { Enabled: false }
+Lint/ConstantResolution: { Enabled: false }
+Rails/BulkChangeTable: { Enabled: false }
+Rails/RedundantPresenceValidationOnBelongsTo: { Enabled: false }
+RSpec/AlignLeftLetBrace: { Enabled: false }
+RSpec/AlignRightLetBrace: { Enabled: false }
+Rails/HasManyOrHasOneDependent: { Enabled: false }
+RSpec/IndexedLet: { Enabled: false }
+RSpec/StubbedMock: { Enabled: false }
+Rails/SchemaComment: { Enabled: false }
+Style/ConstantVisibility: { Enabled: false }
+Style/Copyright: { Enabled: false }
+Style/InlineComment: { Enabled: false }
+Style/MissingElse: { Enabled: false }
+Style/RequireOrder: { Enabled: false }
+Style/SafeNavigation: { Enabled: false }
+Style/StringHashKeys: { Enabled: false }
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 0e4ee7524..cd91918aa 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -1,14 +1,275 @@
 # This configuration was generated by
-# `rubocop --auto-gen-config`
-# on 2016-03-22 20:52:58 +0100 using RuboCop version 0.38.0.
+# `rubocop --auto-gen-config --auto-gen-only-exclude --exclude-limit 400`
+# on 2025-06-09 17:04:01 UTC using RuboCop version 1.76.0.
 # The point is for the user to remove these configuration records
 # one by one as the offenses are removed from the code base.
 # Note that changes in the inspected code, or installation of new
 # versions of RuboCop, may require this file to be generated again.
 
-# Offense count: 163
-# Cop supports --auto-correct.
-# Configuration parameters: EnforcedStyle, SupportedStyles.
-# SupportedStyles: when_needed, always
-Style/FrozenStringLiteralComment:
-  Enabled: false
+# Offense count: 3
+Capybara/NegationMatcherAfterVisit:
+  Exclude:
+    - 'spec/system/account_setup_spec.rb'
+    - 'spec/system/stories_index_spec.rb'
+
+# Offense count: 6
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle.
+# SupportedHashRocketStyles: key, separator, table
+# SupportedColonStyles: key, separator, table
+# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit
+Layout/HashAlignment:
+  Exclude:
+    - 'db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb'
+    - 'db/migrate/20240314031221_create_good_job_labels_index.rb'
+    - 'db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb'
+
+# Offense count: 19
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: AllowMultilineFinalElement.
+Layout/MultilineMethodArgumentLineBreaks:
+  Exclude:
+    - 'db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb'
+    - 'db/migrate/20240314031221_create_good_job_labels_index.rb'
+    - 'db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb'
+
+# Offense count: 9
+# This cop supports unsafe autocorrection (--autocorrect-all).
+# Configuration parameters: AllowedMethods, AllowedPatterns, IgnoredClasses.
+# AllowedMethods: ago, from_now, second, seconds, minute, minutes, hour, hours, day, days, week, weeks, fortnight, fortnights, in_milliseconds
+# IgnoredClasses: Time, DateTime
+Lint/NumberConversion:
+  Exclude:
+    - 'Rakefile'
+    - 'app/commands/fever_api/authentication.rb'
+    - 'app/commands/story/mark_group_as_read.rb'
+    - 'app/models/feed.rb'
+    - 'app/models/story.rb'
+    - 'app/repositories/story_repository.rb'
+    - 'spec/models/feed_spec.rb'
+    - 'spec/models/story_spec.rb'
+
+# Offense count: 1
+# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
+Metrics/CyclomaticComplexity:
+  Exclude:
+    - 'db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb'
+
+# Offense count: 5
+# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
+Metrics/MethodLength:
+  Exclude:
+    - 'app/models/story.rb'
+    - 'app/repositories/story_repository.rb'
+    - 'app/utils/opml_parser.rb'
+    - 'app/utils/sample_story.rb'
+
+# Offense count: 1
+# Configuration parameters: AllowedMethods, AllowedPatterns, Max.
+Metrics/PerceivedComplexity:
+  Exclude:
+    - 'db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb'
+
+# Offense count: 3
+# Configuration parameters: Mode, AllowedMethods.
+# AllowedMethods: call
+Naming/PredicateMethod:
+  Exclude:
+    - 'app/utils/sample_story.rb'
+
+# Offense count: 2
+# Configuration parameters: NamePrefix, ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros, UseSorbetSigs.
+# NamePrefix: is_, has_, have_, does_
+# ForbiddenPrefixes: is_, has_, have_, does_
+# AllowedMethods: is_a?
+# MethodDefinitionMacros: define_method, define_singleton_method
+Naming/PredicatePrefix:
+  Exclude:
+    - 'app/utils/sample_story.rb'
+
+# Offense count: 4
+RSpec/Be:
+  Exclude:
+    - 'spec/commands/feed/import_from_opml_spec.rb'
+
+# Offense count: 13
+# Configuration parameters: Prefixes, AllowedPatterns.
+# Prefixes: when, with, without
+RSpec/ContextWording:
+  Exclude:
+    - 'spec/commands/feed/create_spec.rb'
+    - 'spec/commands/feed/fetch_one_spec.rb'
+    - 'spec/commands/feed/find_new_stories_spec.rb'
+    - 'spec/commands/feed/import_from_opml_spec.rb'
+    - 'spec/integration/feed_importing_spec.rb'
+
+# Offense count: 1
+# Configuration parameters: IgnoredMetadata.
+RSpec/DescribeClass:
+  Exclude:
+    - 'spec/integration/feed_importing_spec.rb'
+
+# Offense count: 34
+# Configuration parameters: Max, CountAsOne.
+RSpec/ExampleLength:
+  Exclude:
+    - 'spec/commands/feed/create_spec.rb'
+    - 'spec/commands/feed/export_to_opml_spec.rb'
+    - 'spec/commands/fever_api/read_favicons_spec.rb'
+    - 'spec/commands/fever_api/read_feeds_groups_spec.rb'
+    - 'spec/commands/fever_api/read_items_spec.rb'
+    - 'spec/helpers/url_helpers_spec.rb'
+    - 'spec/integration/feed_importing_spec.rb'
+    - 'spec/models/feed_spec.rb'
+    - 'spec/models/migration_status_spec.rb'
+    - 'spec/models/story_spec.rb'
+    - 'spec/repositories/group_repository_spec.rb'
+    - 'spec/repositories/story_repository_spec.rb'
+    - 'spec/system/good_job_spec.rb'
+    - 'spec/tasks/remove_old_stories_spec.rb'
+    - 'spec/utils/feed_discovery_spec.rb'
+    - 'spec/utils/opml_parser_spec.rb'
+
+# Offense count: 17
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: allow, expect
+RSpec/MessageExpectation:
+  Exclude:
+    - 'spec/commands/feed/fetch_one_spec.rb'
+    - 'spec/models/migration_status_spec.rb'
+    - 'spec/repositories/story_repository_spec.rb'
+    - 'spec/tasks/remove_old_stories_spec.rb'
+    - 'spec/utils/i18n_support_spec.rb'
+
+# Offense count: 26
+# Configuration parameters: Max.
+RSpec/MultipleExpectations:
+  Exclude:
+    - 'spec/commands/feed/create_spec.rb'
+    - 'spec/commands/feed/export_to_opml_spec.rb'
+    - 'spec/commands/feed/import_from_opml_spec.rb'
+    - 'spec/repositories/feed_repository_spec.rb'
+    - 'spec/repositories/story_repository_spec.rb'
+    - 'spec/requests/feeds_controller_spec.rb'
+    - 'spec/tasks/remove_old_stories_spec.rb'
+    - 'spec/utils/feed_discovery_spec.rb'
+    - 'spec/utils/i18n_support_spec.rb'
+    - 'spec/utils/opml_parser_spec.rb'
+
+# Offense count: 5
+# Configuration parameters: EnforcedStyle, IgnoreSharedExamples.
+# SupportedStyles: always, named_only
+RSpec/NamedSubject:
+  Exclude:
+    - 'spec/commands/fever_api/write_mark_item_spec.rb'
+
+# Offense count: 2
+# Configuration parameters: Max, AllowedGroups.
+RSpec/NestedGroups:
+  Exclude:
+    - 'spec/integration/feed_importing_spec.rb'
+
+# Offense count: 26
+# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
+RSpec/VerifiedDoubles:
+  Exclude:
+    - 'spec/commands/feed/create_spec.rb'
+    - 'spec/commands/feed/fetch_one_spec.rb'
+    - 'spec/commands/feed/find_new_stories_spec.rb'
+    - 'spec/repositories/story_repository_spec.rb'
+    - 'spec/tasks/remove_old_stories_spec.rb'
+    - 'spec/utils/feed_discovery_spec.rb'
+
+# Offense count: 2
+# Configuration parameters: IgnoreScopes, Include.
+# Include: **/app/models/**/*.rb
+Rails/InverseOf:
+  Exclude:
+    - 'app/models/feed.rb'
+
+# Offense count: 3
+# Configuration parameters: Include.
+# Include: db/**/*.rb
+Rails/ReversibleMigrationMethodDefinition:
+  Exclude:
+    - 'db/migrate/20130423001740_drop_email_from_user.rb'
+    - 'db/migrate/20130423180446_remove_author_from_stories.rb'
+    - 'db/migrate/20130425222157_add_delayed_job.rb'
+
+# Offense count: 9
+# This cop supports unsafe autocorrection (--autocorrect-all).
+# Configuration parameters: AllowImplicitReturn, AllowedReceivers.
+Rails/SaveBang:
+  Exclude:
+    - 'app/commands/feed/create.rb'
+    - 'app/commands/feed/import_from_opml.rb'
+    - 'app/repositories/feed_repository.rb'
+    - 'app/repositories/story_repository.rb'
+    - 'app/repositories/user_repository.rb'
+    - 'db/migrate/20130821020313_update_nil_entry_ids.rb'
+
+# Offense count: 2
+# Configuration parameters: ForbiddenMethods, AllowedMethods.
+# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all
+Rails/SkipsModelValidations:
+  Exclude:
+    - 'db/migrate/20140421224454_fix_invalid_unicode.rb'
+    - 'db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb'
+
+# Offense count: 5
+# Configuration parameters: Include.
+# Include: db/**/*.rb
+Rails/ThreeStateBooleanColumn:
+  Exclude:
+    - 'db/migrate/20130412185253_add_new_fields_to_stories.rb'
+    - 'db/migrate/20130425211008_add_setup_complete_to_user.rb'
+    - 'db/migrate/20130513025939_add_keep_unread_to_stories.rb'
+    - 'db/migrate/20130513044029_add_is_starred_status_for_stories.rb'
+    - 'db/migrate/20230801025233_create_good_job_executions.rb'
+
+# Offense count: 6
+# This cop supports unsafe autocorrection (--autocorrect-all).
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: strict, flexible
+Rails/TimeZone:
+  Exclude:
+    - 'app/commands/feed/find_new_stories.rb'
+    - 'app/repositories/story_repository.rb'
+    - 'app/tasks/remove_old_stories.rb'
+    - 'app/utils/sample_story.rb'
+
+# Offense count: 1
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: Include.
+# Include: **/app/models/**/*.rb
+Rails/Validation:
+  Exclude:
+    - 'app/models/story.rb'
+
+# Offense count: 1
+# This cop supports safe autocorrection (--autocorrect).
+Style/IfUnlessModifier:
+  Exclude:
+    - 'db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb'
+
+# Offense count: 1
+# This cop supports safe autocorrection (--autocorrect).
+# Configuration parameters: EnforcedStyle.
+# SupportedStyles: return, return_nil
+Style/ReturnNil:
+  Exclude:
+    - 'app/repositories/user_repository.rb'
+
+# Offense count: 4
+# This cop supports unsafe autocorrection (--autocorrect-all).
+Style/StaticClass:
+  Exclude:
+    - 'app/repositories/feed_repository.rb'
+    - 'app/repositories/group_repository.rb'
+    - 'app/repositories/story_repository.rb'
+    - 'app/repositories/user_repository.rb'
+
+# Offense count: 1
+Style/TopLevelMethodDefinition:
+  Exclude:
+    - 'spec/integration/feed_importing_spec.rb'
diff --git a/.ruby-version b/.ruby-version
index 0bee604df..4f5e69734 100644
--- a/.ruby-version
+++ b/.ruby-version
@@ -1 +1 @@
-2.3.3
+3.4.5
diff --git a/.stringer.env b/.stringer.env
new file mode 100644
index 000000000..12499378e
--- /dev/null
+++ b/.stringer.env
@@ -0,0 +1,8 @@
+SECRET_KEY_BASE=5e1a0474b6c8b517c58a676bb9baae9da8fc82d4c5a13a42a1b69c3b310fe666ff824bfe9426db1c0814ea83fdacb1a8f80eed90ae3501006ea17440136620d9
+ENCRYPTION_PRIMARY_KEY=773dddc695536c2e7bcfe7e56f1bfc9ba29a793663304a6b6ef4764867fd7fdb93b9bbf52f3cf67b11b5bcb31d1da3583202e216e08d645363d453feef776a60
+ENCRYPTION_DETERMINISTIC_KEY=a827a6e936dec463b1635803bb19b96815b74e7aa871c656ac8bce45c070dbdf893300ff5e633ee5143efcf5915ee52c760d851e1bfb48f794ac12a40d433398
+ENCRYPTION_KEY_DERIVATION_SALT=63a7e1e618d35721a98d9d71628bda6d5e4b154f5357ad84d78b20b3be09416a204dce57ba1bf1946cfcad05cc990ffc6e8693d801ee4b184d6ba92d5831d68a
+
+DATABASE_URL=postgres://:@/
+FETCH_FEEDS_CRON='*/5 * * * *'
+CLEANUP_CRON='0 0 * * *'
diff --git a/.tool-versions b/.tool-versions
index 69ed28bbf..4059f806b 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1 +1,3 @@
-ruby 2.3.3
+ruby 3.4.5
+bundler 2.6.2
+postgres 14.6
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 501560eae..000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-before_install:
-  - gem update bundler
-  - 'echo "RUBY_VERSION: $RUBY_VERSION"'
-before_script:
-  - npm install -g mocha-phantomjs@4.1.0
-  - bundle exec rake test_js &> /dev/null &
-  - sleep 5
-cache: bundler
-language: ruby
-rvm:
-  - 2.3.3
-  - ruby-head
-matrix:
-  allow_failures:
-    - rvm: ruby-head
-script:
-  - bundle exec rspec
-  - mocha-phantomjs http://localhost:4567/test
-  - bundle exec rubocop
-sudo: false
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 000000000..fa581a793
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,128 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+  and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+  overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+  advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+  address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+robert@boon.gl.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior,  harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/Dockerfile b/Dockerfile
index 8ccb1ad59..0abd7c415 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,17 +1,21 @@
-FROM ruby:2.3.3
+FROM ruby:3.4.5
 
 ENV RACK_ENV=production
+ENV RAILS_ENV=production
 ENV PORT=8080
+ENV BUNDLER_VERSION=2.6.2
 
 EXPOSE 8080
 
+SHELL ["/bin/bash", "-c"]
+
 WORKDIR /app
 ADD Gemfile Gemfile.lock /app/
-RUN bundle install --deployment
+RUN gem install bundler:$BUNDLER_VERSION && bundle install
 
 RUN apt-get update \
     && apt-get install -y --no-install-recommends \
-      supervisor locales nodejs \
+      supervisor locales nodejs vim nano \
     && apt-get clean \
     && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
 
@@ -19,14 +23,18 @@ RUN DEBIAN_FRONTEND=noninteractive dpkg-reconfigure locales \
     && locale-gen C.UTF-8 \
     && /usr/sbin/update-locale LANG=C.UTF-8
 
-ENV LC_ALL C.UTF-8
+ENV LC_ALL=C.UTF-8
 
-ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.1.3/supercronic-linux-amd64 \
-    SUPERCRONIC=supercronic-linux-amd64 \
-    SUPERCRONIC_SHA1SUM=96960ba3207756bb01e6892c978264e5362e117e
+ARG TARGETARCH
+ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.1.3/supercronic-linux-$TARGETARCH \
+    SUPERCRONIC=supercronic-linux-$TARGETARCH \
+    SUPERCRONIC_amd64_SHA1SUM=96960ba3207756bb01e6892c978264e5362e117e \
+    SUPERCRONIC_arm_SHA1SUM=8c1e7af256ee35a9fcaf19c6a22aa59a8ccc03ef \
+    SUPERCRONIC_arm64_SHA1SUM=f0e8049f3aa8e24ec43e76955a81b76e90c02270 \
+    SUPERCRONIC_SHA1SUM="SUPERCRONIC_${TARGETARCH}_SHA1SUM"
 
 RUN curl -fsSLO "$SUPERCRONIC_URL" \
- && echo "${SUPERCRONIC_SHA1SUM}  ${SUPERCRONIC}" | sha1sum -c - \
+ && echo "${!SUPERCRONIC_SHA1SUM}  ${SUPERCRONIC}" | sha1sum -c - \
  && chmod +x "$SUPERCRONIC" \
  && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
  && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic
@@ -39,4 +47,6 @@ RUN useradd -m stringer
 RUN chown -R stringer:stringer /app
 USER stringer
 
+ENV RAILS_SERVE_STATIC_FILES=true
+
 CMD /app/start.sh
diff --git a/Gemfile b/Gemfile
index db9447538..9694d6fd4 100644
--- a/Gemfile
+++ b/Gemfile
@@ -1,49 +1,58 @@
-ruby_version_file = File.join(File.expand_path("..", __FILE__), ".ruby-version")
+# frozen_string_literal: true
+
+ruby_version_file = File.expand_path(".ruby-version", __dir__)
 ruby File.read(ruby_version_file).chomp if File.readable?(ruby_version_file)
 source "https://rubygems.org"
+git_source(:github) { |repo| "https://github.com/#{repo}.git" }
 
-group :production do
-  gem "pg", "~> 0.18.4"
-  gem "unicorn", "~> 4.7"
-end
+gem "dotenv-rails"
+
+gem "rails", "~> 8.0.0"
+
+gem "bcrypt"
+gem "bootsnap", require: false
+gem "feedbag"
+gem "feedjira"
+gem "good_job", "~> 4.11.1"
+gem "httparty"
+gem "nokogiri", "~> 1.18.0"
+gem "pg"
+gem "puma", "~> 6.4"
+gem "rack-ssl"
+gem "sass"
+gem "sprockets"
+gem "sprockets-rails"
+gem "stripe"
+gem "thread"
+gem "uglifier"
+gem "will_paginate"
 
 group :development do
-  gem "sqlite3", "~> 1.3", ">= 1.3.8"
+  gem "rubocop", require: false
+  gem "rubocop-capybara", require: false
+  gem "rubocop-factory_bot", require: false
+  gem "rubocop-rails", require: false
+  gem "rubocop-rake", require: false
+  gem "rubocop-rspec", require: false
+  gem "rubocop-rspec_rails", require: false
+  gem "web-console"
 end
 
 group :development, :test do
-  gem "capybara", "~> 2.6"
-  gem "coveralls", "~> 0.7", require: false
-  gem "faker", "~> 1.2"
-  gem "pry-byebug", "~> 1.2"
-  gem "rack-test", "~> 0.6"
-  gem "rspec", "~> 3.4"
-  gem "rspec-html-matchers", "~> 0.7"
-  gem "rubocop", "~> 0.38", require: false
-  gem "shotgun", "~> 0.9"
-  gem "timecop", "~> 0.8"
+  gem "capybara"
+  gem "coveralls_reborn", require: false
+  gem "debug"
+  gem "factory_bot"
+  gem "pry-byebug"
+  gem "rspec"
+  gem "rspec-rails"
+  gem "simplecov"
+  gem "webmock", require: false
 end
 
-gem "activerecord", "~> 4.2.6"
-gem "bcrypt", "~> 3.1"
-gem "delayed_job", "~> 4.1"
-gem "delayed_job_active_record", "~> 4.1"
-gem "feedbag", "~> 0.9.5"
-gem "feedjira", "~> 2.1.0"
-gem "i18n"
-gem "loofah", "~> 2.0"
-gem "nokogiri", "~> 1.6", ">= 1.6.7.2"
-gem "rack-protection", "~> 1.5"
-gem "rack-ssl", "~> 1.4"
-gem "racksh", "~> 1.0"
-gem "rake", "~> 10.1", ">= 10.1.1"
-gem "sass"
-gem "sinatra", "~> 1.4.8", ">= 1.4.8"
-gem "sinatra-activerecord", "~> 1.2", ">= 1.2.3"
-gem "sinatra-contrib", "~> 1.4.7"
-gem "sinatra-flash", "~> 0.3"
-gem "sprockets", "~> 3.0"
-gem "sprockets-helpers"
-gem "thread", "~> 0.2"
-gem "uglifier"
-gem "will_paginate", "~> 3.1"
+group :test do
+  gem "axe-core-rspec"
+  gem "selenium-webdriver"
+  gem "webdrivers"
+  gem "with_model"
+end
diff --git a/Gemfile.lock b/Gemfile.lock
index fe40a9405..bb7a402ef 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,224 +1,468 @@
 GEM
   remote: https://rubygems.org/
   specs:
-    activemodel (4.2.6)
-      activesupport (= 4.2.6)
+    actioncable (8.0.2.1)
+      actionpack (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      nio4r (~> 2.0)
+      websocket-driver (>= 0.6.1)
+      zeitwerk (~> 2.6)
+    actionmailbox (8.0.2.1)
+      actionpack (= 8.0.2.1)
+      activejob (= 8.0.2.1)
+      activerecord (= 8.0.2.1)
+      activestorage (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      mail (>= 2.8.0)
+    actionmailer (8.0.2.1)
+      actionpack (= 8.0.2.1)
+      actionview (= 8.0.2.1)
+      activejob (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      mail (>= 2.8.0)
+      rails-dom-testing (~> 2.2)
+    actionpack (8.0.2.1)
+      actionview (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      nokogiri (>= 1.8.5)
+      rack (>= 2.2.4)
+      rack-session (>= 1.0.1)
+      rack-test (>= 0.6.3)
+      rails-dom-testing (~> 2.2)
+      rails-html-sanitizer (~> 1.6)
+      useragent (~> 0.16)
+    actiontext (8.0.2.1)
+      actionpack (= 8.0.2.1)
+      activerecord (= 8.0.2.1)
+      activestorage (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      globalid (>= 0.6.0)
+      nokogiri (>= 1.8.5)
+    actionview (8.0.2.1)
+      activesupport (= 8.0.2.1)
       builder (~> 3.1)
-    activerecord (4.2.6)
-      activemodel (= 4.2.6)
-      activesupport (= 4.2.6)
-      arel (~> 6.0)
-    activesupport (4.2.6)
-      i18n (~> 0.7)
-      json (~> 1.7, >= 1.7.7)
-      minitest (~> 5.1)
-      thread_safe (~> 0.3, >= 0.3.4)
-      tzinfo (~> 1.1)
-    addressable (2.4.0)
-    arel (6.0.3)
-    ast (2.2.0)
-    backports (3.6.8)
-    bcrypt (3.1.11)
-    builder (3.2.2)
-    byebug (2.7.0)
-      columnize (~> 0.3)
-      debugger-linecache (~> 1.2)
-    capybara (2.6.2)
+      erubi (~> 1.11)
+      rails-dom-testing (~> 2.2)
+      rails-html-sanitizer (~> 1.6)
+    activejob (8.0.2.1)
+      activesupport (= 8.0.2.1)
+      globalid (>= 0.3.6)
+    activemodel (8.0.2.1)
+      activesupport (= 8.0.2.1)
+    activerecord (8.0.2.1)
+      activemodel (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      timeout (>= 0.4.0)
+    activestorage (8.0.2.1)
+      actionpack (= 8.0.2.1)
+      activejob (= 8.0.2.1)
+      activerecord (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      marcel (~> 1.0)
+    activesupport (8.0.2.1)
+      base64
+      benchmark (>= 0.3)
+      bigdecimal
+      concurrent-ruby (~> 1.0, >= 1.3.1)
+      connection_pool (>= 2.2.5)
+      drb
+      i18n (>= 1.6, < 2)
+      logger (>= 1.4.2)
+      minitest (>= 5.1)
+      securerandom (>= 0.3)
+      tzinfo (~> 2.0, >= 2.0.5)
+      uri (>= 0.13.1)
+    addressable (2.8.7)
+      public_suffix (>= 2.0.2, < 7.0)
+    ast (2.4.3)
+    axe-core-api (4.10.3)
+      dumb_delegator
+      ostruct
+      virtus
+    axe-core-rspec (4.10.3)
+      axe-core-api (= 4.10.3)
+      dumb_delegator
+      ostruct
+      virtus
+    axiom-types (0.1.1)
+      descendants_tracker (~> 0.0.4)
+      ice_nine (~> 0.11.0)
+      thread_safe (~> 0.3, >= 0.3.1)
+    base64 (0.3.0)
+    bcrypt (3.1.20)
+    benchmark (0.4.1)
+    bigdecimal (3.2.2)
+    bindex (0.8.1)
+    bootsnap (1.18.6)
+      msgpack (~> 1.2)
+    builder (3.3.0)
+    byebug (12.0.0)
+    capybara (3.40.0)
       addressable
-      mime-types (>= 1.16)
-      nokogiri (>= 1.3.3)
-      rack (>= 1.0.0)
-      rack-test (>= 0.5.4)
-      xpath (~> 2.0)
-    coderay (1.1.1)
-    columnize (0.9.0)
-    concurrent-ruby (1.0.2)
-    coveralls (0.8.13)
-      json (~> 1.8)
-      simplecov (~> 0.11.0)
-      term-ansicolor (~> 1.3)
-      thor (~> 0.19.1)
-      tins (~> 1.6.0)
-    debugger-linecache (1.2.0)
-    delayed_job (4.1.1)
-      activesupport (>= 3.0, < 5.0)
-    delayed_job_active_record (4.1.0)
-      activerecord (>= 3.0, < 5)
-      delayed_job (>= 3.0, < 5)
-    diff-lcs (1.2.5)
-    docile (1.1.5)
-    execjs (2.7.0)
-    faker (1.6.3)
-      i18n (~> 0.5)
-    faraday (0.11.0)
-      multipart-post (>= 1.2, < 3)
-    faraday_middleware (0.11.0.1)
-      faraday (>= 0.7.4, < 1.0)
-    feedbag (0.9.5)
-      nokogiri (~> 1.0)
-    feedjira (2.1.0)
-      faraday (>= 0.9)
-      faraday_middleware (>= 0.9)
-      loofah (>= 2.0)
-      sax-machine (>= 1.0)
-    i18n (0.7.0)
-    json (1.8.3)
-    kgio (2.10.0)
-    loofah (2.0.3)
-      nokogiri (>= 1.5.9)
-    method_source (0.8.2)
-    mime-types (3.0)
-      mime-types-data (~> 3.2015)
-    mime-types-data (3.2016.0221)
-    mini_portile2 (2.0.0)
-    minitest (5.8.4)
-    multi_json (1.12.1)
-    multipart-post (2.0.0)
-    nokogiri (1.6.7.2)
-      mini_portile2 (~> 2.0.0.rc2)
-    parser (2.3.0.6)
-      ast (~> 2.2)
-    pg (0.18.4)
-    powerpack (0.1.1)
-    pry (0.10.3)
-      coderay (~> 1.1.0)
-      method_source (~> 0.8.1)
-      slop (~> 3.4)
-    pry-byebug (1.3.3)
-      byebug (~> 2.7)
-      pry (~> 0.10)
-    rack (1.6.5)
-    rack-protection (1.5.3)
-      rack
+      matrix
+      mini_mime (>= 0.1.3)
+      nokogiri (~> 1.11)
+      rack (>= 1.6.0)
+      rack-test (>= 0.6.3)
+      regexp_parser (>= 1.5, < 3.0)
+      xpath (~> 3.2)
+    coderay (1.1.3)
+    coercible (1.0.0)
+      descendants_tracker (~> 0.0.1)
+    concurrent-ruby (1.3.5)
+    connection_pool (2.5.3)
+    coveralls_reborn (0.29.0)
+      simplecov (~> 0.22.0)
+      term-ansicolor (~> 1.7)
+      thor (~> 1.2)
+      tins (~> 1.32)
+    crack (1.0.0)
+      bigdecimal
+      rexml
+    crass (1.0.6)
+    csv (3.3.5)
+    date (3.4.1)
+    debug (1.11.0)
+      irb (~> 1.10)
+      reline (>= 0.3.8)
+    descendants_tracker (0.0.4)
+      thread_safe (~> 0.3, >= 0.3.1)
+    diff-lcs (1.6.2)
+    docile (1.4.1)
+    dotenv (3.1.8)
+    dotenv-rails (3.1.8)
+      dotenv (= 3.1.8)
+      railties (>= 6.1)
+    drb (2.2.3)
+    dumb_delegator (1.1.0)
+    erb (5.0.2)
+    erubi (1.13.1)
+    et-orbi (1.3.0)
+      tzinfo
+    execjs (2.10.0)
+    factory_bot (6.5.4)
+      activesupport (>= 6.1.0)
+    feedbag (1.0.0)
+      nokogiri (~> 1.8, >= 1.8.2)
+    feedjira (3.2.5)
+      loofah (>= 2.3.1, < 3)
+      sax-machine (>= 1.0, < 2)
+    ffi (1.17.2)
+    fugit (1.11.1)
+      et-orbi (~> 1, >= 1.2.11)
+      raabro (~> 1.4)
+    globalid (1.2.1)
+      activesupport (>= 6.1)
+    good_job (4.11.2)
+      activejob (>= 6.1.0)
+      activerecord (>= 6.1.0)
+      concurrent-ruby (>= 1.3.1)
+      fugit (>= 1.11.0)
+      railties (>= 6.1.0)
+      thor (>= 1.0.0)
+    hashdiff (1.2.0)
+    httparty (0.23.1)
+      csv
+      mini_mime (>= 1.0.0)
+      multi_xml (>= 0.5.2)
+    i18n (1.14.7)
+      concurrent-ruby (~> 1.0)
+    ice_nine (0.11.2)
+    io-console (0.8.1)
+    irb (1.15.2)
+      pp (>= 0.6.0)
+      rdoc (>= 4.0.0)
+      reline (>= 0.4.2)
+    json (2.13.2)
+    language_server-protocol (3.17.0.5)
+    lint_roller (1.1.0)
+    logger (1.7.0)
+    loofah (2.24.1)
+      crass (~> 1.0.2)
+      nokogiri (>= 1.12.0)
+    mail (2.8.1)
+      mini_mime (>= 0.1.1)
+      net-imap
+      net-pop
+      net-smtp
+    marcel (1.0.4)
+    matrix (0.4.3)
+    method_source (1.1.0)
+    mini_mime (1.1.5)
+    mini_portile2 (2.8.9)
+    minitest (5.25.5)
+    msgpack (1.8.0)
+    multi_xml (0.7.2)
+      bigdecimal (~> 3.1)
+    net-imap (0.5.9)
+      date
+      net-protocol
+    net-pop (0.1.2)
+      net-protocol
+    net-protocol (0.2.2)
+      timeout
+    net-smtp (0.5.1)
+      net-protocol
+    nio4r (2.7.4)
+    nokogiri (1.18.9)
+      mini_portile2 (~> 2.8.2)
+      racc (~> 1.4)
+    ostruct (0.6.3)
+    parallel (1.27.0)
+    parser (3.3.9.0)
+      ast (~> 2.4.1)
+      racc
+    pg (1.6.1)
+    pp (0.6.2)
+      prettyprint
+    prettyprint (0.2.0)
+    prism (1.4.0)
+    pry (0.15.2)
+      coderay (~> 1.1)
+      method_source (~> 1.0)
+    pry-byebug (3.11.0)
+      byebug (~> 12.0)
+      pry (>= 0.13, < 0.16)
+    psych (5.2.6)
+      date
+      stringio
+    public_suffix (6.0.2)
+    puma (6.6.1)
+      nio4r (~> 2.0)
+    raabro (1.4.0)
+    racc (1.8.1)
+    rack (3.2.0)
+    rack-session (2.1.1)
+      base64 (>= 0.1.0)
+      rack (>= 3.0.0)
     rack-ssl (1.4.1)
       rack
-    rack-test (0.6.3)
-      rack (>= 1.0)
-    racksh (1.0.0)
-      rack (>= 1.0)
-      rack-test (>= 0.5)
-    rainbow (2.1.0)
-    raindrops (0.16.0)
-    rake (10.5.0)
-    rspec (3.4.0)
-      rspec-core (~> 3.4.0)
-      rspec-expectations (~> 3.4.0)
-      rspec-mocks (~> 3.4.0)
-    rspec-core (3.4.4)
-      rspec-support (~> 3.4.0)
-    rspec-expectations (3.4.0)
+    rack-test (2.2.0)
+      rack (>= 1.3)
+    rackup (2.2.1)
+      rack (>= 3)
+    rails (8.0.2.1)
+      actioncable (= 8.0.2.1)
+      actionmailbox (= 8.0.2.1)
+      actionmailer (= 8.0.2.1)
+      actionpack (= 8.0.2.1)
+      actiontext (= 8.0.2.1)
+      actionview (= 8.0.2.1)
+      activejob (= 8.0.2.1)
+      activemodel (= 8.0.2.1)
+      activerecord (= 8.0.2.1)
+      activestorage (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      bundler (>= 1.15.0)
+      railties (= 8.0.2.1)
+    rails-dom-testing (2.3.0)
+      activesupport (>= 5.0.0)
+      minitest
+      nokogiri (>= 1.6)
+    rails-html-sanitizer (1.6.2)
+      loofah (~> 2.21)
+      nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
+    railties (8.0.2.1)
+      actionpack (= 8.0.2.1)
+      activesupport (= 8.0.2.1)
+      irb (~> 1.13)
+      rackup (>= 1.0.0)
+      rake (>= 12.2)
+      thor (~> 1.0, >= 1.2.2)
+      zeitwerk (~> 2.6)
+    rainbow (3.1.1)
+    rake (13.3.0)
+    rb-fsevent (0.11.2)
+    rb-inotify (0.11.1)
+      ffi (~> 1.0)
+    rdoc (6.14.2)
+      erb
+      psych (>= 4.0.0)
+    regexp_parser (2.11.1)
+    reline (0.6.2)
+      io-console (~> 0.5)
+    rexml (3.4.1)
+    rspec (3.13.1)
+      rspec-core (~> 3.13.0)
+      rspec-expectations (~> 3.13.0)
+      rspec-mocks (~> 3.13.0)
+    rspec-core (3.13.5)
+      rspec-support (~> 3.13.0)
+    rspec-expectations (3.13.5)
       diff-lcs (>= 1.2.0, < 2.0)
-      rspec-support (~> 3.4.0)
-    rspec-html-matchers (0.7.1)
-      nokogiri (~> 1)
-      rspec (>= 3.0.0.a, < 4)
-    rspec-mocks (3.4.1)
+      rspec-support (~> 3.13.0)
+    rspec-mocks (3.13.5)
       diff-lcs (>= 1.2.0, < 2.0)
-      rspec-support (~> 3.4.0)
-    rspec-support (3.4.1)
-    rubocop (0.38.0)
-      parser (>= 2.3.0.6, < 3.0)
-      powerpack (~> 0.1)
-      rainbow (>= 1.99.1, < 3.0)
+      rspec-support (~> 3.13.0)
+    rspec-rails (8.0.1)
+      actionpack (>= 7.2)
+      activesupport (>= 7.2)
+      railties (>= 7.2)
+      rspec-core (~> 3.13)
+      rspec-expectations (~> 3.13)
+      rspec-mocks (~> 3.13)
+      rspec-support (~> 3.13)
+    rspec-support (3.13.4)
+    rubocop (1.79.2)
+      json (~> 2.3)
+      language_server-protocol (~> 3.17.0.2)
+      lint_roller (~> 1.1.0)
+      parallel (~> 1.10)
+      parser (>= 3.3.0.2)
+      rainbow (>= 2.2.2, < 4.0)
+      regexp_parser (>= 2.9.3, < 3.0)
+      rubocop-ast (>= 1.46.0, < 2.0)
       ruby-progressbar (~> 1.7)
-      unicode-display_width (~> 1.0, >= 1.0.1)
-    ruby-progressbar (1.7.5)
-    sass (3.4.22)
+      unicode-display_width (>= 2.4.0, < 4.0)
+    rubocop-ast (1.46.0)
+      parser (>= 3.3.7.2)
+      prism (~> 1.4)
+    rubocop-capybara (2.22.1)
+      lint_roller (~> 1.1)
+      rubocop (~> 1.72, >= 1.72.1)
+    rubocop-factory_bot (2.27.1)
+      lint_roller (~> 1.1)
+      rubocop (~> 1.72, >= 1.72.1)
+    rubocop-rails (2.33.0)
+      activesupport (>= 4.2.0)
+      lint_roller (~> 1.1)
+      rack (>= 1.1)
+      rubocop (>= 1.75.0, < 2.0)
+      rubocop-ast (>= 1.44.0, < 2.0)
+    rubocop-rake (0.7.1)
+      lint_roller (~> 1.1)
+      rubocop (>= 1.72.1)
+    rubocop-rspec (3.6.0)
+      lint_roller (~> 1.1)
+      rubocop (~> 1.72, >= 1.72.1)
+    rubocop-rspec_rails (2.31.0)
+      lint_roller (~> 1.1)
+      rubocop (~> 1.72, >= 1.72.1)
+      rubocop-rspec (~> 3.5)
+    ruby-progressbar (1.13.0)
+    rubyzip (2.4.1)
+    sass (3.7.4)
+      sass-listen (~> 4.0.0)
+    sass-listen (4.0.0)
+      rb-fsevent (~> 0.9, >= 0.9.4)
+      rb-inotify (~> 0.9, >= 0.9.7)
     sax-machine (1.3.2)
-    shotgun (0.9.1)
-      rack (>= 1.0)
-    simplecov (0.11.2)
-      docile (~> 1.1.0)
-      json (~> 1.8)
-      simplecov-html (~> 0.10.0)
-    simplecov-html (0.10.0)
-    sinatra (1.4.8)
-      rack (~> 1.5)
-      rack-protection (~> 1.4)
-      tilt (>= 1.3, < 3)
-    sinatra-activerecord (1.7.0)
-      activerecord (>= 3.0)
-      sinatra (~> 1.0)
-    sinatra-contrib (1.4.7)
-      backports (>= 2.0)
-      multi_json
-      rack-protection
-      rack-test
-      sinatra (~> 1.4.0)
-      tilt (>= 1.3, < 3)
-    sinatra-flash (0.3.0)
-      sinatra (>= 1.0.0)
-    slop (3.6.0)
-    sprockets (3.7.0)
+    securerandom (0.4.1)
+    selenium-webdriver (4.10.0)
+      rexml (~> 3.2, >= 3.2.5)
+      rubyzip (>= 1.2.2, < 3.0)
+      websocket (~> 1.0)
+    simplecov (0.22.0)
+      docile (~> 1.1)
+      simplecov-html (~> 0.11)
+      simplecov_json_formatter (~> 0.1)
+    simplecov-html (0.13.2)
+    simplecov_json_formatter (0.1.4)
+    sprockets (4.2.2)
       concurrent-ruby (~> 1.0)
-      rack (> 1, < 3)
-    sprockets-helpers (1.2.1)
-      sprockets (>= 2.2)
-    sqlite3 (1.3.11)
-    term-ansicolor (1.3.2)
+      logger
+      rack (>= 2.2.4, < 4)
+    sprockets-rails (3.5.2)
+      actionpack (>= 6.1)
+      activesupport (>= 6.1)
+      sprockets (>= 3.0.0)
+    stringio (3.1.7)
+    stripe (15.4.0)
+    sync (0.5.0)
+    term-ansicolor (1.11.2)
       tins (~> 1.0)
-    thor (0.19.1)
+    thor (1.4.0)
     thread (0.2.2)
-    thread_safe (0.3.5)
-    tilt (1.4.1)
-    timecop (0.8.0)
-    tins (1.6.0)
-    tzinfo (1.2.2)
-      thread_safe (~> 0.1)
-    uglifier (3.0.2)
+    thread_safe (0.3.6)
+    timeout (0.4.3)
+    tins (1.39.1)
+      bigdecimal
+      sync
+    tzinfo (2.0.6)
+      concurrent-ruby (~> 1.0)
+    uglifier (4.2.1)
       execjs (>= 0.3.0, < 3)
-    unicode-display_width (1.0.2)
-    unicorn (4.9.0)
-      kgio (~> 2.6)
-      rack
-      raindrops (~> 0.7)
-    will_paginate (3.1.0)
-    xpath (2.0.0)
-      nokogiri (~> 1.3)
+    unicode-display_width (3.1.4)
+      unicode-emoji (~> 4.0, >= 4.0.4)
+    unicode-emoji (4.0.4)
+    uri (1.0.3)
+    useragent (0.16.11)
+    virtus (2.0.0)
+      axiom-types (~> 0.1)
+      coercible (~> 1.0)
+      descendants_tracker (~> 0.0, >= 0.0.3)
+    web-console (4.2.1)
+      actionview (>= 6.0.0)
+      activemodel (>= 6.0.0)
+      bindex (>= 0.4.0)
+      railties (>= 6.0.0)
+    webdrivers (5.3.1)
+      nokogiri (~> 1.6)
+      rubyzip (>= 1.3.0)
+      selenium-webdriver (~> 4.0, < 4.11)
+    webmock (3.25.1)
+      addressable (>= 2.8.0)
+      crack (>= 0.3.2)
+      hashdiff (>= 0.4.0, < 2.0.0)
+    websocket (1.2.11)
+    websocket-driver (0.8.0)
+      base64
+      websocket-extensions (>= 0.1.0)
+    websocket-extensions (0.1.5)
+    will_paginate (4.0.1)
+    with_model (2.2.0)
+      activerecord (>= 7.0)
+    xpath (3.2.0)
+      nokogiri (~> 1.8)
+    zeitwerk (2.7.3)
 
 PLATFORMS
   ruby
 
 DEPENDENCIES
-  activerecord (~> 4.2.6)
-  bcrypt (~> 3.1)
-  capybara (~> 2.6)
-  coveralls (~> 0.7)
-  delayed_job (~> 4.1)
-  delayed_job_active_record (~> 4.1)
-  faker (~> 1.2)
-  feedbag (~> 0.9.5)
-  feedjira (~> 2.1.0)
-  i18n
-  loofah (~> 2.0)
-  nokogiri (~> 1.6, >= 1.6.7.2)
-  pg (~> 0.18.4)
-  pry-byebug (~> 1.2)
-  rack-protection (~> 1.5)
-  rack-ssl (~> 1.4)
-  rack-test (~> 0.6)
-  racksh (~> 1.0)
-  rake (~> 10.1, >= 10.1.1)
-  rspec (~> 3.4)
-  rspec-html-matchers (~> 0.7)
-  rubocop (~> 0.38)
+  axe-core-rspec
+  bcrypt
+  bootsnap
+  capybara
+  coveralls_reborn
+  debug
+  dotenv-rails
+  factory_bot
+  feedbag
+  feedjira
+  good_job (~> 4.11.1)
+  httparty
+  nokogiri (~> 1.18.0)
+  pg
+  pry-byebug
+  puma (~> 6.4)
+  rack-ssl
+  rails (~> 8.0.0)
+  rspec
+  rspec-rails
+  rubocop
+  rubocop-capybara
+  rubocop-factory_bot
+  rubocop-rails
+  rubocop-rake
+  rubocop-rspec
+  rubocop-rspec_rails
   sass
-  shotgun (~> 0.9)
-  sinatra (~> 1.4.8, >= 1.4.8)
-  sinatra-activerecord (~> 1.2, >= 1.2.3)
-  sinatra-contrib (~> 1.4.7)
-  sinatra-flash (~> 0.3)
-  sprockets (~> 3.0)
-  sprockets-helpers
-  sqlite3 (~> 1.3, >= 1.3.8)
-  thread (~> 0.2)
-  timecop (~> 0.8)
+  selenium-webdriver
+  simplecov
+  sprockets
+  sprockets-rails
+  stripe
+  thread
   uglifier
-  unicorn (~> 4.7)
-  will_paginate (~> 3.1)
+  web-console
+  webdrivers
+  webmock
+  will_paginate
+  with_model
 
 RUBY VERSION
-   ruby 2.3.3p222
+   ruby 3.4.5
 
 BUNDLED WITH
-   1.14.6
+   2.6.2
diff --git a/Procfile b/Procfile
index dfa2ae6e9..c41f453d9 100644
--- a/Procfile
+++ b/Procfile
@@ -1,2 +1,2 @@
-web: bundle exec unicorn -p $PORT -c ./config/unicorn.rb
-console: bundle exec racksh
+web: bundle exec puma -p $PORT -C ./config/puma.rb
+release: bundle exec rails db:migrate
diff --git a/README.md b/README.md
index f7e0806c2..37a04b641 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,9 @@
 # Stringer
 
-[![Build Status](https://api.travis-ci.org/swanson/stringer.svg?style=flat)](https://travis-ci.org/swanson/stringer)
-[![Code Climate](https://codeclimate.com/github/swanson/stringer.svg?style=flat)](https://codeclimate.com/github/swanson/stringer)
-[![Coverage Status](https://coveralls.io/repos/swanson/stringer/badge.svg?style=flat)](https://coveralls.io/r/swanson/stringer)
-[![Dependency Status](https://gemnasium.com/swanson/stringer.svg)](https://gemnasium.com/swanson/stringer)
+[![CircleCI](https://circleci.com/gh/stringer-rss/stringer/tree/main.svg?style=svg)](https://circleci.com/gh/stringer-rss/stringer/tree/main)
+[![Code Climate](https://api.codeclimate.com/v1/badges/899c5407c870e541af4e/maintainability)](https://codeclimate.com/github/stringer-rss/stringer/maintainability)
+[![Coverage Status](https://coveralls.io/repos/github/stringer-rss/stringer/badge.svg?branch=main)](https://coveralls.io/github/stringer-rss/stringer?branch=main)
+[![GitHub Sponsors](https://img.shields.io/github/sponsors/mockdeep?logo=github)](https://github.com/sponsors/mockdeep)
 
 ### A self-hosted, anti-social RSS reader.
 
@@ -17,14 +17,14 @@ But it does have keyboard shortcuts and was made with love!
 
 ## Installation
 
-Stringer is a Ruby (2.3.0+) app based on Sinatra, ActiveRecord, PostgreSQL, Backbone.js and DelayedJob.
+Stringer is a Ruby app based on Rails, PostgreSQL, Backbone.js and GoodJob.
 
-[![Deploy to Heroku](https://cdn.herokuapp.com/deploy/button.svg)](https://heroku.com/deploy)
+[![Deploy to Heroku](https://cdn.herokuapp.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/stringer-rss/stringer)
 
-Stringer will run just fine on the Heroku free plan.
+Stringer will run just fine on the Eco/Basic Heroku plans.
 
 Instructions are provided for deploying to [Heroku manually](/docs/Heroku.md), to any Ruby 
-compatible [Linux-based VPS](/docs/VPS.md), to [Docker](docs/docker.md) and to [OpenShift](/docs/OpenShift.md).
+compatible [Linux-based VPS](/docs/VPS.md), to [Docker](docs/Docker.md) and to [OpenShift](/docs/OpenShift.md).
 
 ## Niceties
 
@@ -34,7 +34,7 @@ You can access the keyboard shortcuts when using the app by hitting `?`.
 
 ![](screenshots/keyboard_shortcuts.png)
 
-### Using you own domain with Heroku
+### Using your own domain with Heroku
 
 You can run Stringer at `http://reader.yourdomain.com` using a CNAME.
 
@@ -68,8 +68,6 @@ Email: stringer (case-sensitive)
 Password: {your-stringer-password}
 ```
 
-If you have previously setup Stringer, you will need to migrate your database and run `rake change_password` for the API key to be setup properly.
-
 ### Translations
 
 Stringer has been translated to [several other languages](config/locales). Your language can be set with the `LOCALE` environment variable.
@@ -80,9 +78,6 @@ If you would like to translate Stringer to your preferred language, please use [
 
 ### Clean up old read stories on Heroku
 
-If you are on the Heroku free plan, there is a 10k row limit so you will
-eventually run out of space.
-
 You can clean up old stories by running: `rake cleanup_old_stories`
 
 By default, this removes read stories that are more than 30 days old (that
@@ -105,15 +100,15 @@ Then run the following commands.
 
 ```sh
 bundle install
-rake db:migrate
+rails db:setup
 foreman start
 ```
 
 The application will be running on port `5000`.
 
-You can launch an interactive console (a la `rails c`) using `racksh`.
+You can launch an interactive console (a la `rails c`) using `rake console`.
 
-## Acknowledgements
+## Acknowledgments
 
 Most of the heavy-lifting is done by [`feedjira`](https://github.com/feedjira/feedjira) and [`feedbag`](https://github.com/dwillis/feedbag).
 
@@ -125,10 +120,13 @@ Lato Font Copyright &copy; 2010-2011 by tyPoland Lukasz Dziedzic (team@latofonts
 
 ## Contact
 
-If you have a question, feature idea, or are running into problems, our preferred method of contact is to open an issue on GitHub. This allows multiple people to weigh in and we can keep everything in one place. Thanks!
+If you have a question, feature idea, or are running into problems, our preferred method of contact is to open an issue on GitHub. This allows multiple people to weigh in, and we can keep everything in one place. Thanks!
 
 ## Maintainers
 
-Matt Swanson, [mdswanson.com](http://mdswanson.com), [@_swanson](http://twitter.com/_swanson)
+Robert Fletcher [boon.gl](https://boon.gl)
+
+## Alumni
 
+Matt Swanson (creator), [mdswanson.com](http://mdswanson.com), [@_swanson](http://twitter.com/_swanson)
 Victor Koronen, [victor.koronen.se](http://victor.koronen.se/), [@victorkoronen](https://twitter.com/victorkoronen)
diff --git a/Rakefile b/Rakefile
index 65669e57e..5fc8d463b 100644
--- a/Rakefile
+++ b/Rakefile
@@ -1,88 +1,35 @@
-require "bundler"
-Bundler.setup
+# frozen_string_literal: true
 
-require "rubygems"
-require "net/http"
-require "active_record"
-require "delayed_job"
-require "delayed_job_active_record"
+require_relative "config/application"
 
-require "sinatra/activerecord/rake"
-ActiveRecord::Tasks::DatabaseTasks.db_dir = "db"
-
-require "./app"
-require_relative "./app/jobs/fetch_feed_job"
-require_relative "./app/tasks/fetch_feeds"
-require_relative "./app/tasks/change_password"
-require_relative "./app/tasks/remove_old_stories.rb"
+Rails.application.load_tasks
 
 desc "Fetch all feeds."
-task :fetch_feeds do
-  FetchFeeds.new(Feed.all).fetch_all
+task fetch_feeds: :environment do
+  Feed::FetchAll.call
 end
 
 desc "Lazily fetch all feeds."
-task :lazy_fetch do
+task lazy_fetch: :environment do
   if ENV["APP_URL"]
     uri = URI(ENV["APP_URL"])
+
+    # warm up server by fetching the root path
     Net::HTTP.get_response(uri)
   end
 
   FeedRepository.list.each do |feed|
-    Delayed::Job.enqueue FetchFeedJob.new(feed.id)
+    CallableJob.perform_later(Feed::FetchOne, feed)
   end
 end
 
 desc "Fetch single feed"
-task :fetch_feed, :id do |_t, args|
-  FetchFeed.new(Feed.find(args[:id])).fetch
-end
-
-desc "Clear the delayed_job queue."
-task :clear_jobs do
-  Delayed::Job.delete_all
-end
-
-desc "Work the delayed_job queue."
-task :work_jobs do
-  Delayed::Job.delete_all
-
-  worker_retry = Integer(ENV["WORKER_RETRY"] || 3)
-  worker_retry.times do
-    Delayed::Worker.new(
-      min_priority: ENV["MIN_PRIORITY"],
-      max_priority: ENV["MAX_PRIORITY"]
-    ).start
-  end
-end
-
-desc "Change your password"
-task :change_password do
-  ChangePassword.new.change_password
+task :fetch_feed, [:id] => :environment do |_t, args|
+  Feed::FetchOne.call(Feed.find(args[:id]))
 end
 
 desc "Clean up old stories that are read and unstarred"
-task :cleanup_old_stories, :number_of_days do |_t, args|
+task :cleanup_old_stories, [:number_of_days] => :environment do |_t, args|
   args.with_defaults(number_of_days: 30)
-  RemoveOldStories.remove!(args[:number_of_days].to_i)
-end
-
-desc "Start server and serve JavaScript test suite at /test"
-task :test_js do
-  require_relative "./spec/javascript/test_controller"
-  Stringer.run!
-end
-
-begin
-  require "rspec/core/rake_task"
-
-  RSpec::Core::RakeTask.new(:speedy_tests) do |t|
-    t.rspec_opts = "--tag ~speed:slow"
-  end
-
-  RSpec::Core::RakeTask.new(:spec)
-
-  task default: [:speedy_tests]
-rescue LoadError # rubocop:disable Lint/HandleExceptions
-  # allow for bundle install --without development:test
+  RemoveOldStories.call(args[:number_of_days].to_i)
 end
diff --git a/app.json b/app.json
index 33f03cdaf..a82ae22ba 100644
--- a/app.json
+++ b/app.json
@@ -1,19 +1,31 @@
 {
   "name": "Stringer",
   "description": "A self-hosted, anti-social RSS reader.",
-  "logo": "https://raw.githubusercontent.com/swanson/stringer/master/screenshots/logo.png",
+  "logo": "https://raw.githubusercontent.com/stringer-rss/stringer/main/screenshots/logo.png",
   "keywords": [
     "RSS",
     "Ruby"
   ],
-  "website": "https://github.com/swanson/stringer",
+  "website": "https://github.com/stringer-rss/stringer",
   "success_url": "/heroku",
   "scripts": {
     "postdeploy": "bundle exec rake db:migrate"
   },
   "env": {
-    "SECRET_TOKEN": {
-      "description": "Secret key used as the session secret",
+    "SECRET_KEY_BASE": {
+      "description": "Secret key used by rails for encryption",
+      "generator": "secret"
+    },
+    "ENCRYPTION_PRIMARY_KEY": {
+      "description": "Secret key used by rails for encryption",
+      "generator": "secret"
+    },
+    "ENCRYPTION_DETERMINISTIC_KEY": {
+      "description": "Secret key used by rails for encryption",
+      "generator": "secret"
+    },
+    "ENCRYPTION_KEY_DERIVATION_SALT": {
+      "description": "Secret key used by rails for encryption",
       "generator": "secret"
     },
     "LOCALE": {
diff --git a/app.rb b/app.rb
deleted file mode 100644
index 1169a3b32..000000000
--- a/app.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-require "sinatra/base"
-require "sinatra/activerecord"
-require "sinatra/flash"
-require "sinatra/contrib/all"
-require "rack/ssl"
-require "json"
-require "i18n"
-require "will_paginate"
-require "will_paginate/active_record"
-require "sprockets"
-require "sprockets-helpers"
-require "securerandom"
-
-require_relative "app/helpers/authentication_helpers"
-require_relative "app/repositories/user_repository"
-require_relative "config/asset_pipeline"
-
-I18n.load_path += Dir[File.join(File.dirname(__FILE__), "config/locales", "*.yml").to_s]
-I18n.config.enforce_available_locales = false
-
-class Stringer < Sinatra::Base
-  # need to exclude assets for sinatra assetpack, see https://github.com/swanson/stringer/issues/112
-  use Rack::SSL, exclude: ->(env) { env["PATH_INFO"] =~ %r{^/(js|css|img)} } if ENV["ENFORCE_SSL"] == "true"
-
-  register Sinatra::ActiveRecordExtension
-  register Sinatra::Flash
-  register Sinatra::Contrib
-  register AssetPipeline
-
-  configure do
-    set :database_file, "config/database.yml"
-    set :views, "app/views"
-    set :public_dir, "app/public"
-    set :root, File.dirname(__FILE__)
-
-    enable :sessions
-    set :session_secret, ENV["SECRET_TOKEN"] || SecureRandom.hex(32)
-    enable :logging
-    enable :method_override
-
-    ActiveRecord::Base.include_root_in_json = false
-  end
-
-  helpers do
-    include Sinatra::AuthenticationHelpers
-
-    def render_partial(name, locals = {})
-      erb "partials/_#{name}".to_sym, layout: false, locals: locals
-    end
-
-    def render_js_template(name)
-      erb "js/templates/_#{name}.js".to_sym, layout: false
-    end
-
-    def render_js(name, locals = {})
-      erb "js/#{name}.js".to_sym, layout: false, locals: locals
-    end
-
-    def t(*args)
-      I18n.t(*args)
-    end
-  end
-
-  before do
-    I18n.locale = ENV["LOCALE"].blank? ? :en : ENV["LOCALE"].to_sym
-
-    if !authenticated? && needs_authentication?(request.path)
-      session[:redirect_to] = request.fullpath
-      redirect "/login"
-    end
-  end
-
-  get "/" do
-    if UserRepository.setup_complete?
-      redirect to("/news")
-    else
-      redirect to("/setup/password")
-    end
-  end
-end
-
-require_relative "app/controllers/stories_controller"
-require_relative "app/controllers/first_run_controller"
-require_relative "app/controllers/sessions_controller"
-require_relative "app/controllers/feeds_controller"
-require_relative "app/controllers/debug_controller"
diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js
new file mode 100644
index 000000000..7d52bcf35
--- /dev/null
+++ b/app/assets/config/manifest.js
@@ -0,0 +1,3 @@
+//= link_tree ../images
+//= link_directory ../stylesheets .css
+//= link_directory ../javascripts .js
diff --git a/app/assets/images/.keep b/app/assets/images/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 7d2c83dfb..720a7efe7 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -11,6 +11,16 @@ _.templateSettings = {
   evaluate: /\{\{(.+?)\}\}/g
 };
 
+function CSRFToken() {
+  const tokenTag = document.getElementsByName('csrf-token')[0];
+
+  return (tokenTag && tokenTag.content) || '';
+}
+
+function requestHeaders() {
+  return { 'X-CSRF-Token': CSRFToken() };
+}
+
 var Story = Backbone.Model.extend({
   defaults: function() {
     return {
@@ -33,7 +43,7 @@ var Story = Backbone.Model.extend({
 
   open: function() {
     if (!this.get("keep_unread")) this.set("is_read", true);
-    if (this.shouldSave()) this.save();
+    if (this.shouldSave()) this.save(null, { headers: requestHeaders() });
 
     if(this.collection){
       this.collection.closeOthers(this);
@@ -54,7 +64,7 @@ var Story = Backbone.Model.extend({
       this.set("is_read", false);
     }
 
-    if (this.shouldSave()) this.save();
+    if (this.shouldSave()) this.save(null, { headers: requestHeaders() });
   },
 
   toggleStarred: function() {
@@ -64,7 +74,7 @@ var Story = Backbone.Model.extend({
       this.set("is_starred", true);
     }
 
-    if (this.shouldSave()) this.save();
+    if (this.shouldSave()) this.save(null, { headers: requestHeaders() });
   },
 
   close: function() {
@@ -156,7 +166,7 @@ var StoryView = Backbone.View.extend({
       if (backgroundTab) backgroundTab.blur();
       window.focus();
       if (!this.model.get("keep_unread")) this.model.set("is_read", true);
-      if (this.model.shouldSave()) this.model.save();
+      if (this.model.shouldSave()) this.model.save(null, { headers: requestHeaders() });
     } else {
       this.model.toggle();
       window.scrollTo(0, this.$el.offset().top);
@@ -324,23 +334,6 @@ var AppView = Backbone.View.extend({
 });
 
 $(document).ready(function() {
-  $(".remove-feed").click(function(e) {
-    e.preventDefault();
-    var $this = $(this);
-
-    var feedId = $this.parents("li").data("id");
-
-    if (feedId > 0) {
-      $.ajax({url: "/feeds/" + feedId, type: "DELETE"})
-       .success(function() {
-          $this.parents("li").fadeOut(500, function () {
-            $(this).remove();
-          });
-        })
-       .fail(function() { alert("something broke!"); });
-    }
-  });
-
   Mousetrap.bind("?", function() {
     $("#shortcuts").modal('toggle');
   });
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 83dc26c0b..e37f2a5b9 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -23,6 +23,10 @@ code {
   white-space: normal;
 }
 
+.warning {
+  background-color: #F2DEDE;
+}
+
 .container {
   width: 100%;
   max-width: 720px;
@@ -51,6 +55,23 @@ code {
   border-top: 7px solid #484948;
 }
 
+@media (max-width: 768px) {
+  #footer .row-fluid > * {
+    display: none;
+  }
+
+  #footer .row-fluid > .span6:first-child {
+    display: block;
+    width: 100%;
+    text-align: center;
+  }
+
+  #frame {
+    padding-top: 7px;
+    border-top: none;
+  }
+}
+
 .alert {
   text-align: center;
   -webkit-border-radius: 0px;
@@ -65,6 +86,11 @@ code {
   width: 18px;
 }
 
+#action-bar button.btn {
+  height: 34px;
+  width: 34px;
+}
+
 .btn {
   position: relative;
   top: 0px;
@@ -108,6 +134,13 @@ code {
   margin-bottom: 14px;
 }
 
+@media (max-width: 768px) {
+  #action-bar {
+    margin-left: 8px;
+    margin-right: 8px;
+  }
+}
+
 ul#story-list, ul#feed-list {
   list-style-type: none;
   margin-left: 0px;
@@ -176,14 +209,18 @@ li.story.open .story-preview {
 }
 
 .story-lead {
-  color: #e5e5e5;
+  color: #c5c5c5;
 }
 
 .story-published {
   margin-left: 20px;
 }
 
-.story-keep-unread, .story-starred  {
+.story-enclosure {
+  float: right;
+}
+
+.story-keep-unread, .story-starred {
   display: inline-block;
   cursor: pointer;
   -webkit-touch-callout: none;
@@ -251,7 +288,7 @@ li.story.open .story-body-container {
 }
 
 p.story-details {
-  margin-right: 5px;
+  margin-right: 14px;
   overflow: hidden;
 }
 
@@ -319,8 +356,16 @@ p.story-details {
   color: #7F8281;
 }
 
+nav {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
 li.feed .feed-line {
   cursor: default;
+  display: flex;
+  justify-content: space-between;
 }
 
 li.feed .feed-title {
@@ -369,13 +414,25 @@ li.feed .feed-last-updated {
   text-align: right;
 }
 
+@media (max-width: 768px) {
+  li.feed .feed-last-updated {
+    display: none;
+  }
+
+  li.feed .row-fluid .span2 {
+    float: right;
+    margin-right: 21px;
+  }
+}
+
 li.feed .last-updated {
   font-size: 10px;
 }
 
-li.feed .last-updated-time {
-  width: 100px;
-  display: inline-block;
+.feed-actions {
+  display: flex;
+  gap: 10px;
+  justify-content: space-between;
 }
 
 li.feed .edit-feed {
@@ -383,10 +440,6 @@ li.feed .edit-feed {
 }
 
 li.feed .edit-feed a {
-  text-align: right;
-  padding-left: 3px;
-  padding-right: 3px;
-  margin-left: 5px;
   color: #000;
 }
 
@@ -396,9 +449,6 @@ li.feed .remove-feed {
 
 li.feed .remove-feed a {
   text-align: center;
-  padding-left: 3px;
-  padding-right: 3px;
-  margin-left: 5px;
   color: #C0392B;
 }
 
@@ -436,8 +486,12 @@ li.feed .remove-feed a:hover {
   padding-top: 10px;
 }
 
+.setup__label {
+  font-weight: bold;
+}
+
 .setup {
-  width: 350px;
+  width: 500px;
   margin: 0 auto;
   padding-top: 100px;
 }
@@ -493,7 +547,7 @@ li.feed .remove-feed a:hover {
   transition: 0.25s;
 }
 
-.setup #password, .setup #password-confirmation, .setup #feed-url, .setup #feed-name, .setup input.select-dummy {
+.setup .form-control {
   padding-left: 100px;
   padding-right: 36px;
 }
diff --git a/app/commands/cast_boolean.rb b/app/commands/cast_boolean.rb
new file mode 100644
index 000000000..7c0c59ae5
--- /dev/null
+++ b/app/commands/cast_boolean.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module CastBoolean
+  TRUE_VALUES = Set.new(["true", true, "1"]).freeze
+  FALSE_VALUES = Set.new(["false", false, "0"]).freeze
+
+  def self.call(boolean)
+    if (TRUE_VALUES + FALSE_VALUES).exclude?(boolean)
+      raise(ArgumentError, "cannot cast to boolean: #{boolean.inspect}")
+    end
+
+    TRUE_VALUES.include?(boolean)
+  end
+end
diff --git a/app/commands/feed/create.rb b/app/commands/feed/create.rb
new file mode 100644
index 000000000..ca4cc50dd
--- /dev/null
+++ b/app/commands/feed/create.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Feed::Create
+  def self.call(url, user:)
+    result = FeedDiscovery.call(url)
+    return false unless result
+
+    name = ContentSanitizer.call(result.title.presence || result.feed_url)
+
+    Feed.create(name:, user:, url: result.feed_url, last_fetched: 1.day.ago)
+  end
+end
diff --git a/app/commands/feed/export_to_opml.rb b/app/commands/feed/export_to_opml.rb
new file mode 100644
index 000000000..bc913d0e1
--- /dev/null
+++ b/app/commands/feed/export_to_opml.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Feed::ExportToOpml
+  class << self
+    def call(feeds)
+      builder =
+        Nokogiri::XML::Builder.new do |xml|
+          xml.opml(version: "1.0") do
+            xml.head { xml.title("Feeds from Stringer") }
+            xml.body { feeds.each { |feed| feed_outline(xml, feed) } }
+          end
+        end
+
+      builder.to_xml
+    end
+
+    private
+
+    def feed_outline(xml, feed)
+      xml.outline(
+        text: feed.name,
+        title: feed.name,
+        type: "rss",
+        xmlUrl: feed.url
+      )
+    end
+  end
+end
diff --git a/app/commands/feed/fetch_all.rb b/app/commands/feed/fetch_all.rb
new file mode 100644
index 000000000..c1f140cef
--- /dev/null
+++ b/app/commands/feed/fetch_all.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require "thread/pool"
+
+module Feed::FetchAll
+  def self.call
+    pool = Thread.pool(10)
+
+    Feed.find_each { |feed| pool.process { Feed::FetchOne.call(feed) } }
+
+    pool.shutdown
+  end
+end
diff --git a/app/commands/feed/fetch_all_for_user.rb b/app/commands/feed/fetch_all_for_user.rb
new file mode 100644
index 000000000..65bd42427
--- /dev/null
+++ b/app/commands/feed/fetch_all_for_user.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require "thread/pool"
+
+module Feed::FetchAllForUser
+  def self.call(user)
+    pool = Thread.pool(10)
+
+    user.feeds.find_each { |feed| pool.process { Feed::FetchOne.call(feed) } }
+
+    pool.shutdown
+  end
+end
diff --git a/app/commands/feed/fetch_one.rb b/app/commands/feed/fetch_one.rb
new file mode 100644
index 000000000..6d71b5f49
--- /dev/null
+++ b/app/commands/feed/fetch_one.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Feed::FetchOne
+  class << self
+    def call(feed)
+      raw_feed = fetch_raw_feed(feed)
+
+      new_entries_from(feed, raw_feed).each do |entry|
+        StoryRepository.add(entry, feed)
+      end
+
+      FeedRepository.update_last_fetched(feed, raw_feed.last_modified)
+
+      FeedRepository.set_status(:green, feed)
+    rescue StandardError => e
+      FeedRepository.set_status(:red, feed)
+
+      Rails.logger.error("Something went wrong when parsing #{feed.url}: #{e}")
+    end
+
+    private
+
+    def fetch_raw_feed(feed)
+      response = HTTParty.get(feed.url).to_s
+      Feedjira.parse(response)
+    end
+
+    def new_entries_from(feed, raw_feed)
+      Feed::FindNewStories.call(raw_feed, feed.id, latest_entry_id(feed))
+    end
+
+    def latest_entry_id(feed)
+      feed.stories.first.entry_id unless feed.stories.empty?
+    end
+  end
+end
diff --git a/app/commands/feed/find_new_stories.rb b/app/commands/feed/find_new_stories.rb
new file mode 100644
index 000000000..e46ea9ca1
--- /dev/null
+++ b/app/commands/feed/find_new_stories.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Feed::FindNewStories
+  STORY_AGE_THRESHOLD_DAYS = 3
+
+  def self.call(raw_feed, feed_id, latest_entry_id = nil)
+    stories = []
+
+    raw_feed.entries.each do |story|
+      break if latest_entry_id && story.id == latest_entry_id
+      next if story_age_exceeds_threshold?(story) || StoryRepository.exists?(
+        story.id,
+        feed_id
+      )
+
+      stories << story
+    end
+
+    stories
+  end
+
+  def self.story_age_exceeds_threshold?(story)
+    max_age = Time.now - STORY_AGE_THRESHOLD_DAYS.days
+    story.published && story.published < max_age
+  end
+end
diff --git a/app/commands/feed/import_from_opml.rb b/app/commands/feed/import_from_opml.rb
new file mode 100644
index 000000000..16d23faf3
--- /dev/null
+++ b/app/commands/feed/import_from_opml.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Feed::ImportFromOpml
+  class << self
+    def call(opml_contents, user:)
+      feeds_with_groups = OpmlParser.call(opml_contents)
+
+      # It considers a situation when feeds are already imported without
+      # groups, so it's possible to re-import the same subscriptions.xml just
+      # to set group_id for existing feeds. Feeds without groups are in
+      # 'Ungrouped' group, we don't create such group and create such feeds
+      # with group_id = nil.
+      feeds_with_groups.each do |group_name, parsed_feeds|
+        group = find_or_create_group(group_name, user)
+
+        parsed_feeds.each do |parsed_feed|
+          create_feed(parsed_feed, group, user)
+        end
+      end
+    end
+
+    private
+
+    def find_or_create_group(group_name, user)
+      return if group_name == "Ungrouped"
+
+      user.groups.create_or_find_by(name: group_name)
+    end
+
+    def create_feed(parsed_feed, group, user)
+      feed = user.feeds.where(**parsed_feed.slice(:name, :url))
+                 .first_or_initialize
+      find_feed_name(feed, parsed_feed)
+      feed.last_fetched = 1.day.ago if feed.new_record?
+      feed.group_id = group.id if group
+      feed.save
+    end
+
+    def find_feed_name(feed, parsed_feed)
+      return if feed.name?
+
+      result = FeedDiscovery.call(parsed_feed[:url])
+      title = result.title if result
+      feed.name = ContentSanitizer.call(title.presence || parsed_feed[:url])
+    end
+  end
+end
diff --git a/app/commands/feeds/add_new_feed.rb b/app/commands/feeds/add_new_feed.rb
deleted file mode 100644
index 1334e1aed..000000000
--- a/app/commands/feeds/add_new_feed.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-require_relative "../../models/feed"
-require_relative "../../utils/feed_discovery"
-
-class AddNewFeed
-  ONE_DAY = 24 * 60 * 60
-
-  def self.add(url, discoverer = FeedDiscovery.new, repo = Feed)
-    result = discoverer.discover(url)
-    return false unless result
-
-    repo.create(name: result.title,
-                url: result.feed_url,
-                last_fetched: Time.now - ONE_DAY)
-  end
-end
diff --git a/app/commands/feeds/export_to_opml.rb b/app/commands/feeds/export_to_opml.rb
deleted file mode 100644
index 707274e60..000000000
--- a/app/commands/feeds/export_to_opml.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-require "nokogiri"
-
-class ExportToOpml
-  def initialize(feeds)
-    @feeds = feeds
-  end
-
-  def to_xml # rubocop:disable Metrics/MethodLength
-    builder = Nokogiri::XML::Builder.new do |xml|
-      xml.opml(version: "1.0") do
-        xml.head do
-          xml.title "Feeds from Stringer"
-        end
-        xml.body do
-          @feeds.each do |feed|
-            xml.outline(
-              text: feed.name,
-              title: feed.name,
-              type: "rss",
-              xmlUrl: feed.url
-            )
-          end
-        end
-      end
-    end
-
-    builder.to_xml
-  end
-end
diff --git a/app/commands/feeds/find_new_stories.rb b/app/commands/feeds/find_new_stories.rb
deleted file mode 100644
index e8bd10678..000000000
--- a/app/commands/feeds/find_new_stories.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-require_relative "../../repositories/story_repository"
-
-class FindNewStories
-  STORY_AGE_THRESHOLD_DAYS = 3
-
-  def initialize(raw_feed, feed_id, last_fetched, latest_entry_id = nil)
-    @raw_feed = raw_feed
-    @feed_id = feed_id
-    @last_fetched = last_fetched
-    @latest_entry_id = latest_entry_id
-  end
-
-  def new_stories
-    stories = []
-
-    @raw_feed.entries.each do |story|
-      break if @latest_entry_id && story.id == @latest_entry_id
-      next if story_age_exceeds_threshold?(story) || StoryRepository.exists?(story.id, @feed_id)
-      stories << story
-    end
-
-    stories
-  end
-
-  private
-
-  def story_age_exceeds_threshold?(story)
-    max_age = Time.now - STORY_AGE_THRESHOLD_DAYS.days
-    story.published && story.published < max_age
-  end
-end
diff --git a/app/commands/feeds/import_from_opml.rb b/app/commands/feeds/import_from_opml.rb
deleted file mode 100644
index 9fb6ae47b..000000000
--- a/app/commands/feeds/import_from_opml.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-require_relative "../../models/feed"
-require_relative "../../models/group"
-require_relative "../../utils/opml_parser"
-
-class ImportFromOpml
-  ONE_DAY = 24 * 60 * 60
-
-  class << self
-    def import(opml_contents)
-      feeds_with_groups = OpmlParser.new.parse_feeds(opml_contents)
-
-      # It considers a situation when feeds are already imported without groups,
-      # so it's possible to re-import the same subscriptions.xml just to set group_id
-      # for existing feeds. Feeds without groups are in 'Ungrouped' group, we don't
-      # create such group and create such feeds with group_id = nil.
-      feeds_with_groups.each do |group_name, parsed_feeds|
-        next if parsed_feeds.empty?
-
-        group = Group.where(name: group_name).first_or_create unless group_name == "Ungrouped"
-
-        parsed_feeds.each { |parsed_feed| create_feed(parsed_feed, group) }
-      end
-    end
-
-    private
-
-    def create_feed(parsed_feed, group)
-      feed = Feed.where(name: parsed_feed[:name], url: parsed_feed[:url]).first_or_initialize
-      feed.last_fetched = Time.now - ONE_DAY if feed.new_record?
-      feed.group_id = group.id if group
-      feed.save
-    end
-  end
-end
diff --git a/app/commands/fever_api.rb b/app/commands/fever_api.rb
new file mode 100644
index 000000000..bcb975d3f
--- /dev/null
+++ b/app/commands/fever_api.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module FeverAPI
+  API_VERSION = 3
+
+  PARAMS = [
+    :as,
+    :before,
+    :favicons,
+    :feeds,
+    :groups,
+    :id,
+    :items,
+    :links,
+    :mark,
+    :saved_item_ids,
+    :since_id,
+    :unread_item_ids,
+    :with_ids
+  ].freeze
+end
diff --git a/app/commands/fever_api/authentication.rb b/app/commands/fever_api/authentication.rb
new file mode 100644
index 000000000..1ff89b28f
--- /dev/null
+++ b/app/commands/fever_api/authentication.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module FeverAPI::Authentication
+  def self.call(authorization:, **_params)
+    feeds = authorization.scope(Feed)
+    last_refreshed_on_time = (feeds.maximum(:last_fetched) || 0).to_i
+
+    { auth: 1, last_refreshed_on_time: }
+  end
+end
diff --git a/app/commands/fever_api/read_favicons.rb b/app/commands/fever_api/read_favicons.rb
new file mode 100644
index 000000000..2227d9376
--- /dev/null
+++ b/app/commands/fever_api/read_favicons.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class FeverAPI::ReadFavicons
+  ICON = "R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
+
+  class << self
+    def call(params)
+      if params.key?(:favicons)
+        { favicons: }
+      else
+        {}
+      end
+    end
+
+    private
+
+    def favicons
+      [
+        {
+          id: 0,
+          data: "image/gif;base64,#{ICON}"
+        }
+      ]
+    end
+  end
+end
diff --git a/app/commands/fever_api/read_feeds.rb b/app/commands/fever_api/read_feeds.rb
new file mode 100644
index 000000000..2cf674999
--- /dev/null
+++ b/app/commands/fever_api/read_feeds.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module FeverAPI::ReadFeeds
+  class << self
+    def call(authorization:, **params)
+      if params.key?(:feeds)
+        { feeds: feeds(authorization) }
+      else
+        {}
+      end
+    end
+
+    private
+
+    def feeds(authorization)
+      authorization.scope(FeedRepository.list).map(&:as_fever_json)
+    end
+  end
+end
diff --git a/app/commands/fever_api/read_feeds_groups.rb b/app/commands/fever_api/read_feeds_groups.rb
new file mode 100644
index 000000000..c751ac9aa
--- /dev/null
+++ b/app/commands/fever_api/read_feeds_groups.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class FeverAPI::ReadFeedsGroups
+  class << self
+    def call(authorization:, **params)
+      if params.key?(:feeds) || params.key?(:groups)
+        { feeds_groups: feeds_groups(authorization) }
+      else
+        {}
+      end
+    end
+
+    private
+
+    def feeds_groups(authorization)
+      scoped_feeds = authorization.scope(FeedRepository.list)
+      grouped_feeds = scoped_feeds.order("LOWER(name)").group_by(&:group_id)
+      grouped_feeds.map do |group_id, feeds|
+        group_id ||= Group::UNGROUPED.id
+
+        {
+          group_id:,
+          feed_ids: feeds.map(&:id).join(",")
+        }
+      end
+    end
+  end
+end
diff --git a/app/commands/fever_api/read_groups.rb b/app/commands/fever_api/read_groups.rb
new file mode 100644
index 000000000..8fe316f10
--- /dev/null
+++ b/app/commands/fever_api/read_groups.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module FeverAPI::ReadGroups
+  class << self
+    def call(authorization:, **params)
+      if params.key?(:groups)
+        { groups: groups(authorization) }
+      else
+        {}
+      end
+    end
+
+    private
+
+    def groups(authorization)
+      [Group::UNGROUPED, *authorization.scope(GroupRepository.list)]
+        .map(&:as_fever_json)
+    end
+  end
+end
diff --git a/app/commands/fever_api/read_items.rb b/app/commands/fever_api/read_items.rb
new file mode 100644
index 000000000..eca1d15c7
--- /dev/null
+++ b/app/commands/fever_api/read_items.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module FeverAPI::ReadItems
+  class << self
+    def call(authorization:, **params)
+      return {} unless params.key?(:items)
+
+      item_ids = params[:with_ids].split(",") if params.key?(:with_ids)
+
+      {
+        items: items(item_ids, params[:since_id], authorization),
+        total_items: total_items(item_ids, authorization)
+      }
+    end
+
+    private
+
+    def items(item_ids, since_id, authorization)
+      items =
+        if item_ids
+          stories_by_ids(item_ids, authorization)
+        else
+          unread_stories(since_id, authorization)
+        end
+      items.order(:published, :id).map(&:as_fever_json)
+    end
+
+    def total_items(item_ids, authorization)
+      items =
+        if item_ids
+          stories_by_ids(item_ids, authorization)
+        else
+          unread_stories(nil, authorization)
+        end
+      items.count
+    end
+
+    def stories_by_ids(ids, authorization)
+      authorization.scope(StoryRepository.fetch_by_ids(ids))
+    end
+
+    def unread_stories(since_id, authorization)
+      if since_id.present?
+        authorization.scope(StoryRepository.unread_since_id(since_id))
+      else
+        authorization.scope(StoryRepository.unread)
+      end
+    end
+  end
+end
diff --git a/app/commands/fever_api/read_links.rb b/app/commands/fever_api/read_links.rb
new file mode 100644
index 000000000..db5c0fbaf
--- /dev/null
+++ b/app/commands/fever_api/read_links.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module FeverAPI::ReadLinks
+  def self.call(params)
+    if params.key?(:links)
+      { links: [] }
+    else
+      {}
+    end
+  end
+end
diff --git a/app/commands/fever_api/response.rb b/app/commands/fever_api/response.rb
new file mode 100644
index 000000000..55c7e699e
--- /dev/null
+++ b/app/commands/fever_api/response.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module FeverAPI::Response
+  ACTIONS = [
+    FeverAPI::Authentication,
+    FeverAPI::ReadFeeds,
+    FeverAPI::ReadGroups,
+    FeverAPI::ReadFeedsGroups,
+    FeverAPI::ReadFavicons,
+    FeverAPI::ReadItems,
+    FeverAPI::ReadLinks,
+    FeverAPI::SyncUnreadItemIds,
+    FeverAPI::SyncSavedItemIds,
+    FeverAPI::WriteMarkItem,
+    FeverAPI::WriteMarkFeed,
+    FeverAPI::WriteMarkGroup
+  ].freeze
+
+  def self.call(params)
+    result = { api_version: FeverAPI::API_VERSION }
+    ACTIONS.each { |action| result.merge!(action.call(**params)) }
+    result.to_json
+  end
+end
diff --git a/app/commands/fever_api/sync_saved_item_ids.rb b/app/commands/fever_api/sync_saved_item_ids.rb
new file mode 100644
index 000000000..0a0a160a7
--- /dev/null
+++ b/app/commands/fever_api/sync_saved_item_ids.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module FeverAPI::SyncSavedItemIds
+  class << self
+    def call(authorization:, **params)
+      if params.key?(:saved_item_ids)
+        { saved_item_ids: saved_item_ids(authorization) }
+      else
+        {}
+      end
+    end
+
+    private
+
+    def saved_item_ids(authorization)
+      all_starred_stories(authorization).map(&:id).join(",")
+    end
+
+    def all_starred_stories(authorization)
+      authorization.scope(StoryRepository.all_starred)
+    end
+  end
+end
diff --git a/app/commands/fever_api/sync_unread_item_ids.rb b/app/commands/fever_api/sync_unread_item_ids.rb
new file mode 100644
index 000000000..9f6351d2e
--- /dev/null
+++ b/app/commands/fever_api/sync_unread_item_ids.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module FeverAPI::SyncUnreadItemIds
+  class << self
+    def call(authorization:, **params)
+      if params.key?(:unread_item_ids)
+        { unread_item_ids: unread_item_ids(authorization) }
+      else
+        {}
+      end
+    end
+
+    private
+
+    def unread_item_ids(authorization)
+      authorization.scope(unread_stories).map(&:id).join(",")
+    end
+
+    def unread_stories
+      StoryRepository.unread
+    end
+  end
+end
diff --git a/app/commands/fever_api/write_mark_feed.rb b/app/commands/fever_api/write_mark_feed.rb
new file mode 100644
index 000000000..d01147078
--- /dev/null
+++ b/app/commands/fever_api/write_mark_feed.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module FeverAPI::WriteMarkFeed
+  def self.call(authorization:, **params)
+    if params[:mark] == "feed"
+      authorization.check(Feed.find(params[:id]))
+      MarkFeedAsRead.call(params[:id], params[:before])
+    end
+
+    {}
+  end
+end
diff --git a/app/commands/fever_api/write_mark_group.rb b/app/commands/fever_api/write_mark_group.rb
new file mode 100644
index 000000000..bf285a820
--- /dev/null
+++ b/app/commands/fever_api/write_mark_group.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module FeverAPI::WriteMarkGroup
+  def self.call(authorization:, **params)
+    if params[:mark] == "group"
+      authorization.check(Group.find(params[:id]))
+      MarkGroupAsRead.call(params[:id], params[:before])
+    end
+
+    {}
+  end
+end
diff --git a/app/commands/fever_api/write_mark_item.rb b/app/commands/fever_api/write_mark_item.rb
new file mode 100644
index 000000000..9550ace4b
--- /dev/null
+++ b/app/commands/fever_api/write_mark_item.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module FeverAPI::WriteMarkItem
+  class << self
+    def call(authorization:, **params)
+      if params[:mark] == "item"
+        authorization.check(Story.find(params[:id])) if params.key?(:id)
+        mark_item_as(params[:id], params[:as])
+      end
+
+      {}
+    end
+
+    private
+
+    def mark_item_as(id, mark_as)
+      case mark_as
+      when "read"
+        MarkAsRead.call(id)
+      when "unread"
+        MarkAsUnread.call(id)
+      when "saved"
+        MarkAsStarred.call(id)
+      when "unsaved"
+        MarkAsUnstarred.call(id)
+      end
+    end
+  end
+end
diff --git a/app/commands/stories/mark_all_as_read.rb b/app/commands/stories/mark_all_as_read.rb
deleted file mode 100644
index e458c1f02..000000000
--- a/app/commands/stories/mark_all_as_read.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-require_relative "../../repositories/story_repository"
-
-class MarkAllAsRead
-  def initialize(story_ids, repository = StoryRepository)
-    @story_ids = story_ids
-    @repo = repository
-  end
-
-  def mark_as_read
-    @repo.fetch_by_ids(@story_ids).update_all(is_read: true)
-  end
-end
diff --git a/app/commands/stories/mark_as_read.rb b/app/commands/stories/mark_as_read.rb
deleted file mode 100644
index 93fb679ae..000000000
--- a/app/commands/stories/mark_as_read.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-require_relative "../../repositories/story_repository"
-
-class MarkAsRead
-  def initialize(story_id, repository = StoryRepository)
-    @story_id = story_id
-    @repo = repository
-  end
-
-  def mark_as_read
-    @repo.fetch(@story_id).update_attributes(is_read: true)
-  end
-end
diff --git a/app/commands/stories/mark_as_starred.rb b/app/commands/stories/mark_as_starred.rb
deleted file mode 100644
index aff11e67f..000000000
--- a/app/commands/stories/mark_as_starred.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-require_relative "../../repositories/story_repository"
-
-class MarkAsStarred
-  def initialize(story_id, repository = StoryRepository)
-    @story_id = story_id
-    @repo = repository
-  end
-
-  def mark_as_starred
-    @repo.fetch(@story_id).update_attributes(is_starred: true)
-  end
-end
diff --git a/app/commands/stories/mark_as_unread.rb b/app/commands/stories/mark_as_unread.rb
deleted file mode 100644
index d1d90fb3c..000000000
--- a/app/commands/stories/mark_as_unread.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-require_relative "../../repositories/story_repository"
-
-class MarkAsUnread
-  def initialize(story_id, repository = StoryRepository)
-    @story_id = story_id
-    @repo = repository
-  end
-
-  def mark_as_unread
-    @repo.fetch(@story_id).update_attributes(is_read: false)
-  end
-end
diff --git a/app/commands/stories/mark_as_unstarred.rb b/app/commands/stories/mark_as_unstarred.rb
deleted file mode 100644
index f6e6afe9c..000000000
--- a/app/commands/stories/mark_as_unstarred.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-require_relative "../../repositories/story_repository"
-
-class MarkAsUnstarred
-  def initialize(story_id, repository = StoryRepository)
-    @story_id = story_id
-    @repo = repository
-  end
-
-  def mark_as_unstarred
-    @repo.fetch(@story_id).update_attributes(is_starred: false)
-  end
-end
diff --git a/app/commands/stories/mark_feed_as_read.rb b/app/commands/stories/mark_feed_as_read.rb
deleted file mode 100644
index 035f50e1d..000000000
--- a/app/commands/stories/mark_feed_as_read.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-require_relative "../../repositories/story_repository"
-
-class MarkFeedAsRead
-  def initialize(feed_id, timestamp, repository = StoryRepository)
-    @feed_id   = feed_id.to_i
-    @repo      = repository
-    @timestamp = timestamp
-  end
-
-  def mark_feed_as_read
-    @repo.fetch_unread_for_feed_by_timestamp(@feed_id, @timestamp).update_all(is_read: true)
-  end
-end
diff --git a/app/commands/stories/mark_group_as_read.rb b/app/commands/stories/mark_group_as_read.rb
deleted file mode 100644
index 286b0821c..000000000
--- a/app/commands/stories/mark_group_as_read.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require_relative "../../repositories/story_repository"
-
-class MarkGroupAsRead
-  KINDLING_GROUP_ID = 0
-  SPARKS_GROUP_ID   = -1
-
-  def initialize(group_id, timestamp, repository = StoryRepository)
-    @group_id  = group_id
-    @repo      = repository
-    @timestamp = timestamp
-  end
-
-  def mark_group_as_read
-    return unless @group_id
-
-    if [KINDLING_GROUP_ID, SPARKS_GROUP_ID].include?(@group_id.to_i)
-      @repo.fetch_unread_by_timestamp(@timestamp).update_all(is_read: true)
-    elsif @group_id.to_i > 0
-      @repo.fetch_unread_by_timestamp_and_group(@timestamp, @group_id).update_all(is_read: true)
-    end
-  end
-end
diff --git a/app/commands/story/mark_all_as_read.rb b/app/commands/story/mark_all_as_read.rb
new file mode 100644
index 000000000..c20e49518
--- /dev/null
+++ b/app/commands/story/mark_all_as_read.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module MarkAllAsRead
+  def self.call(story_ids)
+    StoryRepository.fetch_by_ids(story_ids).update_all(is_read: true)
+  end
+end
diff --git a/app/commands/story/mark_as_read.rb b/app/commands/story/mark_as_read.rb
new file mode 100644
index 000000000..27f7ab751
--- /dev/null
+++ b/app/commands/story/mark_as_read.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module MarkAsRead
+  def self.call(story_id)
+    StoryRepository.fetch(story_id).update!(is_read: true)
+  end
+end
diff --git a/app/commands/story/mark_as_starred.rb b/app/commands/story/mark_as_starred.rb
new file mode 100644
index 000000000..aae2198a5
--- /dev/null
+++ b/app/commands/story/mark_as_starred.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module MarkAsStarred
+  def self.call(story_id)
+    StoryRepository.fetch(story_id).update!(is_starred: true)
+  end
+end
diff --git a/app/commands/story/mark_as_unread.rb b/app/commands/story/mark_as_unread.rb
new file mode 100644
index 000000000..cf72798a9
--- /dev/null
+++ b/app/commands/story/mark_as_unread.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module MarkAsUnread
+  def self.call(story_id)
+    StoryRepository.fetch(story_id).update!(is_read: false)
+  end
+end
diff --git a/app/commands/story/mark_as_unstarred.rb b/app/commands/story/mark_as_unstarred.rb
new file mode 100644
index 000000000..144abc19c
--- /dev/null
+++ b/app/commands/story/mark_as_unstarred.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module MarkAsUnstarred
+  def self.call(story_id)
+    StoryRepository.fetch(story_id).update!(is_starred: false)
+  end
+end
diff --git a/app/commands/story/mark_feed_as_read.rb b/app/commands/story/mark_feed_as_read.rb
new file mode 100644
index 000000000..de3afa936
--- /dev/null
+++ b/app/commands/story/mark_feed_as_read.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module MarkFeedAsRead
+  def self.call(feed_id, timestamp)
+    StoryRepository
+      .fetch_unread_for_feed_by_timestamp(feed_id, timestamp)
+      .update_all(is_read: true)
+  end
+end
diff --git a/app/commands/story/mark_group_as_read.rb b/app/commands/story/mark_group_as_read.rb
new file mode 100644
index 000000000..9104c6238
--- /dev/null
+++ b/app/commands/story/mark_group_as_read.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module MarkGroupAsRead
+  KINDLING_GROUP_ID = 0
+  SPARKS_GROUP_ID   = -1
+
+  def self.call(group_id, timestamp)
+    return unless group_id
+
+    if [KINDLING_GROUP_ID, SPARKS_GROUP_ID].include?(group_id.to_i)
+      StoryRepository
+        .fetch_unread_by_timestamp(timestamp).update_all(is_read: true)
+    else
+      StoryRepository
+        .fetch_unread_by_timestamp_and_group(timestamp, group_id)
+        .update_all(is_read: true)
+    end
+  end
+end
diff --git a/app/commands/user/sign_in_user.rb b/app/commands/user/sign_in_user.rb
new file mode 100644
index 000000000..021ec10b6
--- /dev/null
+++ b/app/commands/user/sign_in_user.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module SignInUser
+  def self.call(username, submitted_password)
+    user = User.find_by(username:)
+    return unless user
+
+    user_password = BCrypt::Password.new(user.password_digest)
+
+    user if user_password == submitted_password
+  end
+end
diff --git a/app/commands/users/change_user_password.rb b/app/commands/users/change_user_password.rb
deleted file mode 100644
index a818aea13..000000000
--- a/app/commands/users/change_user_password.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-require_relative "../../repositories/user_repository"
-require_relative "../../utils/api_key"
-
-class ChangeUserPassword
-  def initialize(repository = UserRepository)
-    @repo = repository
-  end
-
-  def change_user_password(new_password)
-    user = @repo.first
-    user.password = user.password_confirmation = new_password
-    user.api_key = ApiKey.compute(new_password)
-
-    @repo.save(user)
-    user
-  end
-end
diff --git a/app/commands/users/complete_setup.rb b/app/commands/users/complete_setup.rb
deleted file mode 100644
index e37cb80bf..000000000
--- a/app/commands/users/complete_setup.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class CompleteSetup
-  def self.complete(user)
-    user.setup_complete = true
-    user.save
-    user
-  end
-end
diff --git a/app/commands/users/create_user.rb b/app/commands/users/create_user.rb
deleted file mode 100644
index d3de6fe27..000000000
--- a/app/commands/users/create_user.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-require_relative "../../utils/api_key"
-
-class CreateUser
-  def initialize(repository = User)
-    @repo = repository
-  end
-
-  def create(password)
-    @repo.delete_all
-    @repo.create(password: password,
-                 password_confirmation: password,
-                 setup_complete: false,
-                 api_key: ApiKey.compute(password))
-  end
-end
diff --git a/app/commands/users/sign_in_user.rb b/app/commands/users/sign_in_user.rb
deleted file mode 100644
index f41ad2e98..000000000
--- a/app/commands/users/sign_in_user.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require_relative "../../models/user"
-
-class SignInUser
-  def self.sign_in(submitted_password, repository = User)
-    user = repository.first
-    user_password = BCrypt::Password.new(user.password_digest)
-
-    user if user_password == submitted_password
-  end
-end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
new file mode 100644
index 000000000..2520b577b
--- /dev/null
+++ b/app/controllers/application_controller.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+class ApplicationController < ActionController::Base
+  before_action :complete_setup
+  before_action :authenticate_user
+  after_action -> { authorization.verify }
+
+  private
+
+  def authorization
+    @authorization ||= Authorization.new(current_user)
+  end
+
+  def complete_setup
+    redirect_to("/setup/password") unless UserRepository.setup_complete?
+  end
+
+  def authenticate_user
+    return if current_user
+
+    session[:redirect_to] = request.fullpath
+    redirect_to("/login")
+  end
+
+  def current_user
+    @current_user ||= UserRepository.fetch(session[:user_id])
+  end
+  helper_method :current_user
+end
diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/controllers/debug_controller.rb b/app/controllers/debug_controller.rb
index f38594201..bc87c11bb 100644
--- a/app/controllers/debug_controller.rb
+++ b/app/controllers/debug_controller.rb
@@ -1,14 +1,20 @@
-require_relative "../models/migration_status"
+# frozen_string_literal: true
 
-class Stringer < Sinatra::Base
-  get "/debug" do
-    erb :debug, locals: {
-      queued_jobs_count: Delayed::Job.count,
-      pending_migrations: MigrationStatus.new.pending_migrations
-    }
+class DebugController < ApplicationController
+  skip_before_action :complete_setup, only: [:heroku]
+  skip_before_action :authenticate_user, only: [:heroku]
+
+  def index
+    authorization.skip
+    render(
+      locals: {
+        queued_jobs_count: GoodJob::Job.queued.count,
+        pending_migrations: MigrationStatus.call
+      }
+    )
   end
 
-  get "/heroku" do
-    erb :heroku
+  def heroku
+    authorization.skip
   end
 end
diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb
new file mode 100644
index 000000000..6faa78f0c
--- /dev/null
+++ b/app/controllers/exports_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ExportsController < ApplicationController
+  def index
+    xml = Feed::ExportToOpml.call(authorization.scope(Feed.all))
+
+    send_data(
+      xml,
+      type: "application/xml",
+      disposition: "attachment",
+      filename: "stringer.opml"
+    )
+  end
+end
diff --git a/app/controllers/feeds_controller.rb b/app/controllers/feeds_controller.rb
index 0cbb0edb7..b54de2d4e 100644
--- a/app/controllers/feeds_controller.rb
+++ b/app/controllers/feeds_controller.rb
@@ -1,72 +1,64 @@
-require_relative "../repositories/feed_repository"
-require_relative "../commands/feeds/add_new_feed"
-require_relative "../commands/feeds/export_to_opml"
+# frozen_string_literal: true
 
-class Stringer < Sinatra::Base
-  get "/feeds" do
-    @feeds = FeedRepository.list
-
-    erb :'feeds/index'
-  end
-
-  get "/feeds/:id/edit" do
-    @feed = FeedRepository.fetch(params[:id])
-
-    erb :'feeds/edit'
+class FeedsController < ApplicationController
+  def index
+    @feeds = authorization.scope(FeedRepository.list.with_unread_stories_counts)
   end
 
-  put "/feeds/:id" do
-    feed = FeedRepository.fetch(params[:id])
-
-    FeedRepository.update_feed(feed, params[:feed_name], params[:feed_url], params[:group_id])
+  def show
+    @feed = FeedRepository.fetch(params[:feed_id])
+    authorization.check(@feed)
 
-    flash[:success] = t("feeds.edit.flash.updated_successfully")
-    redirect to("/feeds")
+    @stories = StoryRepository.feed(params[:feed_id])
+    @unread_stories = @stories.reject(&:is_read)
   end
 
-  delete "/feeds/:feed_id" do
-    FeedRepository.delete(params[:feed_id])
-
-    status 200
+  def new
+    authorization.skip
+    @feed_url = params[:feed_url]
   end
 
-  get "/feeds/new" do
-    @feed_url = params[:feed_url]
-    erb :'feeds/add'
+  def edit
+    @feed = FeedRepository.fetch(params[:id])
+    authorization.check(@feed)
   end
 
-  post "/feeds" do
+  def create
+    authorization.skip
     @feed_url = params[:feed_url]
-    feed = AddNewFeed.add(@feed_url)
+    feed = Feed::Create.call(@feed_url, user: current_user)
 
     if feed && feed.valid?
-      FetchFeeds.enqueue([feed])
+      CallableJob.perform_later(Feed::FetchOne, feed)
 
-      flash[:success] = t("feeds.add.flash.added_successfully")
-      redirect to("/")
-    elsif feed
-      flash.now[:error] = t("feeds.add.flash.already_subscribed_error")
-      erb :'feeds/add'
+      redirect_to("/", flash: { success: t(".success") })
     else
-      flash.now[:error] = t("feeds.add.flash.feed_not_found_error")
-      erb :'feeds/add'
+      flash.now[:error] = feed ? t(".already_subscribed") : t(".feed_not_found")
+
+      render(:new)
     end
   end
 
-  get "/feeds/import" do
-    erb :'feeds/import'
-  end
+  def update
+    feed = FeedRepository.fetch(params[:id])
+    authorization.check(feed)
 
-  post "/feeds/import" do
-    ImportFromOpml.import(params["opml_file"][:tempfile].read)
+    FeedRepository.update_feed(
+      feed,
+      params[:feed_name],
+      params[:feed_url],
+      params[:group_id]
+    )
 
-    redirect to("/setup/tutorial")
+    flash[:success] = t("feeds.edit.flash.updated_successfully")
+    redirect_to("/feeds")
   end
 
-  get "/feeds/export" do
-    content_type "application/xml"
-    attachment "stringer.opml"
+  def destroy
+    authorization.check(Feed.find(params[:id]))
+    FeedRepository.delete(params[:id])
 
-    ExportToOpml.new(Feed.all).to_xml
+    flash[:success] = t(".success")
+    redirect_to("/feeds")
   end
 end
diff --git a/app/controllers/fever_controller.rb b/app/controllers/fever_controller.rb
new file mode 100644
index 000000000..bc3b59adc
--- /dev/null
+++ b/app/controllers/fever_controller.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+class FeverController < ApplicationController
+  skip_before_action :complete_setup, only: [:index, :update]
+  protect_from_forgery with: :null_session, only: [:update]
+
+  def index
+    authorization.skip
+    render(json: FeverAPI::Response.call(fever_params))
+  end
+
+  def update
+    authorization.skip
+    render(json: FeverAPI::Response.call(fever_params))
+  end
+
+  private
+
+  def fever_params
+    params.permit(FeverAPI::PARAMS).to_hash.symbolize_keys.merge(authorization:)
+  end
+
+  def authenticate_user
+    return if current_user
+
+    render(json: { api_version: FeverAPI::API_VERSION, auth: 0 })
+  end
+
+  def current_user
+    if instance_variable_defined?(:@current_user)
+      @current_user
+    else
+      @current_user = User.find_by(api_key: params[:api_key])
+    end
+  end
+end
diff --git a/app/controllers/first_run_controller.rb b/app/controllers/first_run_controller.rb
deleted file mode 100644
index 6be12f769..000000000
--- a/app/controllers/first_run_controller.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-require_relative "../commands/feeds/import_from_opml"
-require_relative "../commands/users/create_user"
-require_relative "../commands/users/complete_setup"
-require_relative "../repositories/user_repository"
-require_relative "../repositories/story_repository"
-require_relative "../tasks/fetch_feeds"
-
-class Stringer < Sinatra::Base
-  namespace "/setup" do
-    before do
-      redirect to("/news") if UserRepository.setup_complete?
-    end
-
-    get "/password" do
-      erb :"first_run/password"
-    end
-
-    post "/password" do
-      if no_password(params) || password_mismatch?(params)
-        flash.now[:error] = t("first_run.password.flash.passwords_dont_match")
-        erb :"first_run/password"
-      else
-        user = CreateUser.new.create(params[:password])
-        session[:user_id] = user.id
-
-        redirect to("/feeds/import")
-      end
-    end
-
-    get "/tutorial" do
-      FetchFeeds.enqueue(Feed.all)
-      CompleteSetup.complete(current_user)
-
-      @sample_stories = StoryRepository.samples
-      erb :tutorial
-    end
-  end
-
-  private
-
-  def no_password(params)
-    params[:password].nil? || params[:password] == ""
-  end
-
-  def password_mismatch?(params)
-    params[:password] != params[:password_confirmation]
-  end
-end
diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb
new file mode 100644
index 000000000..c8887548c
--- /dev/null
+++ b/app/controllers/imports_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ImportsController < ApplicationController
+  def new
+    authorization.skip
+  end
+
+  def create
+    authorization.skip
+    Feed::ImportFromOpml.call(params["opml_file"].read, user: current_user)
+
+    redirect_to("/setup/tutorial")
+  end
+end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
new file mode 100644
index 000000000..0b1644439
--- /dev/null
+++ b/app/controllers/passwords_controller.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+class PasswordsController < ApplicationController
+  skip_before_action :complete_setup, only: [:new, :create]
+  skip_before_action :authenticate_user, only: [:new, :create]
+  before_action :check_signups_enabled, only: [:new, :create]
+
+  def new
+    authorization.skip
+  end
+
+  def create
+    authorization.skip
+
+    user = User.new(user_params)
+    if user.save
+      session[:user_id] = user.id
+
+      redirect_to("/feeds/import")
+    else
+      flash.now[:error] = user.error_messages
+      render(:new)
+    end
+  end
+
+  def update
+    authorization.skip
+
+    if current_user.update(password_params)
+      redirect_to("/news", flash: { success: t(".success") })
+    else
+      flash.now[:error] = t(".failure", errors: current_user.error_messages)
+      render("profiles/edit", locals: { user: current_user })
+    end
+  end
+
+  private
+
+  def check_signups_enabled
+    redirect_to(login_path) unless Setting::UserSignup.enabled?
+  end
+
+  def password_params
+    params
+      .expect(user: [
+                :password_challenge,
+                :password,
+                :password_confirmation
+              ])
+  end
+
+  def user_params
+    params
+      .expect(user: [:username, :password, :password_confirmation])
+      .merge(admin: User.none?)
+      .to_h.symbolize_keys
+  end
+end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
new file mode 100644
index 000000000..7d80c917a
--- /dev/null
+++ b/app/controllers/profiles_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class ProfilesController < ApplicationController
+  def edit
+    authorization.skip
+
+    render(locals: { user: current_user })
+  end
+
+  def update
+    authorization.skip
+
+    if current_user.update(user_params)
+      redirect_to(news_path, flash: { success: t(".success") })
+    else
+      errors = current_user.error_messages
+      flash.now[:error] = t(".failure", errors:)
+      render(:edit, locals: { user: current_user })
+    end
+  end
+
+  private
+
+  def user_params
+    params.expect(user: [:username, :password_challenge, :stories_order])
+  end
+end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 54d40df42..a9fa82b36 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -1,27 +1,31 @@
-require_relative "../commands/users/sign_in_user"
+# frozen_string_literal: true
 
-class Stringer < Sinatra::Base
-  get "/login" do
-    erb :"sessions/new"
+class SessionsController < ApplicationController
+  skip_before_action :authenticate_user, only: [:new, :create]
+
+  def new
+    authorization.skip
   end
 
-  post "/login" do
-    user = SignInUser.sign_in(params[:password])
+  def create
+    authorization.skip
+    user = SignInUser.call(params[:username], params[:password])
     if user
       session[:user_id] = user.id
 
       redirect_uri = session.delete(:redirect_to) || "/"
-      redirect to(redirect_uri)
+      redirect_to(redirect_uri)
     else
       flash.now[:error] = t("sessions.new.flash.wrong_password")
-      erb :"sessions/new"
+      render(:new)
     end
   end
 
-  get "/logout" do
+  def destroy
+    authorization.skip
     flash[:success] = t("sessions.destroy.flash.logged_out_successfully")
     session[:user_id] = nil
 
-    redirect to("/")
+    redirect_to("/")
   end
 end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
new file mode 100644
index 000000000..8452f4ca6
--- /dev/null
+++ b/app/controllers/settings_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class SettingsController < ApplicationController
+  def index
+    authorization.skip
+  end
+
+  def update
+    authorization.skip
+
+    setting = Setting.find(params[:id])
+    setting.update!(setting_params)
+
+    redirect_to(settings_path)
+  end
+
+  private
+
+  def setting_params
+    params.expect(setting: [:enabled])
+  end
+end
diff --git a/app/controllers/stories_controller.rb b/app/controllers/stories_controller.rb
index 052682046..d5b03747b 100644
--- a/app/controllers/stories_controller.rb
+++ b/app/controllers/stories_controller.rb
@@ -1,48 +1,33 @@
-require_relative "../repositories/story_repository"
-require_relative "../commands/stories/mark_all_as_read"
+# frozen_string_literal: true
 
-class Stringer < Sinatra::Base
-  get "/news" do
-    @unread_stories = StoryRepository.unread
-
-    erb :index
+class StoriesController < ApplicationController
+  def index
+    order = current_user.stories_order
+    @unread_stories = authorization.scope(StoryRepository.unread(order:))
   end
 
-  get "/feed/:feed_id" do
-    @feed = FeedRepository.fetch(params[:feed_id])
+  def update
+    json_params = JSON.parse(request.body.read, symbolize_names: true)
 
-    @stories = StoryRepository.feed(params[:feed_id])
-    @unread_stories = @stories.find_all { |story| !story.is_read }
+    story = authorization.check(StoryRepository.fetch(params[:id]))
+    story.update!(json_params.slice(:is_read, :is_starred, :keep_unread))
 
-    erb :feed
+    head(:no_content)
   end
 
-  get "/archive" do
-    @read_stories = StoryRepository.read(params[:page])
+  def mark_all_as_read
+    stories = authorization.scope(Story.where(id: params[:story_ids]))
+    MarkAllAsRead.call(stories.ids)
 
-    erb :archive
+    redirect_to("/news")
   end
 
-  get "/starred" do
-    @starred_stories = StoryRepository.starred(params[:page])
-
-    erb :starred
+  def archived
+    @read_stories = authorization.scope(StoryRepository.read(params[:page]))
   end
 
-  put "/stories/:id" do
-    json_params = JSON.parse(request.body.read, symbolize_names: true)
-
-    story = StoryRepository.fetch(params[:id])
-    story.is_read = !!json_params[:is_read]
-    story.keep_unread = !!json_params[:keep_unread]
-    story.is_starred = !!json_params[:is_starred]
-
-    StoryRepository.save(story)
-  end
-
-  post "/stories/mark_all_as_read" do
-    MarkAllAsRead.new(params[:story_ids]).mark_as_read
-
-    redirect to("/news")
+  def starred
+    @starred_stories =
+      authorization.scope(StoryRepository.starred(params[:page]))
   end
 end
diff --git a/app/controllers/tutorials_controller.rb b/app/controllers/tutorials_controller.rb
new file mode 100644
index 000000000..42c9328d3
--- /dev/null
+++ b/app/controllers/tutorials_controller.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class TutorialsController < ApplicationController
+  def index
+    authorization.skip
+    CallableJob.perform_later(Feed::FetchAllForUser, current_user)
+
+    @sample_stories = StoryRepository.samples
+  end
+end
diff --git a/app/fever_api/authentication.rb b/app/fever_api/authentication.rb
deleted file mode 100644
index 819eb07eb..000000000
--- a/app/fever_api/authentication.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-module FeverAPI
-  class Authentication
-    def initialize(options = {})
-      @clock = options.fetch(:clock) { Time }
-    end
-
-    def call(_params)
-      { auth: 1, last_refreshed_on_time: @clock.now.to_i }
-    end
-  end
-end
diff --git a/app/fever_api/read_favicons.rb b/app/fever_api/read_favicons.rb
deleted file mode 100644
index f49393198..000000000
--- a/app/fever_api/read_favicons.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-module FeverAPI
-  class ReadFavicons
-    def call(params = {})
-      if params.keys.include?("favicons")
-        { favicons: favicons }
-      else
-        {}
-      end
-    end
-
-    private
-
-    def favicons
-      [
-        {
-          id: 0,
-          data: "image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
-        }
-      ]
-    end
-  end
-end
diff --git a/app/fever_api/read_feeds.rb b/app/fever_api/read_feeds.rb
deleted file mode 100644
index a00a4b60d..000000000
--- a/app/fever_api/read_feeds.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-require_relative "../repositories/feed_repository"
-
-module FeverAPI
-  class ReadFeeds
-    def initialize(options = {})
-      @feed_repository = options.fetch(:feed_repository) { FeedRepository }
-    end
-
-    def call(params = {})
-      if params.keys.include?("feeds")
-        { feeds: feeds }
-      else
-        {}
-      end
-    end
-
-    private
-
-    def feeds
-      @feed_repository.list.map(&:as_fever_json)
-    end
-  end
-end
diff --git a/app/fever_api/read_feeds_groups.rb b/app/fever_api/read_feeds_groups.rb
deleted file mode 100644
index 6e6513b47..000000000
--- a/app/fever_api/read_feeds_groups.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-require_relative "../repositories/feed_repository"
-
-module FeverAPI
-  class ReadFeedsGroups
-    def initialize(options = {})
-      @feed_repository = options.fetch(:feed_repository) { FeedRepository }
-    end
-
-    def call(params = {})
-      if params.keys.include?("feeds") || params.keys.include?("groups")
-        { feeds_groups: feeds_groups }
-      else
-        {}
-      end
-    end
-
-    private
-
-    def feeds_groups
-      grouped_feeds = @feed_repository.in_group.order("LOWER(name)").group_by(&:group_id)
-      grouped_feeds.map do |group_id, feeds|
-        {
-          group_id: group_id,
-          feed_ids: feeds.map(&:id).join(",")
-        }
-      end
-    end
-  end
-end
diff --git a/app/fever_api/read_groups.rb b/app/fever_api/read_groups.rb
deleted file mode 100644
index 2709e6648..000000000
--- a/app/fever_api/read_groups.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-require_relative "../repositories/group_repository"
-
-module FeverAPI
-  class ReadGroups
-    def initialize(options = {})
-      @group_repository = options.fetch(:group_repository) { GroupRepository }
-    end
-
-    def call(params = {})
-      if params.keys.include?("groups")
-        { groups: groups }
-      else
-        {}
-      end
-    end
-
-    private
-
-    def groups
-      @group_repository.list.map(&:as_fever_json)
-    end
-  end
-end
diff --git a/app/fever_api/read_items.rb b/app/fever_api/read_items.rb
deleted file mode 100644
index 0ae45ffbd..000000000
--- a/app/fever_api/read_items.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-require_relative "../repositories/story_repository"
-
-module FeverAPI
-  class ReadItems
-    def initialize(options = {})
-      @story_repository = options.fetch(:story_repository) { StoryRepository }
-    end
-
-    def call(params = {})
-      if params.keys.include?("items")
-        item_ids = begin
-                     params[:with_ids].split(",")
-                   rescue
-                     nil
-                   end
-
-        {
-          items: items(item_ids, params[:since_id]),
-          total_items: total_items(item_ids)
-        }
-      else
-        {}
-      end
-    end
-
-    private
-
-    def items(item_ids, since_id)
-      items = item_ids ? stories_by_ids(item_ids) : unread_stories(since_id)
-      items.map(&:as_fever_json)
-    end
-
-    def total_items(item_ids)
-      items = item_ids ? stories_by_ids(item_ids) : unread_stories
-      items.count
-    end
-
-    def stories_by_ids(ids)
-      @story_repository.fetch_by_ids(ids)
-    end
-
-    def unread_stories(since_id = nil)
-      if since_id
-        @story_repository.unread_since_id(since_id)
-      else
-        @story_repository.unread
-      end
-    end
-  end
-end
diff --git a/app/fever_api/read_links.rb b/app/fever_api/read_links.rb
deleted file mode 100644
index 595343c9a..000000000
--- a/app/fever_api/read_links.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-module FeverAPI
-  class ReadLinks
-    def call(params = {})
-      if params.keys.include?("links")
-        { links: links }
-      else
-        {}
-      end
-    end
-
-    private
-
-    def links
-      []
-    end
-  end
-end
diff --git a/app/fever_api/response.rb b/app/fever_api/response.rb
deleted file mode 100644
index 1c9521060..000000000
--- a/app/fever_api/response.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-require_relative "authentication"
-
-require_relative "read_groups"
-require_relative "read_feeds"
-require_relative "read_feeds_groups"
-require_relative "read_favicons"
-require_relative "read_items"
-require_relative "read_links"
-
-require_relative "sync_unread_item_ids"
-require_relative "sync_saved_item_ids"
-
-require_relative "write_mark_item"
-require_relative "write_mark_feed"
-require_relative "write_mark_group"
-
-module FeverAPI
-  API_VERSION = 3
-
-  class Response
-    ACTIONS = [
-      Authentication,
-
-      ReadFeeds,
-      ReadGroups,
-      ReadFeedsGroups,
-      ReadFavicons,
-      ReadItems,
-      ReadLinks,
-
-      SyncUnreadItemIds,
-      SyncSavedItemIds,
-
-      WriteMarkItem,
-      WriteMarkFeed,
-      WriteMarkGroup
-    ].freeze
-
-    def initialize(params)
-      @params = params
-    end
-
-    def to_json
-      base_response = { api_version: API_VERSION }
-      ACTIONS
-        .inject(base_response) { |a, e| a.merge!(e.new.call(@params)) }
-        .to_json
-    end
-  end
-end
diff --git a/app/fever_api/sync_saved_item_ids.rb b/app/fever_api/sync_saved_item_ids.rb
deleted file mode 100644
index 9e4ff9dde..000000000
--- a/app/fever_api/sync_saved_item_ids.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-require_relative "../repositories/story_repository"
-
-module FeverAPI
-  class SyncSavedItemIds
-    def initialize(options = {})
-      @story_repository = options.fetch(:story_repository) { StoryRepository }
-    end
-
-    def call(params = {})
-      if params.keys.include?("saved_item_ids")
-        { saved_item_ids: saved_item_ids }
-      else
-        {}
-      end
-    end
-
-    private
-
-    def saved_item_ids
-      all_starred_stories.map(&:id).join(",")
-    end
-
-    def all_starred_stories
-      @story_repository.all_starred
-    end
-  end
-end
diff --git a/app/fever_api/sync_unread_item_ids.rb b/app/fever_api/sync_unread_item_ids.rb
deleted file mode 100644
index 7d469d73b..000000000
--- a/app/fever_api/sync_unread_item_ids.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-require_relative "../repositories/story_repository"
-
-module FeverAPI
-  class SyncUnreadItemIds
-    def initialize(options = {})
-      @story_repository = options.fetch(:story_repository) { StoryRepository }
-    end
-
-    def call(params = {})
-      if params.keys.include?("unread_item_ids")
-        { unread_item_ids: unread_item_ids }
-      else
-        {}
-      end
-    end
-
-    private
-
-    def unread_item_ids
-      unread_stories.map(&:id).join(",")
-    end
-
-    def unread_stories
-      @story_repository.unread
-    end
-  end
-end
diff --git a/app/fever_api/write_mark_feed.rb b/app/fever_api/write_mark_feed.rb
deleted file mode 100644
index 1c4d72961..000000000
--- a/app/fever_api/write_mark_feed.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-require_relative "../commands/stories/mark_feed_as_read"
-
-module FeverAPI
-  class WriteMarkFeed
-    def initialize(options = {})
-      @marker_class = options.fetch(:marker_class) { MarkFeedAsRead }
-    end
-
-    def call(params = {})
-      @marker_class.new(params[:id], params[:before]).mark_feed_as_read if params[:mark] == "feed"
-
-      {}
-    end
-  end
-end
diff --git a/app/fever_api/write_mark_group.rb b/app/fever_api/write_mark_group.rb
deleted file mode 100644
index 3ef39a0e1..000000000
--- a/app/fever_api/write_mark_group.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-require_relative "../commands/stories/mark_group_as_read"
-
-module FeverAPI
-  class WriteMarkGroup
-    def initialize(options = {})
-      @marker_class = options.fetch(:marker_class) { MarkGroupAsRead }
-    end
-
-    def call(params = {})
-      @marker_class.new(params[:id], params[:before]).mark_group_as_read if params[:mark] == "group"
-
-      {}
-    end
-  end
-end
diff --git a/app/fever_api/write_mark_item.rb b/app/fever_api/write_mark_item.rb
deleted file mode 100644
index a2752e013..000000000
--- a/app/fever_api/write_mark_item.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-require_relative "../commands/stories/mark_as_read"
-require_relative "../commands/stories/mark_as_unread"
-require_relative "../commands/stories/mark_as_starred"
-require_relative "../commands/stories/mark_as_unstarred"
-
-module FeverAPI
-  class WriteMarkItem
-    def initialize(options = {})
-      @read_marker_class = options.fetch(:read_marker_class) { MarkAsRead }
-      @unread_marker_class = options.fetch(:unread_marker_class) { MarkAsUnread }
-      @starred_marker_class = options.fetch(:starred_marker_class) { MarkAsStarred }
-      @unstarred_marker_class = options.fetch(:unstarred_marker_class) { MarkAsUnstarred }
-    end
-
-    def call(params = {})
-      mark_item_as(params[:id], params[:as]) if params[:mark] == "item"
-
-      {}
-    end
-
-    private
-
-    def mark_item_as(id, as)
-      case as
-      when "read"
-        @read_marker_class.new(id).mark_as_read
-      when "unread"
-        @unread_marker_class.new(id).mark_as_unread
-      when "saved"
-        @starred_marker_class.new(id).mark_as_starred
-      when "unsaved"
-        @unstarred_marker_class.new(id).mark_as_unstarred
-      end
-    end
-  end
-end
diff --git a/app/helpers/authentication_helpers.rb b/app/helpers/authentication_helpers.rb
deleted file mode 100644
index 9239b5d63..000000000
--- a/app/helpers/authentication_helpers.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-require "sinatra/base"
-
-require_relative "../repositories/user_repository"
-
-module Sinatra
-  module AuthenticationHelpers
-    def authenticated?
-      session[:user_id]
-    end
-
-    def needs_authentication?(path)
-      return false if ENV["RACK_ENV"] == "test"
-      return false unless UserRepository.setup_complete?
-      return false if %w(/login /logout /heroku).include?(path)
-      return false if path =~ /css|js|img/
-      true
-    end
-
-    def current_user
-      UserRepository.fetch(session[:user_id])
-    end
-  end
-end
diff --git a/app/helpers/url_helpers.rb b/app/helpers/url_helpers.rb
index 9d4c6fc7e..a17ee89cd 100644
--- a/app/helpers/url_helpers.rb
+++ b/app/helpers/url_helpers.rb
@@ -1,23 +1,18 @@
-require "nokogiri"
-require "uri"
+# frozen_string_literal: true
 
 module UrlHelpers
-  ABS_RE = URI::DEFAULT_PARSER.regexp[:ABS_URI]
-
   def expand_absolute_urls(content, base_url)
     doc = Nokogiri::HTML.fragment(content)
 
-    [%w(a href), %w(img src), %w(video src)].each do |tag, attr|
+    [["a", "href"], ["img", "src"], ["video", "src"]].each do |tag, attr|
       doc.css("#{tag}[#{attr}]").each do |node|
         url = node.get_attribute(attr)
-        next if url =~ ABS_RE
+        next if url =~ URI::RFC2396_PARSER.regexp[:ABS_URI]
 
-        begin
-          node.set_attribute(attr, URI.join(base_url, url).to_s)
-        rescue URI::InvalidURIError # rubocop:disable Lint/HandleExceptions
-          # Just ignore. If we cannot parse the url, we don't want the entire
-          # import to blow up.
-        end
+        node.set_attribute(attr, URI.join(base_url, url).to_s)
+      rescue URI::InvalidURIError
+        # Just ignore. If we cannot parse the url, we don't want the entire
+        # import to blow up.
       end
     end
 
@@ -25,11 +20,11 @@ def expand_absolute_urls(content, base_url)
   end
 
   def normalize_url(url, base_url)
-    uri = URI.parse(url)
+    uri = URI.parse(url.strip)
 
     # resolve (protocol) relative URIs
     if uri.relative?
-      base_uri = URI.parse(base_url)
+      base_uri = URI.parse(base_url.strip)
       scheme = base_uri.scheme || "http"
       uri = URI.join("#{scheme}://#{base_uri.host}", uri)
     end
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
new file mode 100644
index 000000000..d92ffddcb
--- /dev/null
+++ b/app/jobs/application_job.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class ApplicationJob < ActiveJob::Base
+end
diff --git a/app/jobs/callable_job.rb b/app/jobs/callable_job.rb
new file mode 100644
index 000000000..3225d6dd1
--- /dev/null
+++ b/app/jobs/callable_job.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class CallableJob < ApplicationJob
+  def perform(callable, *, **)
+    callable.call(*, **)
+  end
+end
diff --git a/app/jobs/fetch_feed_job.rb b/app/jobs/fetch_feed_job.rb
deleted file mode 100644
index 4f5915a1b..000000000
--- a/app/jobs/fetch_feed_job.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-FetchFeedJob = Struct.new(:feed_id) do
-  def perform
-    feed = FeedRepository.fetch(feed_id)
-    FetchFeed.new(feed).fetch
-  end
-end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
new file mode 100644
index 000000000..866db6833
--- /dev/null
+++ b/app/models/application_record.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ApplicationRecord < ActiveRecord::Base
+  primary_abstract_class
+
+  def self.boolean_accessor(attribute, key, default: false)
+    store_accessor(attribute, key)
+
+    define_method(key) do
+      value = super()
+      value.nil? ? default : CastBoolean.call(value)
+    end
+    alias_method(:"#{key}?", :"#{key}")
+
+    define_method(:"#{key}=") do |value|
+      super(value.nil? ? default : CastBoolean.call(value))
+    end
+  end
+
+  def error_messages
+    errors.full_messages.join(", ")
+  end
+end
diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/models/feed.rb b/app/models/feed.rb
index 08b66523b..17ffca406 100644
--- a/app/models/feed.rb
+++ b/app/models/feed.rb
@@ -1,34 +1,37 @@
-class Feed < ActiveRecord::Base
-  has_many :stories, -> { order "published desc" }, dependent: :delete_all
+# frozen_string_literal: true
+
+class Feed < ApplicationRecord
+  has_many :stories, -> { order(published: :desc) }, dependent: :delete_all
+  has_many :unread_stories, -> { unread }, class_name: "Story"
   belongs_to :group
+  belongs_to :user
 
-  validates_uniqueness_of :url
+  delegate :name, to: :group, prefix: true, allow_nil: true
 
-  STATUS = { green: 0, yellow: 1, red: 2 }.freeze
+  validates :url, presence: true, uniqueness: { scope: :user_id }
+  validates :user_id, presence: true
 
-  def status
-    STATUS.key(read_attribute(:status))
-  end
+  enum :status, { green: 0, yellow: 1, red: 2 }
 
-  def status=(s)
-    write_attribute(:status, STATUS[s])
-  end
+  scope :with_unread_stories_counts,
+        lambda {
+          left_joins(:unread_stories)
+            .select("feeds.*, count(stories.id) as unread_stories_count")
+            .group("feeds.id")
+        }
 
   def status_bubble
-    return :yellow if status == :red && stories.any?
-    status
-  end
+    return "yellow" if status == "red" && stories.any?
 
-  def unread_stories
-    stories.where("is_read = ?", false)
+    status
   end
 
   def as_fever_json
     {
-      id: id,
+      id:,
       favicon_id: 0,
-      title: name,
-      url: url,
+      title: name || "",
+      url:,
       site_url: url,
       is_spark: 0,
       last_updated_on_time: last_fetched.to_i
diff --git a/app/models/group.rb b/app/models/group.rb
index 7c26fcd54..26ebe1ddd 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -1,7 +1,15 @@
-class Group < ActiveRecord::Base
+# frozen_string_literal: true
+
+class Group < ApplicationRecord
+  UNGROUPED = Group.new(id: 0, name: "Ungrouped")
+
+  belongs_to :user
   has_many :feeds
 
+  validates :name, presence: true, uniqueness: { scope: :user_id }
+  validates :user_id, presence: true
+
   def as_fever_json
-    { id: id, title: name }
+    { id:, title: name }
   end
 end
diff --git a/app/models/migration_status.rb b/app/models/migration_status.rb
index b89956cbe..c9b0f618f 100644
--- a/app/models/migration_status.rb
+++ b/app/models/migration_status.rb
@@ -1,17 +1,11 @@
-class MigrationStatus
-  attr_reader :migrator
+# frozen_string_literal: true
 
-  def initialize(migrator = ActiveRecord::Migrator)
-    @migrator = migrator
-  end
-
-  def pending_migrations
-    migrations_path = migrator.migrations_path
-    migrations = migrator.migrations(migrations_path)
-    current_version = migrator.current_version
+module MigrationStatus
+  def self.call
+    migrator = ActiveRecord::Base.connection.pool.migration_context.open
 
-    migrations
-      .select { |m| current_version < m.version }
-      .map { |m| "#{m.name} - #{m.version}" }
+    migrator.pending_migrations.map do |migration|
+      "#{migration.name} - #{migration.version}"
+    end
   end
 end
diff --git a/app/models/setting.rb b/app/models/setting.rb
new file mode 100644
index 000000000..cb38322ec
--- /dev/null
+++ b/app/models/setting.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class Setting < ApplicationRecord
+  validates :type, presence: true, uniqueness: true
+end
diff --git a/app/models/setting/user_signup.rb b/app/models/setting/user_signup.rb
new file mode 100644
index 000000000..d1ec03f81
--- /dev/null
+++ b/app/models/setting/user_signup.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class Setting::UserSignup < Setting
+  boolean_accessor :data, :enabled, default: false
+
+  validates :enabled, inclusion: { in: [true, false] }
+
+  def self.first
+    first_or_create!
+  end
+
+  def self.enabled?
+    first_or_create!.enabled? || User.none?
+  end
+end
diff --git a/app/models/story.rb b/app/models/story.rb
index 6dec0471d..3806fafe1 100644
--- a/app/models/story.rb
+++ b/app/models/story.rb
@@ -1,11 +1,16 @@
-require_relative "./feed"
+# frozen_string_literal: true
 
-class Story < ActiveRecord::Base
+class Story < ApplicationRecord
   belongs_to :feed
+  has_one :user, through: :feed
 
   validates_uniqueness_of :entry_id, scope: :feed_id
 
-  UNTITLED = "[untitled]".freeze
+  delegate :group_id, :user_id, to: :feed
+
+  scope :unread, -> { where(is_read: false) }
+
+  UNTITLED = "[untitled]"
 
   def headline
     title.nil? ? UNTITLED : strip_html(title)[0, 50]
@@ -29,9 +34,9 @@ def as_json(_options = {})
 
   def as_fever_json
     {
-      id: id,
-      feed_id: feed_id,
-      title: title,
+      id:,
+      feed_id:,
+      title:,
       author: source,
       html: body,
       url: permalink,
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
new file mode 100644
index 000000000..6c001351e
--- /dev/null
+++ b/app/models/subscription.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Subscription < ApplicationRecord
+  belongs_to :user
+
+  STATUSES = ["active", "past_due", "unpaid", "canceled"].freeze
+
+  validates :user_id, presence: true, uniqueness: true
+  validates :stripe_customer_id, presence: true, uniqueness: true
+  validates :stripe_subscription_id, presence: true, uniqueness: true
+  validates :status, presence: true, inclusion: { in: STATUSES }
+  validates :current_period_start, presence: true
+  validates :current_period_end, presence: true
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 13422ce7f..587a6cc93 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,7 +1,45 @@
-class User < ActiveRecord::Base
+# frozen_string_literal: true
+
+class User < ApplicationRecord
   has_secure_password
 
-  def setup_complete?
-    setup_complete
+  encrypts :api_key, deterministic: true
+
+  has_many :feeds, dependent: :delete_all
+  has_many :groups, dependent: :delete_all
+
+  validates :username, presence: true, uniqueness: { case_sensitive: false }
+  validate :password_challenge_matches
+
+  before_save :update_api_key
+
+  enum :stories_order, { desc: "desc", asc: "asc" }, prefix: true
+
+  attr_accessor :password_challenge
+
+  # `password_challenge` logic should be able to be removed in Rails 7.1
+  # https://blog.appsignal.com/2023/02/15/whats-new-in-rails-7-1.html#password-challenge-via-has_secure_password
+  def password_challenge_matches
+    return unless password_challenge
+
+    digested_password = BCrypt::Password.new(password_digest_was)
+    return if digested_password.is_password?(password_challenge)
+
+    errors.add(:original_password, "does not match")
+  end
+
+  def update_api_key
+    return unless password_digest_changed? || username_changed?
+
+    if password_challenge.blank? && password.blank?
+      message = "Cannot change username without providing a password"
+
+      raise(ActiveRecord::ActiveRecordError, message)
+    end
+
+    password = password_challenge.presence || self.password.presence
+
+    # API key based on Fever spec: https://feedafever.com/api
+    self.api_key = Digest::MD5.hexdigest("#{username}:#{password}")
   end
 end
diff --git a/app/repositories/feed_repository.rb b/app/repositories/feed_repository.rb
index 6a8bec620..f28585c8b 100644
--- a/app/repositories/feed_repository.rb
+++ b/app/repositories/feed_repository.rb
@@ -1,4 +1,4 @@
-require_relative "../models/feed"
+# frozen_string_literal: true
 
 class FeedRepository
   MIN_YEAR = 1970
@@ -19,10 +19,10 @@ def self.update_feed(feed, name, url, group_id = nil)
   end
 
   def self.update_last_fetched(feed, timestamp)
-    if valid_timestamp?(timestamp, feed.last_fetched)
-      feed.last_fetched = timestamp
-      feed.save
-    end
+    return unless valid_timestamp?(timestamp, feed.last_fetched)
+
+    feed.last_fetched = timestamp
+    feed.save
   end
 
   def self.delete(feed_id)
@@ -35,11 +35,11 @@ def self.set_status(status, feed)
   end
 
   def self.list
-    Feed.order("lower(name)")
+    Feed.order(Feed.arel_table[:name].lower)
   end
 
   def self.in_group
-    Feed.where("group_id IS NOT NULL")
+    Feed.where.not(group_id: nil)
   end
 
   def self.valid_timestamp?(new_timestamp, current_timestamp)
diff --git a/app/repositories/group_repository.rb b/app/repositories/group_repository.rb
index 61f30a914..39635fec0 100644
--- a/app/repositories/group_repository.rb
+++ b/app/repositories/group_repository.rb
@@ -1,7 +1,7 @@
-require_relative "../models/group"
+# frozen_string_literal: true
 
 class GroupRepository
   def self.list
-    Group.order("LOWER(name)")
+    Group.order(Group.arel_table[:name].lower)
   end
 end
diff --git a/app/repositories/story_repository.rb b/app/repositories/story_repository.rb
index 4967d77ac..b9eda7efb 100644
--- a/app/repositories/story_repository.rb
+++ b/app/repositories/story_repository.rb
@@ -1,19 +1,21 @@
-require_relative "../helpers/url_helpers"
-require_relative "../models/story"
-require_relative "../utils/sample_story"
+# frozen_string_literal: true
 
 class StoryRepository
   extend UrlHelpers
 
   def self.add(entry, feed)
-    Story.create(feed: feed,
-                 title: extract_title(entry),
-                 permalink: extract_url(entry, feed),
-                 body: extract_content(entry),
-                 is_read: false,
-                 is_starred: false,
-                 published: entry.published || Time.now,
-                 entry_id: entry.id)
+    enclosure_url = entry.enclosure_url if entry.respond_to?(:enclosure_url)
+    Story.create(
+      feed:,
+      title: extract_title(entry),
+      permalink: extract_url(entry, feed),
+      enclosure_url:,
+      body: extract_content(entry),
+      is_read: false,
+      is_starred: false,
+      published: entry.published || Time.now,
+      entry_id: entry.id
+    )
   end
 
   def self.fetch(id)
@@ -26,55 +28,54 @@ def self.fetch_by_ids(ids)
 
   def self.fetch_unread_by_timestamp(timestamp)
     timestamp = Time.at(timestamp.to_i)
-    Story.where("stories.created_at < ?", timestamp).where(is_read: false)
+    Story.where(stories: { created_at: ...timestamp }).where(is_read: false)
   end
 
   def self.fetch_unread_by_timestamp_and_group(timestamp, group_id)
-    fetch_unread_by_timestamp(timestamp).joins(:feed).where(feeds: { group_id: group_id })
+    fetch_unread_by_timestamp(timestamp)
+      .joins(:feed).where(feeds: { group_id: })
   end
 
   def self.fetch_unread_for_feed_by_timestamp(feed_id, timestamp)
     timestamp = Time.at(timestamp.to_i)
-    Story.where(feed_id: feed_id).where("created_at < ? AND is_read = ?", timestamp, false)
-  end
-
-  def self.save(story)
-    story.save
+    Story
+      .where(feed_id:)
+      .where("created_at < ? AND is_read = ?", timestamp, false)
   end
 
   def self.exists?(id, feed_id)
-    Story.exists?(entry_id: id, feed_id: feed_id)
+    Story.exists?(entry_id: id, feed_id:)
   end
 
-  def self.unread
-    Story.where(is_read: false).order("published desc").includes(:feed)
+  def self.unread(order: "desc")
+    Story.where(is_read: false).order("published #{order}").includes(:feed)
   end
 
   def self.unread_since_id(since_id)
-    unread.where("id > ?", since_id)
+    unread.where(Story.arel_table[:id].gt(since_id))
   end
 
   def self.feed(feed_id)
-    Story.where("feed_id = ?", feed_id).order("published desc").includes(:feed)
+    Story.where(feed_id:).order(published: :desc).includes(:feed)
   end
 
   def self.read(page = 1)
     Story.where(is_read: true).includes(:feed)
-         .order("published desc").page(page).per_page(20)
+         .order(published: :desc).page(page).per_page(20)
   end
 
   def self.starred(page = 1)
     Story.where(is_starred: true).includes(:feed)
-         .order("published desc").page(page).per_page(20)
+         .order(published: :desc).page(page).per_page(20)
   end
 
   def self.all_starred
-    Story.where(is_starred: true)
+    Story.where(is_starred: true).order(published: :desc)
   end
 
   def self.unstarred_read_stories_older_than(num_days)
     Story.where(is_read: true, is_starred: false)
-         .where("published <= ?", num_days.days.ago)
+         .where(published: ..num_days.days.ago)
   end
 
   def self.read_count
@@ -82,40 +83,41 @@ def self.read_count
   end
 
   def self.extract_url(entry, feed)
-    return entry.enclosure_url if entry.url.nil? && entry.enclosure_url.present?
+    return normalize_url(entry.url, feed.url) if entry.url.present?
 
-    normalize_url(entry.url, feed.url) unless entry.url.nil?
+    entry.enclosure_url if entry.respond_to?(:enclosure_url)
   end
 
   def self.extract_content(entry)
     sanitized_content = ""
 
-    if entry.content
-      sanitized_content = sanitize(entry.content)
-    elsif entry.summary
-      sanitized_content = sanitize(entry.summary)
-    end
+    content = entry.content || entry.summary
+    sanitized_content = ContentSanitizer.call(content) if content
 
-    expand_absolute_urls(sanitized_content, entry.url)
+    if entry.url.present?
+      expand_absolute_urls(sanitized_content, entry.url)
+    else
+      sanitized_content
+    end
   end
 
   def self.extract_title(entry)
-    return sanitize(entry.title) if entry.title.present?
-    return sanitize(entry.summary) if entry.summary.present?
-    "There isn't a title for this story"
-  end
+    return ContentSanitizer.call(entry.title) if entry.title.present?
+    return ContentSanitizer.call(entry.summary) if entry.summary.present?
 
-  def self.sanitize(content)
-    Loofah.fragment(content.gsub(/<wbr\s*>/i, ""))
-          .scrub!(:prune)
-          .scrub!(:unprintable)
-          .to_s
+    "There isn't a title for this story"
   end
 
   def self.samples
     [
-      SampleStory.new("Darin' Fireballs", "Why you should trade your firstborn for a Retina iPad"),
-      SampleStory.new("TechKrunch", "SugarGlidr raises $1.2M Series A for Social Network for Photo Filters"),
+      SampleStory.new(
+        "Darin' Fireballs",
+        "Why you should trade your firstborn for a Retina iPad"
+      ),
+      SampleStory.new(
+        "TechKrunch",
+        "SugarGlidr raises $1.2M Series A for Social Network for Photo Filters"
+      ),
       SampleStory.new("Lambda Da Ultimate", "Flimsy types are the new hotness")
     ]
   end
diff --git a/app/repositories/user_repository.rb b/app/repositories/user_repository.rb
index 029f26b85..1120839ba 100644
--- a/app/repositories/user_repository.rb
+++ b/app/repositories/user_repository.rb
@@ -1,4 +1,4 @@
-require_relative "../models/user"
+# frozen_string_literal: true
 
 class UserRepository
   def self.fetch(id)
@@ -8,7 +8,7 @@ def self.fetch(id)
   end
 
   def self.setup_complete?
-    User.any? && User.first.setup_complete?
+    User.any?
   end
 
   def self.save(user)
diff --git a/app/tasks/change_password.rb b/app/tasks/change_password.rb
deleted file mode 100644
index 8a3f67011..000000000
--- a/app/tasks/change_password.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-require "io/console"
-
-require_relative "../commands/users/change_user_password"
-
-class ChangePassword
-  def initialize(command = ChangeUserPassword.new)
-    @command = command
-  end
-
-  def change_password
-    while (password = ask_password) != ask_confirmation
-      puts "The confirmation doesn't match the password. Please try again."
-    end
-    @command.change_user_password(password)
-  end
-
-  private
-
-  def ask_password
-    ask_hidden("New password: ")
-  end
-
-  def ask_confirmation
-    ask_hidden("Confirmation: ")
-  end
-
-  def ask_hidden(question)
-    print(question)
-    input = STDIN.noecho(&:gets).chomp
-    puts
-    input
-  end
-end
diff --git a/app/tasks/fetch_feed.rb b/app/tasks/fetch_feed.rb
deleted file mode 100644
index 2482391cc..000000000
--- a/app/tasks/fetch_feed.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-require "feedjira"
-
-require_relative "../repositories/story_repository"
-require_relative "../repositories/feed_repository"
-require_relative "../commands/feeds/find_new_stories"
-
-class FetchFeed
-  def initialize(feed, parser: Feedjira::Feed, logger: nil)
-    @feed = feed
-    @parser = parser
-    @logger = logger
-  end
-
-  def fetch
-    raw_feed = fetch_raw_feed
-
-    if raw_feed == 304
-      feed_not_modified
-    else
-      feed_modified(raw_feed)
-    end
-
-    FeedRepository.set_status(:green, @feed)
-  rescue => ex
-    FeedRepository.set_status(:red, @feed)
-
-    @logger.error "Something went wrong when parsing #{@feed.url}: #{ex}" if @logger
-  end
-
-  private
-
-  def fetch_raw_feed
-    @parser.fetch_and_parse(@feed.url)
-  end
-
-  def feed_not_modified
-    @logger.info "#{@feed.url} has not been modified since last fetch" if @logger
-  end
-
-  def feed_modified(raw_feed)
-    new_entries_from(raw_feed).each do |entry|
-      StoryRepository.add(entry, @feed)
-    end
-
-    FeedRepository.update_last_fetched(@feed, raw_feed.last_modified)
-  end
-
-  def new_entries_from(raw_feed)
-    finder = FindNewStories.new(raw_feed, @feed.id, @feed.last_fetched, latest_entry_id)
-    finder.new_stories
-  end
-
-  def latest_entry_id
-    return @feed.stories.first.entry_id unless @feed.stories.empty?
-  end
-end
diff --git a/app/tasks/fetch_feeds.rb b/app/tasks/fetch_feeds.rb
deleted file mode 100644
index 73cfc2c68..000000000
--- a/app/tasks/fetch_feeds.rb
+++ /dev/null
@@ -1,37 +0,0 @@
-require "thread/pool"
-
-require_relative "fetch_feed"
-
-class FetchFeeds
-  def initialize(feeds, pool = nil)
-    @pool  = pool
-    @feeds = feeds
-    @feeds_ids = []
-  end
-
-  def fetch_all
-    @pool ||= Thread.pool(10)
-
-    @feeds = FeedRepository.fetch_by_ids(@feeds_ids) if @feeds.blank? && !@feeds_ids.blank?
-
-    @feeds.each do |feed|
-      @pool.process do
-        FetchFeed.new(feed).fetch
-
-        ActiveRecord::Base.connection.close
-      end
-    end
-
-    @pool.shutdown
-  end
-
-  def prepare_to_delay
-    @feeds_ids = @feeds.map(&:id)
-    @feeds = []
-    self
-  end
-
-  def self.enqueue(feeds)
-    new(feeds).prepare_to_delay.delay.fetch_all
-  end
-end
diff --git a/app/tasks/remove_old_stories.rb b/app/tasks/remove_old_stories.rb
index d766b49bd..e51bbbd2e 100644
--- a/app/tasks/remove_old_stories.rb
+++ b/app/tasks/remove_old_stories.rb
@@ -1,17 +1,23 @@
-class RemoveOldStories
-  def self.remove!(number_of_days)
-    stories = old_stories(number_of_days)
-    feeds = pruned_feeds(stories)
+# frozen_string_literal: true
 
-    stories.delete_all
-    feeds.each { |feed| FeedRepository.update_last_fetched(feed, Time.now) }
-  end
+module RemoveOldStories
+  class << self
+    def call(number_of_days)
+      stories = old_stories(number_of_days)
+      feeds = pruned_feeds(stories)
 
-  def self.old_stories(number_of_days)
-    StoryRepository.unstarred_read_stories_older_than(number_of_days)
-  end
+      stories.delete_all
+      feeds.each { |feed| FeedRepository.update_last_fetched(feed, Time.now) }
+    end
+
+    private
+
+    def old_stories(number_of_days)
+      StoryRepository.unstarred_read_stories_older_than(number_of_days)
+    end
 
-  def self.pruned_feeds(stories)
-    FeedRepository.fetch_by_ids(stories.map(&:feed_id))
+    def pruned_feeds(stories)
+      FeedRepository.fetch_by_ids(stories.map(&:feed_id))
+    end
   end
 end
diff --git a/app/utils/api_key.rb b/app/utils/api_key.rb
deleted file mode 100644
index 6f0f52c4c..000000000
--- a/app/utils/api_key.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-require "digest/md5"
-
-class ApiKey
-  def self.compute(plaintext_password)
-    Digest::MD5.hexdigest("stringer:#{plaintext_password}")
-  end
-end
diff --git a/app/utils/authorization.rb b/app/utils/authorization.rb
new file mode 100644
index 000000000..bd417d05d
--- /dev/null
+++ b/app/utils/authorization.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class Authorization
+  attr_accessor :user, :authorized
+
+  class NotAuthorizedError < StandardError; end
+
+  def initialize(user)
+    self.user = user
+    self.authorized = false
+  end
+
+  alias authorized? authorized
+
+  def check(record)
+    raise(NotAuthorizedError) unless record.user_id == user.id
+
+    self.authorized = true
+    record
+  end
+
+  def scope(records)
+    self.authorized = true
+    records.joins(:user).where(users: { id: user.id })
+  end
+
+  def skip
+    self.authorized = true
+  end
+
+  def verify
+    raise(NotAuthorizedError) unless authorized?
+  end
+end
diff --git a/app/utils/content_sanitizer.rb b/app/utils/content_sanitizer.rb
new file mode 100644
index 000000000..e7fd83d6b
--- /dev/null
+++ b/app/utils/content_sanitizer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module ContentSanitizer
+  def self.call(content)
+    Loofah.fragment(content.gsub(/<wbr\s*>/i, ""))
+          .scrub!(:prune)
+          .scrub!(:unprintable)
+          .to_s
+  end
+end
diff --git a/app/utils/feed_discovery.rb b/app/utils/feed_discovery.rb
index 8c8be46d9..2664a09ee 100644
--- a/app/utils/feed_discovery.rb
+++ b/app/utils/feed_discovery.rb
@@ -1,23 +1,25 @@
-require "feedbag"
-require "feedjira"
+# frozen_string_literal: true
 
-class FeedDiscovery
-  def discover(url, finder = Feedbag, parser = Feedjira::Feed)
-    get_feed_for_url(url, parser) do
-      urls = finder.find(url)
-      return false if urls.empty?
+module FeedDiscovery
+  class << self
+    def call(url)
+      get_feed_for_url(url) do
+        urls = Feedbag.find(url)
+        return false if urls.empty?
 
-      get_feed_for_url(urls.first, parser) do
-        return false
+        get_feed_for_url(urls.first) { return false }
       end
     end
-  end
 
-  def get_feed_for_url(url, parser)
-    feed = parser.fetch_and_parse(url)
-    feed.feed_url ||= url
-    feed
-  rescue
-    yield if block_given?
+    private
+
+    def get_feed_for_url(url)
+      response = HTTParty.get(url).to_s
+      feed = Feedjira.parse(response)
+      feed.feed_url ||= url
+      feed
+    rescue StandardError
+      yield
+    end
   end
 end
diff --git a/app/utils/opml_parser.rb b/app/utils/opml_parser.rb
index e1643f68c..2d25b16f7 100644
--- a/app/utils/opml_parser.rb
+++ b/app/utils/opml_parser.rb
@@ -1,44 +1,48 @@
+# frozen_string_literal: true
+
 require "nokogiri"
 
-class OpmlParser
-  def parse_feeds(contents)
-    feeds_with_groups = Hash.new { |h, k| h[k] = [] }
-
-    outlines_in(contents).each do |outline|
-      if outline_is_group?(outline)
-        group_name = extract_name(outline.attributes).value
-        feeds = outline.xpath("./outline")
-      else # it's a top-level feed, which means it's a feed without group
-        group_name = "Ungrouped"
-        feeds = [outline]
+module OpmlParser
+  class << self
+    def call(contents)
+      feeds_with_groups = Hash.new { |h, k| h[k] = [] }
+
+      outlines_in(contents).each do |outline|
+        if outline_is_group?(outline)
+          group_name = extract_name(outline.attributes).value
+          feeds = outline.xpath("./outline")
+        else # it's a top-level feed, which means it's a feed without group
+          group_name = "Ungrouped"
+          feeds = [outline]
+        end
+
+        feeds.each do |feed|
+          feeds_with_groups[group_name] << feed_to_hash(feed)
+        end
       end
 
-      feeds.each do |feed|
-        feeds_with_groups[group_name] << feed_to_hash(feed)
-      end
+      feeds_with_groups
     end
 
-    feeds_with_groups
-  end
-
-  private
+    private
 
-  def outlines_in(contents)
-    Nokogiri.XML(contents).xpath("//body/outline")
-  end
+    def outlines_in(contents)
+      Nokogiri.XML(contents).xpath("//body/outline")
+    end
 
-  def outline_is_group?(outline)
-    outline.attributes["xmlUrl"].nil?
-  end
+    def outline_is_group?(outline)
+      outline.attributes["xmlUrl"].nil?
+    end
 
-  def extract_name(attributes)
-    attributes["title"] || attributes["text"]
-  end
+    def extract_name(attributes)
+      attributes["title"] || attributes["text"]
+    end
 
-  def feed_to_hash(feed)
-    {
-      name: extract_name(feed.attributes).value,
-      url:  feed.attributes["xmlUrl"].value
-    }
+    def feed_to_hash(feed)
+      {
+        name: extract_name(feed.attributes)&.value,
+        url: feed.attributes["xmlUrl"].value
+      }
+    end
   end
 end
diff --git a/app/utils/sample_story.rb b/app/utils/sample_story.rb
index 9fa5ae58e..437eda401 100644
--- a/app/utils/sample_story.rb
+++ b/app/utils/sample_story.rb
@@ -1,74 +1,80 @@
-SampleStory = Struct.new(:source, :title, :lead, :is_read, :published) do
-  BODY = <<-eos.freeze
-<p>Tofu shoreditch intelligentsia <a href="#">umami</a>, fashion axe photo booth
-try-hard terry richardson quinoa actually fingerstache meggings fixie. Aesthetic
-salvia vinyl raw denim, keffiyeh master cleanse tonx selfies mlkshk occupy twee
-street art gentrify. Quinoa PBR readymade 90's. <b>Chambray</b> Austin aesthetic
-meggings, carles vinyl intelligentsia tattooed. Keffiyeh mumblecore
-fingerstache, sartorial sriracha disrupt biodiesel cred. Skateboard yr cosby
-sweater, narwhal beard ethnic jean shorts aesthetic. Post-ironic flannel mlkshk,
-pickled VHS wolf banjo forage portland wayfarers.</p>
-<img src='https://placekitten.com/g/500/300' />
-<p>Selfies mumblecore odd future <a href="#">irony DIY messenger bag</a>.
-Authentic neutra next level selvage squid. Four loko freegan occupy, tousled
-vinyl leggings selvage messenger bag. Four loko wayfarers kale chips, next level
-banksy banh mi umami flannel hella. Street art odd future scenester,
-intelligentsia brunch fingerstache YOLO narwhal single-origin coffee tousled
-tumblr pop-up four loko you probably haven't heard of them dreamcatcher.
-Single-origin coffee direct trade retro biodiesel, truffaut fanny pack portland
-blue bottle scenester bushwick. Skateboard squid fanny pack bushwick, photo
-booth vice literally.</p>
-    eos
+# frozen_string_literal: true
 
-  def id
-    -1 * rand(100)
-  end
+SAMPLE_BODY = <<~HTML
+  <p>Tofu shoreditch intelligentsia <a href="#">umami</a>, fashion axe photo booth
+  try-hard terry richardson quinoa actually fingerstache meggings fixie. Aesthetic
+  salvia vinyl raw denim, keffiyeh master cleanse tonx selfies mlkshk occupy twee
+  street art gentrify. Quinoa PBR readymade 90's. <b>Chambray</b> Austin aesthetic
+  meggings, carles vinyl intelligentsia tattooed. Keffiyeh mumblecore
+  fingerstache, sartorial sriracha disrupt biodiesel cred. Skateboard yr cosby
+  sweater, narwhal beard ethnic jean shorts aesthetic. Post-ironic flannel mlkshk,
+  pickled VHS wolf banjo forage portland wayfarers.</p>
+  <img src='https://placekitten.com/g/500/300' />
+  <p>Selfies mumblecore odd future <a href="#">irony DIY messenger bag</a>.
+  Authentic neutra next level selvage squid. Four loko freegan occupy, tousled
+  vinyl leggings selvage messenger bag. Four loko wayfarers kale chips, next level
+  banksy banh mi umami flannel hella. Street art odd future scenester,
+  intelligentsia brunch fingerstache YOLO narwhal single-origin coffee tousled
+  tumblr pop-up four loko you probably haven't heard of them dreamcatcher.
+  Single-origin coffee direct trade retro biodiesel, truffaut fanny pack portland
+  blue bottle scenester bushwick. Skateboard squid fanny pack bushwick, photo
+  booth vice literally.</p>
+HTML
 
-  def headline
-    title
-  end
+SampleStory =
+  Struct.new(:source, :title, :lead, :is_read, :published) do
+    def id
+      rand(100) * -1
+    end
 
-  def permalink
-    "#"
-  end
+    def headline
+      title
+    end
 
-  def lead
-    "Tofu shoreditch intelligentsia umami, fashion axe photo booth try-hard"
-  end
+    def permalink
+      "#"
+    end
 
-  def body
-    BODY
-  end
+    def enclosure_url; end
 
-  def is_read # rubocop:disable Style/PredicateName
-    false
-  end
+    def lead
+      "Tofu shoreditch intelligentsia umami, fashion axe photo booth try-hard"
+    end
 
-  def keep_unread
-    false
-  end
+    def body
+      SAMPLE_BODY
+    end
 
-  def is_starred # rubocop:disable Style/PredicateName
-    false
-  end
+    def is_read
+      false
+    end
 
-  def published
-    Time.now
-  end
+    def keep_unread
+      false
+    end
+
+    def is_starred
+      false
+    end
+
+    def published
+      Time.now
+    end
 
-  def as_json(_options = {})
-    {
-      id: id,
-      headline: headline,
-      lead: lead,
-      source: source,
-      title: title,
-      pretty_date: published.strftime("%A, %B %d"),
-      body: body,
-      permalink: permalink,
-      is_read: is_read,
-      is_starred: is_starred,
-      keep_unread: keep_unread
-    }
+    def as_json(_options = {})
+      {
+        id:,
+        headline:,
+        lead:,
+        source:,
+        title:,
+        pretty_date: published.strftime("%A, %B %d"),
+        body:,
+        enclosure_url:,
+        permalink:,
+        is_read:,
+        is_starred:,
+        keep_unread:
+      }
+    end
   end
-end
diff --git a/app/views/heroku.erb b/app/views/debug/heroku.html.erb
similarity index 100%
rename from app/views/heroku.erb
rename to app/views/debug/heroku.html.erb
diff --git a/app/views/debug.erb b/app/views/debug/index.html.erb
similarity index 100%
rename from app/views/debug.erb
rename to app/views/debug/index.html.erb
diff --git a/app/views/partials/_feed_action_bar.erb b/app/views/feeds/_action_bar.html.erb
similarity index 86%
rename from app/views/partials/_feed_action_bar.erb
rename to app/views/feeds/_action_bar.html.erb
index f11a3d0c4..80e32b674 100644
--- a/app/views/partials/_feed_action_bar.erb
+++ b/app/views/feeds/_action_bar.html.erb
@@ -1,11 +1,15 @@
-<div class="row-fluid">
-  <div class="pull-left">
+<nav>
+  <div>
     <a class="btn" id="home" href="/news" title="<%= t('partials.feed_action_bar.home') %>">
       <i class="icon-reply"></i>
     </a>
   </div>
 
-  <div class="pull-right">
+  <div>
+    Logged in as <%= link_to(current_user.username, edit_profile_path) %>
+  </div>
+
+  <div>
     <a class="btn btn-primary" id="starred" href="/starred" title="<%= t('partials.feed_action_bar.starred_stories') %>">
       <i class="icon-star"></i>
     </a>
@@ -19,4 +23,4 @@
       <i class="icon-plus"></i>
     </a>
   </div>
-</div>
+</nav>
diff --git a/app/views/feeds/_feed.html.erb b/app/views/feeds/_feed.html.erb
new file mode 100644
index 000000000..99bf270a3
--- /dev/null
+++ b/app/views/feeds/_feed.html.erb
@@ -0,0 +1,36 @@
+<li class="feed" id="feed-<%= feed.id%>" data-id="<%= feed.id %>">
+  <div class="feed-line">
+    <div class="feed-title-container">
+      <p class="feed-title <%= feed.unread_stories_count > 0 ? "feed-unread" : "" %>">
+        <i class="icon-circle status <%= feed.status_bubble %>" data-toggle="tooltip" title="<%= t("partials.feed.status_bubble.#{feed.status_bubble}") if feed.status_bubble %>" data-placement="left"></i>
+        <a href="/feed/<%= feed.id %>">
+          <% if feed.unread_stories_count > 0 %>
+            (<%= feed.unread_stories_count %>)
+          <% end %>
+          <%= feed.name %>
+        </a>
+      </p>
+    </div>
+    <div class="feed-actions">
+      <div class="feed-last-updated">
+        <span class="last-updated"><%= t('partials.feed.last_updated') %></span>
+        <span class="last-updated-time">
+          <% if feed.last_fetched %>
+            <%= I18n.l(feed.last_fetched) %>
+          <% else %>
+            <%= t("partials.feed.last_fetched.never") %>
+          <% end %>
+        </span>
+      </div>
+      <span class="read-feed">
+        <a class="icon-external-link" aria-label="visit feed site" href="<%= feed.url %>"></a>
+      </span>
+      <span class="edit-feed">
+        <a class="icon-edit" aria-label="Edit" href="<%= "/feeds/#{feed.id}/edit" %>"></a>
+      </span>
+      <%= button_to("/feeds/#{feed.id}", method: :delete, class: "remove-feed btn-link", aria: { label: "Delete" }) do %>
+        <a class="icon-remove"></a>
+      <% end %>
+    </div>
+  </div>
+</li>
diff --git a/app/views/partials/_action_bar.erb b/app/views/feeds/_single_feed_action_bar.html.erb
similarity index 81%
rename from app/views/partials/_action_bar.erb
rename to app/views/feeds/_single_feed_action_bar.html.erb
index 523e6e2da..580db37a7 100644
--- a/app/views/partials/_action_bar.erb
+++ b/app/views/feeds/_single_feed_action_bar.html.erb
@@ -1,11 +1,13 @@
 <div class="row-fluid">
   <div class="pull-left">
+    <a class="btn" id="home" href="/news" title="<%= t('partials.feed_action_bar.home') %>">
+      <i class="icon-reply"></i>
+    </a>
     <a class="btn" id="mark-all" title="<%= t('partials.action_bar.mark_all') %>">
       <i class="icon-ok"></i>
-      <%= render_partial :mark_all_as_read_form,
-                        {stories: stories} %>
+      <%= render "stories/mark_all_as_read_form", {stories: stories} %>
     </a>
-    <a class="btn" href="/" id="refresh" title="<%= t('partials.action_bar.refresh') %>">
+    <a class="btn" href="<%= "/feed/#{@feed.id}" %>" id="refresh" title="<%= t('partials.action_bar.refresh') %>">
       <i class="icon-repeat"></i>
     </a>
   </div>
@@ -39,11 +41,6 @@
       if (refresh) refresh.click();
     });
 
-    Mousetrap.bind("f", function() {
-      var all_feeds = $("a#feeds")[0];
-      if (all_feeds) all_feeds.click();
-    });
-
     Mousetrap.bind("a", function() {
       var add_feed = $("a#add-feed")[0];
       if (add_feed) add_feed.click();
diff --git a/app/views/feeds/edit.erb b/app/views/feeds/edit.erb
deleted file mode 100644
index 7f0565daf..000000000
--- a/app/views/feeds/edit.erb
+++ /dev/null
@@ -1,36 +0,0 @@
-<div id="action-bar">
-  <%= render_partial :feed_action_bar %>
-</div>
-
-<div class="setup" id="add-feed-container">
-  <h1><%= @feed.name %></h1>
-  <hr />
-    <form id="add-feed-setup" method="POST" action="/feeds/<%= @feed.id %>">
-      <input type="hidden" name="_method" value="PUT">
-      <input type="hidden" name="feed_id" value="<%= @feed.id %>">
-      <div class="control-group">
-        <input name="feed_name" id="feed-name" type="text" value="<%= @feed.name %>" required />
-        <i class="icon-pencil field-icon"></i>
-        <label id="feed_alias-label" class="field-label" for="feed_alias"><%= t('feeds.edit.fields.feed_name') %></label>
-      </div>
-      <div class="control-group">
-        <input name="feed_url" id="feed-url" type="text" value="<%= @feed.url %>" autofocus required />
-        <i class="icon-rss field-icon"></i>
-        <label id="feed_url-label" class="field-label" for="feed_url"><%= t('feeds.edit.fields.feed_url') %></label>
-      </div>
-      <% if Group.any? %>
-        <div class="control-group">
-          <input type="text" class="select-dummy" tabindex="-1">
-          <select name="group_id" id="group-id">
-            <option value=""></option>
-            <% Group.all.each do |group| %>
-              <option value="<%= group.id %>" <%= "selected" if @feed.group_id == group.id %>><%= group.name %></option>
-            <% end %>
-          </select>
-          <label id="group_id-label" class="field-label" for="group_id"><%= t('feeds.edit.fields.group') %></label>
-        </div>
-      <% end %>
-
-    <input type="submit" id="submit" class="btn btn-primary pull-right" value="<%= t('feeds.edit.fields.submit') %>"/>
-  </form>
-</div>
diff --git a/app/views/feeds/edit.html.erb b/app/views/feeds/edit.html.erb
new file mode 100644
index 000000000..a932c3e4f
--- /dev/null
+++ b/app/views/feeds/edit.html.erb
@@ -0,0 +1,35 @@
+<div id="action-bar">
+  <%= render "feeds/action_bar" %>
+</div>
+
+<div class="setup" id="add-feed-container">
+  <h1><%= @feed.name %></h1>
+  <hr />
+  <%= form_with(url: "/feeds/#{@feed.id}", method: :put, id: "add-feed-setup") do %>
+    <input type="hidden" name="feed_id" value="<%= @feed.id %>">
+    <div class="control-group">
+      <input name="feed_name" id="feed-name" class="form-control" type="text" value="<%= @feed.name %>" required />
+      <i class="icon-pencil field-icon"></i>
+      <label id="feed-name-label" class="field-label" for="feed-name"><%= t('feeds.edit.fields.feed_name') %></label>
+    </div>
+    <div class="control-group">
+      <input name="feed_url" id="feed-url" class="form-control" type="text" value="<%= @feed.url %>" autofocus required />
+      <i class="icon-rss field-icon"></i>
+      <label id="feed-url-label" class="field-label" for="feed-url"><%= t('feeds.edit.fields.feed_url') %></label>
+    </div>
+    <% if current_user.groups.any? %>
+      <div class="control-group">
+        <input type="text" class="form-control" tabindex="-1">
+        <select name="group_id" id="group-id">
+          <option value=""></option>
+          <% current_user.groups.each do |group| %>
+            <option value="<%= group.id %>" <%= "selected" if @feed.group_id == group.id %>><%= group.name %></option>
+          <% end %>
+        </select>
+        <label id="group_id-label" class="field-label" for="group_id"><%= t('feeds.edit.fields.group') %></label>
+      </div>
+    <% end %>
+
+    <input type="submit" id="submit" class="btn btn-primary pull-right" value="<%= t('feeds.edit.fields.submit') %>"/>
+  <% end %>
+</div>
diff --git a/app/views/feeds/index.erb b/app/views/feeds/index.html.erb
similarity index 72%
rename from app/views/feeds/index.erb
rename to app/views/feeds/index.html.erb
index 0dad10031..79025c862 100644
--- a/app/views/feeds/index.erb
+++ b/app/views/feeds/index.html.erb
@@ -1,18 +1,18 @@
 <div id="action-bar">
-  <%= render_partial :feed_action_bar %>
+  <%= render "feeds/action_bar" %>
 </div>
 
 <% unless @feeds.empty? %>
 <div id="feeds-container">
   <ul id="feed-list">
     <% @feeds.each do |feed| %>
-      <%= render_partial :feed, { feed: feed } %>
+      <%= render "feeds/feed", { feed: feed } %>
     <% end %>
   </ul>
 </div>
 <% else %>
   <div id="add-some-feeds">
-    <p><%= t('feeds.index.add_some_feeds', :add => '<a href="/feeds/new">'+t('feeds.index.add')+'</a>') %></p>
+    <p><%= t('feeds.index.add_some_feeds', :add => '<a href="/feeds/new">'+t('feeds.index.add')+'</a>').html_safe %></p>
   </div>
 <% end %>
 
@@ -20,4 +20,4 @@
   $(document).ready(function () {
     $(".status").tooltip();
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/app/views/feeds/add.erb b/app/views/feeds/new.html.erb
similarity index 69%
rename from app/views/feeds/add.erb
rename to app/views/feeds/new.html.erb
index 7ead0fde7..5f36a8575 100644
--- a/app/views/feeds/add.erb
+++ b/app/views/feeds/new.html.erb
@@ -1,5 +1,5 @@
 <div id="action-bar">
-  <%= render_partial :feed_action_bar %>
+  <%= render "feeds/action_bar" %>
 </div>
 
 <div class="setup" id="add-feed-container">
@@ -7,13 +7,13 @@
   <hr />
   <p><%= t('feeds.add.description') %></p>
   <hr />
-  <form id="add-feed-setup" method="POST" action="/feeds">
+  <%= form_with(url: "/feeds", id: "add-feed-setup") do %>
     <div class="control-group">
-      <input name="feed_url" id="feed-url" type="text" value="<%= @feed_url %>" autofocus/>
+      <input name="feed_url" class="form-control" id="feed-url" type="text" value="<%= @feed_url %>" autofocus/>
       <i class="icon-rss field-icon"></i>
       <label id="feed_url-label" class="field-label" for="feed_url"><%= t('feeds.add.fields.feed_url') %></label>
     </div>
 
     <input type="submit" id="submit" class="btn btn-primary pull-right" value="<%= t('feeds.add.fields.submit') %>"/>
-  </form>
+  <% end %>
 </div>
diff --git a/app/views/feed.erb b/app/views/feeds/show.html.erb
similarity index 67%
rename from app/views/feed.erb
rename to app/views/feeds/show.html.erb
index d6576b5e4..8a539df9a 100644
--- a/app/views/feed.erb
+++ b/app/views/feeds/show.html.erb
@@ -5,14 +5,14 @@
 <% end %>
 
 <div id="action-bar">
-  <%= render_partial :single_feed_action_bar, { stories: @unread_stories } %>
+  <%= render "feeds/single_feed_action_bar", { stories: @unread_stories } %>
 </div>
 
 <div id="feed-title">
   <%= @feed.name %>
 </div>
 
-<%= render_js :stories, { stories: @stories } %>
+<%= render "stories/js", { stories: @stories } %>
 
 <div id="stories">
   <ul id="story-list">
diff --git a/app/views/first_run/password.erb b/app/views/first_run/password.erb
deleted file mode 100644
index 90a3d6394..000000000
--- a/app/views/first_run/password.erb
+++ /dev/null
@@ -1,22 +0,0 @@
-<div class="setup" id="password-setup">
-  <h1><%= t('first_run.password.title') %> <span class="orange"><%= t('first_run.password.anti_social') %></span>.</h1>
-  <h2><%= t('first_run.password.subtitle') %></h2>
-  <hr />
-  <p><%= t('first_run.password.description') %></p>
-  <hr />
-  <form id="password_setup" method="POST" action="/setup/password">
-    <input class="hidden" value="username" />
-    <div class="control-group">
-      <input name="password" id="password" type="password" autofocus />
-      <i class="icon-lock field-icon"></i>
-      <label id="password-label" class="field-label" for="password"><%= t('first_run.password.fields.password') %></label>
-    </div>
-    <div class="control-group">
-      <input name="password_confirmation" id="password-confirmation" type="password" />
-      <i class="icon-lock field-icon"></i>
-      <label id="confirm-label" class="field-label" for="password_confirmation"><%= t('first_run.password.fields.password_confirmation') %></label>
-    </div>
-
-    <input type="submit" id="submit" class="btn btn-primary pull-right" value="<%= t('first_run.password.fields.next') %>"/>
-  </form>
-</div>
diff --git a/app/views/feeds/import.erb b/app/views/imports/new.html.erb
similarity index 59%
rename from app/views/feeds/import.erb
rename to app/views/imports/new.html.erb
index 35632da9c..ba7c7246a 100644
--- a/app/views/feeds/import.erb
+++ b/app/views/imports/new.html.erb
@@ -1,17 +1,23 @@
+<nav>
+  <div>
+    Logged in as <%= link_to(current_user.username, edit_profile_path) %>
+  </div>
+</nav>
+
 <div class="setup" id="feed-setup">
   <h1><%= t('import.title') %></h1>
   <h2><%= t('import.subtitle') %></h2>
 
   <hr />
   <p>
-    <%= t('import.description', :link => '<a href="https://www.google.com/reader/subscriptions/export?hl=en">'+t('import.export')+'</a>') %>
+    <%= t('import.description') %>
   </p>
   <hr />
 
-  <form id="import" method="POST" action="/feeds/import" enctype="multipart/form-data">
-    <input id="opml_file" name="opml_file" type="file" title="<%= t('import.fields.import') %>" class="btn-primary" />
+  <%= form_with(url: "/feeds/import", id: "import", multipart: true) do %>
+    <input id="opml_file" name="opml_file" type="file" title="<%= t('import.fields.import') %>" aria-label="<%= t('import.fields.import') %>" class="btn-primary" />
     <a href="/setup/tutorial" id="skip" class="btn pull-right"><%= t('import.fields.not_now') %></a>
-  </form>
+  <% end %>
 </div>
 
 <script type="text/javascript">
diff --git a/app/views/js/templates/_story.js.erb b/app/views/js/templates/_story.js.erb
index dae33201a..4d51f514e 100644
--- a/app/views/js/templates/_story.js.erb
+++ b/app/views/js/templates/_story.js.erb
@@ -22,7 +22,14 @@
 
   <div class="story-body-container" class="row-fluid">
     <div class="story-body">
-      <h1><a href="{{= permalink }}">{{= title }}</a></h1>
+      <h1>
+        <a href="{{= permalink }}">{{= title }}</a>
+        {{ if (enclosure_url) { }}
+          <a class="story-enclosure" target="_blank" href="{{= enclosure_url }}">
+            <i class="icon-download"></i>
+          </a>
+        {{ } }}
+      </h1>
       {{= body }}
     </div>
     <div class="row-fluid story-actions-container">
@@ -33,7 +40,7 @@
       </div>
       <div class="pull-right story-actions">
         <div class="story-keep-unread">
-          <i class="icon-check{{ if(!keep_unread) { }}-empty{{ } }}"></i> <%= t('stories.keep_unread') %>
+          <i class="icon-check{{ if(!keep_unread) { }}-empty{{ } }}"></i> <%= I18n.t('stories.keep_unread') %>
         </div>
         <div class="story-starred">
           <i class="icon-star{{ if(!is_starred) { }}-empty{{ } }}"></i>
diff --git a/app/views/partials/_flash.erb b/app/views/layouts/_flash.html.erb
similarity index 88%
rename from app/views/partials/_flash.erb
rename to app/views/layouts/_flash.html.erb
index 2155dc810..f9f4283b3 100644
--- a/app/views/partials/_flash.erb
+++ b/app/views/layouts/_flash.html.erb
@@ -1,10 +1,10 @@
-<% if flash.has_key? :success %>
+<% if flash.key? :success %>
   <div class="alert alert-success success">
     <%= flash[:success] %>
   </div>
 <% end %>
 
-<% if flash.has_key? :error %>
+<% if flash.key? :error %>
   <div class="alert alert-error error">
     <%= flash[:error] %>
   </div>
@@ -26,4 +26,4 @@ $(document).ready(function() {
     $("#cookies-required").show();
   }
 });
-</script>
\ No newline at end of file
+</script>
diff --git a/app/views/partials/_footer.erb b/app/views/layouts/_footer.html.erb
similarity index 50%
rename from app/views/partials/_footer.erb
rename to app/views/layouts/_footer.html.erb
index 5f5619930..076efed8b 100644
--- a/app/views/partials/_footer.erb
+++ b/app/views/layouts/_footer.html.erb
@@ -1,6 +1,6 @@
 <div class="container">
   <div class="row-fluid">
-    <div class="span6">
+    <div class="span8">
       <ul class="footer-links">
         <% if current_user %>
           <li><a href="/logout"><%= t('layout.logout') %></a></li>
@@ -9,14 +9,25 @@
           <li class="muted">·</li>
           <li><a href="/feeds/export"><%= t('layout.export') %></a></li>
           <li class="muted">·</li>
-          <li><a href="#shortcuts" data-toggle="modal"><i class="icon-keyboard"></i></a></li>
+          <li>
+            <a href="#shortcuts" data-toggle="modal" aria-label="shortcuts">
+              <i aria-hidden="true" class="icon-keyboard">
+              </i>
+            </a>
+          </li>
           <li class="muted">·</li>
+          <li><%= link_to(t('layout.profile'), edit_profile_path) %></li>
+          <li class="muted">·</li>
+          <% if current_user.admin? %>
+            <li><%= link_to(t('layout.admin_settings'), settings_path) %></li>
+            <li class="muted">·</li>
+          <% end %>
         <% end %>
 
-        <li><a href="https://github.com/swanson/stringer"><%= t('layout.support') %></a></li>
+        <li><a href="https://github.com/stringer-rss/stringer"><%= t('layout.support') %></a></li>
       </ul>
     </div>
-    <div class="span6">
+    <div class="span4">
       <p class="pull-right">
         <b><%= t('layout.hey') %></b> <%= t('layout.back_to_work') %>
       </p>
diff --git a/app/views/partials/_shortcuts.erb b/app/views/layouts/_shortcuts.html.erb
similarity index 100%
rename from app/views/partials/_shortcuts.erb
rename to app/views/layouts/_shortcuts.html.erb
diff --git a/app/views/layout.erb b/app/views/layouts/application.html.erb
similarity index 61%
rename from app/views/layout.erb
rename to app/views/layouts/application.html.erb
index 980aee3ae..638cf0819 100644
--- a/app/views/layout.erb
+++ b/app/views/layouts/application.html.erb
@@ -1,25 +1,31 @@
 <!DOCTYPE html>
-<html>
+<html lang="<%= I18n.locale %>">
   <head>
     <title>
-      <%= yield_content :title %>
+      <%= content_for(:title) %>
       <%= t('layout.title') %>
     </title>
+    <%= csrf_meta_tags %>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <link rel="shortcut icon" href="/img/favicon.png">
     <link rel="apple-touch-icon-precomposed" href="/img/apple-touch-icon-precomposed.png">
 
-    <%= yield_content :head %>
+    <link rel="manifest" href="/manifest.json">
 
-    <link href="<%= stylesheet_path 'application' %>" rel="stylesheet" />
-    <script src="<%= javascript_path 'application' %>"></script>
+    <meta name="mobile-web-app-capable" content="yes">
+    <meta name="apple-mobile-web-app-capable" content="yes">
+
+    <%= content_for(:head) %>
+
+    <%= stylesheet_link_tag 'application' %>
+    <%= javascript_include_tag 'application' %>
   </head>
   <body>
     <div id="wrap">
       <div id="frame">
-        <%= render_partial :flash %>
-        <%= render_partial :shortcuts  if current_user %>
+        <%= render 'layouts/flash' %>
+        <%= render 'layouts/shortcuts' if current_user %>
         <div class="container">
           <div class="row-fluid">
             <div class="span12">
@@ -33,7 +39,7 @@
     </div>
 
     <div id="footer">
-      <%= render_partial :footer %>
+      <%= render 'layouts/footer' %>
     </div>
 
     <script>
diff --git a/app/views/partials/_feed.erb b/app/views/partials/_feed.erb
deleted file mode 100644
index 677eaae88..000000000
--- a/app/views/partials/_feed.erb
+++ /dev/null
@@ -1,36 +0,0 @@
-<li class="feed" id="feed-<%= feed.id%>" data-id="<%= feed.id %>">
-  <div class="row-fluid feed-line">
-    <div class="span7 feed-title-container">
-      <p class="feed-title <%= feed.unread_stories.any? ? "feed-unread" : "" %>">
-        <i class="icon-circle status <%= feed.status_bubble %>" data-toggle="tooltip" title="<%= t("partials.feed.status_bubble.#{feed.status_bubble}") if feed.status_bubble %>" data-placement="left"></i>
-        <a href="/feed/<%= feed.id %>">
-          <% if feed.unread_stories.any? %>
-            (<%= feed.unread_stories.count %>)
-          <% end %>
-          <%= feed.name %>
-        </a>
-      </p>
-    </div>
-    <div class="span3 feed-last-updated">
-      <span class="last-updated"><%= t('partials.feed.last_updated') %></span>
-      <span class="last-updated-time">
-        <% if feed.last_fetched %>
-          <%= I18n.l(feed.last_fetched) %>
-        <% else %>
-          <%= t("partials.feed.last_fetched.never") %>
-        <% end %>
-      </span>
-    </div>
-    <div class="span2">
-      <span class="read-feed">
-        <a class="icon-external-link" href="<%= feed.url %>"></a>
-      </span>
-      <span class="edit-feed">
-        <a class="icon-edit" href="<%= "/feeds/#{feed.id}/edit" %>"></a>
-      </span>
-      <span class="remove-feed">
-        <a class="icon-remove"></a>
-      </span>
-    </div>
-  </div>
-</li>
diff --git a/app/views/partials/_single_feed_action_bar.erb b/app/views/partials/_single_feed_action_bar.erb
deleted file mode 100644
index 1cdf29132..000000000
--- a/app/views/partials/_single_feed_action_bar.erb
+++ /dev/null
@@ -1,54 +0,0 @@
-<div class="row-fluid">
-  <div class="pull-left">
-    <a class="btn" id="home" href="<%= url('/news') %>" title="<%= t('partials.feed_action_bar.home') %>">
-      <i class="icon-reply"></i>
-    </a>
-    <a class="btn" id="mark-all" title="<%= t('partials.action_bar.mark_all') %>">
-      <i class="icon-ok"></i>
-      <%= render_partial :mark_all_as_read_form,
-                        {stories: stories} %>
-    </a>
-    <a class="btn" href="<%= url("/feed/#{@feed.id}") %>" id="refresh" title="<%= t('partials.action_bar.refresh') %>">
-      <i class="icon-repeat"></i>
-    </a>
-  </div>
-
-  <div class="pull-right">
-    <a class="btn btn-primary" id="starred" href="<%= url('/starred') %>" title="<%= t('partials.action_bar.starred_stories') %>">
-      <i class="icon-star"></i>
-    </a>
-    <a class="btn btn-primary" id="archive" href="<%= url('/archive') %>" title="<%= t('partials.action_bar.archived_stories') %>">
-      <i class="icon-time"></i>
-    </a>
-    <a class="btn btn-primary" id="feeds" href="<%= url('/feeds') %>" title="<%= t('partials.action_bar.view_feeds') %>">
-      <i class="icon-list"></i>
-    </a>
-    <a class="btn btn-primary" id="add-feed" href="<%= url('/feeds/new') %>" title="<%= t('partials.action_bar.add_feed') %>">
-      <i class="icon-plus"></i>
-    </a>
-  </div>
-</div>
-
-<script type="text/javascript">
-  $(document).ready(function() {
-    $("#mark-all").click(function(e) {
-      e.preventDefault();
-
-      $("form#mark-all-as-read").submit();
-    });
-    
-    Mousetrap.bind("r", function() {
-      var refresh = $("a#refresh")[0];
-      if (refresh) refresh.click();
-    });
-
-    Mousetrap.bind("a", function() {
-      var add_feed = $("a#add-feed")[0];
-      if (add_feed) add_feed.click();
-    });
-
-    Mousetrap.bind("shift+a", function() {
-      $("form#mark-all-as-read").submit();
-    });
-  });
-</script>
diff --git a/app/views/partials/_story.erb b/app/views/partials/_story.erb
deleted file mode 100644
index 19926f492..000000000
--- a/app/views/partials/_story.erb
+++ /dev/null
@@ -1,46 +0,0 @@
-<% if story.is_read %>
-  <li class="story read" id="story-<%= story.id%>" data-id="<%= story.id %>">
-<% else %>
-  <li class="story" id="story-<%= story.id%>" data-id="<%= story.id %>">
-<% end %>
-    <div class="row-fluid story-preview">
-      <div class="span3">
-        <p class="blog-title">
-          <%= story.source %>
-        </p>
-      </div>
-      <div class="span9">
-        <p class="story-details">
-          <span class="story-title">
-            <%= story.headline %>
-          </span>
-          <span class="story-lead">
-            &mdash; <%= story.lead %>
-          </span>
-        </p>
-      </div>
-    </div>
-
-
-    <div class="story-body-container" class="row-fluid" style="display: none;">
-      <div class="story-body">
-        <h1><a href="<%=story.permalink %>"><%= story.title %></a></h1>
-        <%= story.body %>
-      </div>
-      <div class="row-fluid story-actions-container">
-        <div class="pull-left">
-          <span class="story-published">
-            <%= I18n.l(story.published) %>
-          </span>
-        </div>
-        <div class="pull-right story-actions">
-          <a class="story-keep-unread" href="#">
-            <i class="icon-check"></i> Keep unread
-          </a>
-          <a class="story-permalink" href="<%= story.permalink %>">
-            <i class="icon-external-link"></i>
-          </a>
-        </div>
-      </div>
-    </div>
-  </li>
\ No newline at end of file
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb
new file mode 100644
index 000000000..87c46ce47
--- /dev/null
+++ b/app/views/passwords/new.html.erb
@@ -0,0 +1,27 @@
+<div class="setup" id="password-setup">
+  <h1><%= t('first_run.password.title') %> <span class="orange"><%= t('first_run.password.anti_social') %></span>.</h1>
+  <h2><%= t('first_run.password.subtitle') %></h2>
+  <hr />
+  <p><%= t('first_run.password.description') %></p>
+  <hr />
+  <%= form_with(model: User.new, url: "/setup/password", id: "password_setup") do |form| %>
+    <div class="control-group">
+      <%= form.text_field :username, class: "form-control", required: true %>
+      <%= form.label :username, t('first_run.password.fields.username'), class: "field-label" %>
+    </div>
+
+    <div class="control-group">
+      <%= form.password_field :password, class: "form-control", required: true %>
+      <i class="icon-lock field-icon"></i>
+      <%= form.label :password, t('first_run.password.fields.password'), class: "field-label" %>
+    </div>
+
+    <div class="control-group">
+      <%= form.password_field :password_confirmation, class: "form-control", required: true %>
+      <i class="icon-lock field-icon"></i>
+      <%= form.label :password_confirmation, t('first_run.password.fields.password_confirmation'), class: "field-label" %>
+    </div>
+
+    <input type="submit" id="submit" class="btn btn-primary pull-right" value="<%= t('first_run.password.fields.next') %>"/>
+  <% end %>
+</div>
diff --git a/app/views/profiles/edit.html.erb b/app/views/profiles/edit.html.erb
new file mode 100644
index 000000000..d8bc7c499
--- /dev/null
+++ b/app/views/profiles/edit.html.erb
@@ -0,0 +1,37 @@
+<h1><%= t('.title') %></h1>
+
+<%= form_with(model: user, url: profile_path) do |form| %>
+  <fieldset>
+    <legend><%= t(".stories_feed_settings") %></legend>
+    <%= form.label :stories_order %>
+    <%= form.select :stories_order, User.stories_orders.transform_keys {|k| User.human_attribute_name("stories_order.#{k}") } %>
+  </fieldset>
+  <%= form.submit("Update") %>
+<% end %>
+
+<%= form_with(model: user, url: profile_path) do |form| %>
+  <fieldset>
+    <legend><%= t('.change_username') %></legend>
+    <div class="warning">
+      <p><%= t('.warning_html') %></p>
+    </div>
+    <%= form.label :username %>
+    <%= form.text_field :username, required: true %>
+    <%= form.label :password_challenge, "Existing password" %>
+    <%= form.password_field :password_challenge, required: true %>
+  </fieldset>
+  <%= form.submit("Update username") %>
+<% end %>
+
+<%= form_with(model: user, url: password_path) do |form| %>
+  <fieldset>
+    <legend><%= t('.change_password') %></legend>
+    <%= form.label :password_challenge, "Existing password" %>
+    <%= form.password_field :password_challenge, required: true %>
+    <%= form.label :password, "New password" %>
+    <%= form.password_field :password, required: true %>
+    <%= form.label :password_confirmation %>
+    <%= form.password_field :password_confirmation, required: true %>
+  </fieldset>
+  <%= form.submit("Update password") %>
+<% end %>
diff --git a/app/views/read.erb b/app/views/read.erb
deleted file mode 100644
index b37ad9663..000000000
--- a/app/views/read.erb
+++ /dev/null
@@ -1,26 +0,0 @@
-<div id="action-bar">
-  <%= render_partial :feed_action_bar %>
-</div>
-
-<% unless @read_stories.empty? %>
-<div id="stories">
-  <ul id="story-list">
-    <% @read_stories.each do |story| %>
-      <%= render_partial :story, { story: story } %>
-    <% end %>
-  </ul>
-</div>
-<div id="pagination">
-  <% if @read_stories.previous_page %>
-    <a href="?page=<%= @read_stories.previous_page %>"><i class="icon-double-angle-left"></i> Previous</a>
-  <% end %>
-  Page <%= @read_stories.current_page %> of <%= @read_stories.total_pages %>
-  <% if @read_stories.next_page %>
-    <a href="?page=<%= @read_stories.next_page %>">Next <i class="icon-double-angle-right"></i></a>
-  <% end %>
-</div>
-<% else %>
-  <div id="add-some-feeds">
-    <p>Sorry, you haven't read any stories yet!</p>
-  </div>
-<% end %>
\ No newline at end of file
diff --git a/app/views/sessions/new.erb b/app/views/sessions/new.erb
index 2a5bc8183..cb3d05e2f 100644
--- a/app/views/sessions/new.erb
+++ b/app/views/sessions/new.erb
@@ -4,13 +4,24 @@
 
   <hr />
 
-  <form method="POST" id="login" action="/login">
-    <input class="hidden" value="username" />
+  <%= form_with(url: "/login") do |form| %>
+    <p><%= t('sessions.new.fields.unknown_username_html') %></p>
     <div class="control-group">
-      <input name="password" id="password" type="password" autofocus />
+      <%= form.text_field :username, class: "form-control", required: true %>
+      <%= form.label :username, t('sessions.new.fields.username'), class: "field-label" %>
+    </div>
+
+    <div class="control-group">
+      <input name="password" class="form-control" id="password" type="password" required />
       <i class="icon-lock field-icon"></i>
       <label id="password-label" class="field-label" for="password"><%= t('sessions.new.fields.password') %></label>
     </div>
     <input type="submit" value="<%= t('sessions.new.fields.submit') %>" class="btn btn-primary pull-right" />
-  </form>
+  <% end %>
+
+  <% if Setting::UserSignup.enabled? %>
+    <div>
+      <%= t('.sign_up_html', href: setup_password_path) %>
+    </div>
+  <% end %>
 </div>
diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb
new file mode 100644
index 000000000..9924c21eb
--- /dev/null
+++ b/app/views/settings/index.html.erb
@@ -0,0 +1,24 @@
+<div id="action-bar">
+  <%= render "feeds/action_bar" %>
+</div>
+
+<div class="setup">
+  <h1><%= t('.heading') %></h1>
+  <hr />
+  <p><%= t('.description') %></p>
+  <hr />
+  <div class="control-group">
+    <% setting = Setting::UserSignup.first %>
+    <%= form_with(model: setting, scope: :setting, url: setting_path(setting)) do |form| %>
+      <% if Setting::UserSignup.enabled? %>
+        <%= form.hidden_field :enabled, value: false %>
+        <span class='setup__label'><%= t('.signup.enabled') %></span>
+        <%= form.submit(t('.signup.disable'), class: 'btn btn-primary pull-right') %>
+      <% else %>
+        <%= form.hidden_field :enabled, value: true %>
+        <span class='setup__label'><%= t('.signup.disabled') %></span>
+        <%= form.submit(t('.signup.enable'), class: 'btn btn-primary pull-right') %>
+      <% end %>
+    <% end %>
+  </div>
+</div>
diff --git a/app/views/stories/_action_bar.html.erb b/app/views/stories/_action_bar.html.erb
new file mode 100644
index 000000000..6017a05ba
--- /dev/null
+++ b/app/views/stories/_action_bar.html.erb
@@ -0,0 +1,59 @@
+<nav>
+  <div>
+    <button class="btn" id="mark-all" title="<%= t('partials.action_bar.mark_all') %>" aria-label="<%= t('partials.action_bar.mark_all') %>">
+      <i class="icon-ok"></i>
+      <%= render "stories/mark_all_as_read_form", { stories: stories } %>
+    </button>
+    <a class="btn" href="/" id="refresh" title="<%= t('partials.action_bar.refresh') %>">
+      <i class="icon-repeat"></i>
+    </a>
+  </div>
+
+  <div>
+    Logged in as <%= link_to(current_user.username, edit_profile_path) %>
+  </div>
+
+  <div>
+    <a class="btn btn-primary" id="starred" href="/starred" title="<%= t('partials.action_bar.starred_stories') %>">
+      <i class="icon-star"></i>
+    </a>
+    <a class="btn btn-primary" id="archive" href="/archive" title="<%= t('partials.action_bar.archived_stories') %>">
+      <i class="icon-time"></i>
+    </a>
+    <a class="btn btn-primary" id="feeds" href="/feeds" title="<%= t('partials.action_bar.view_feeds') %>">
+      <i class="icon-list"></i>
+    </a>
+    <a class="btn btn-primary" id="add-feed" href="/feeds/new" title="<%= t('partials.action_bar.add_feed') %>">
+      <i class="icon-plus"></i>
+    </a>
+  </div>
+</nav>
+
+<script type="text/javascript">
+  $(document).ready(function() {
+    $("#mark-all").click(function(e) {
+      e.preventDefault();
+
+      $("form#mark-all-as-read").submit();
+    });
+    
+    Mousetrap.bind("r", function() {
+      var refresh = $("a#refresh")[0];
+      if (refresh) refresh.click();
+    });
+
+    Mousetrap.bind("f", function() {
+      var all_feeds = $("a#feeds")[0];
+      if (all_feeds) all_feeds.click();
+    });
+
+    Mousetrap.bind("a", function() {
+      var add_feed = $("a#add-feed")[0];
+      if (add_feed) add_feed.click();
+    });
+
+    Mousetrap.bind("shift+a", function() {
+      $("form#mark-all-as-read").submit();
+    });
+  });
+</script>
diff --git a/app/views/js/stories.js.erb b/app/views/stories/_js.html.erb
similarity index 90%
rename from app/views/js/stories.js.erb
rename to app/views/stories/_js.html.erb
index f937cce8a..f804579e1 100644
--- a/app/views/js/stories.js.erb
+++ b/app/views/stories/_js.html.erb
@@ -1,10 +1,10 @@
-<%= render_js_template :story %>
+<%= render 'stories/templates' %>
 
 <script type="text/javascript">
   $(document).ready(function() {
     var Stories = new StoryList;
     var StoryApp = new AppView(Stories);
-    StoryApp.loadData(<%= stories.to_json %>);
+    StoryApp.loadData(<%= sanitize(stories.to_json) %>);
 
     Mousetrap.bind("j", function() {
       StoryApp.moveCursorDown();
diff --git a/app/views/partials/_mark_all_as_read_form.erb b/app/views/stories/_mark_all_as_read_form.html.erb
similarity index 58%
rename from app/views/partials/_mark_all_as_read_form.erb
rename to app/views/stories/_mark_all_as_read_form.html.erb
index 2e4b3089e..9881c472a 100644
--- a/app/views/partials/_mark_all_as_read_form.erb
+++ b/app/views/stories/_mark_all_as_read_form.html.erb
@@ -1,7 +1,7 @@
 <div class="hide">
-  <form id="mark-all-as-read" method="POST" action="/stories/mark_all_as_read">
+  <%= form_with(url: "/stories/mark_all_as_read", id: "mark-all-as-read") do %>
     <% stories.each do |story| %>
       <input type="hidden" name="story_ids[]" value="<%= story.id %>" />
     <% end %>
-  </form>
-</div>
\ No newline at end of file
+  <% end %>
+</div>
diff --git a/app/views/stories/_templates.html.erb b/app/views/stories/_templates.html.erb
new file mode 100644
index 000000000..2c10c12fc
--- /dev/null
+++ b/app/views/stories/_templates.html.erb
@@ -0,0 +1,54 @@
+<script type="text/template" id="story-template">
+  <div class="row-fluid story-preview">
+    <div class="span3">
+      <div class="story-starred">
+        <i class="icon-star{{ if(!is_starred) { }}-empty{{ } }}"></i>
+      </div>
+      <p class="blog-title">
+        {{= source }}
+      </p>
+    </div>
+    <div class="span9">
+      <p class="story-details">
+        <span class="story-title">
+          {{= headline }}
+        </span>
+        <span class="story-lead">
+          &mdash; {{= lead }}
+        </span>
+      </p>
+    </div>
+  </div>
+
+  <div class="story-body-container" class="row-fluid">
+    <div class="story-body">
+      <h1>
+        <a href="{{= permalink }}">{{= title }}</a>
+        {{ if (enclosure_url) { }}
+          <a class="story-enclosure" target="_blank" href="{{= enclosure_url }}">
+            <i class="icon-download"></i>
+          </a>
+        {{ } }}
+      </h1>
+      {{= body }}
+    </div>
+    <div class="row-fluid story-actions-container">
+      <div class="pull-left">
+        <span class="story-published">
+          {{= pretty_date }}
+        </span>
+      </div>
+      <div class="pull-right story-actions">
+        <div class="story-keep-unread">
+          <i class="icon-check{{ if(!keep_unread) { }}-empty{{ } }}"></i> <%= t('stories.keep_unread') %>
+        </div>
+        <div class="story-starred">
+          <i class="icon-star{{ if(!is_starred) { }}-empty{{ } }}"></i>
+        </div>
+        <a class="story-permalink" target="_blank" href="{{= permalink }}">
+          <i class="icon-external-link"></i>
+        </a>
+      </div>
+    </div>
+  </div>
+</script>
diff --git a/app/views/partials/_zen.erb b/app/views/stories/_zen.html.erb
similarity index 84%
rename from app/views/partials/_zen.erb
rename to app/views/stories/_zen.html.erb
index 702e6dbe0..389a72f93 100644
--- a/app/views/partials/_zen.erb
+++ b/app/views/stories/_zen.html.erb
@@ -1,6 +1,6 @@
 <div id="zen">
   <span id="zero">&ndash; 0 &ndash;</span>
-  <p><%= t('partials.zen.rss_zero') %></p>
+  <p><%= t('partials.zen.rss_zero').html_safe %></p>
   <p>
     <small>
       <%= t('partials.zen.gtfo') %> <span id="gtfo"><%= t('partials.zen.go_make') %></span>.
diff --git a/app/views/archive.erb b/app/views/stories/archived.html.erb
similarity index 92%
rename from app/views/archive.erb
rename to app/views/stories/archived.html.erb
index 263368fea..0c2a09def 100644
--- a/app/views/archive.erb
+++ b/app/views/stories/archived.html.erb
@@ -1,9 +1,9 @@
 <div id="action-bar">
-  <%= render_partial :feed_action_bar %>
+  <%= render "feeds/action_bar" %>
 </div>
 
 <% unless @read_stories.empty? %>
-  <%= render_js :stories, { stories: @read_stories } %>
+  <%= render "stories/js", { stories: @read_stories } %>
 
   <div id="stories">
     <ul id="story-list">
@@ -49,4 +49,4 @@
   <div id="sorry">
     <p><%= t('archive.sorry') %></p>
   </div>
-<% end %>
\ No newline at end of file
+<% end %>
diff --git a/app/views/index.erb b/app/views/stories/index.html.erb
similarity index 61%
rename from app/views/index.erb
rename to app/views/stories/index.html.erb
index 55a710b1b..619793cd9 100644
--- a/app/views/index.erb
+++ b/app/views/stories/index.html.erb
@@ -5,16 +5,16 @@
 <% end %>
 
 <div id="action-bar">
-  <%= render_partial :action_bar, { stories: @unread_stories } %>
+  <%= render "stories/action_bar", { stories: @unread_stories } %>
 </div>
 
 <% if @unread_stories.empty? %>
-  <%= render_partial :zen %>
+  <%= render "stories/zen" %>
 <% else %>
-  <%= render_js :stories, { stories: @unread_stories } %>
+  <%= render "stories/js", { stories: @unread_stories } %>
   
   <div id="stories">
     <ul id="story-list">
     </ul>
   </div>
-<% end %>
\ No newline at end of file
+<% end %>
diff --git a/app/views/starred.erb b/app/views/stories/starred.html.erb
similarity index 92%
rename from app/views/starred.erb
rename to app/views/stories/starred.html.erb
index 6335b92cd..65da0018f 100644
--- a/app/views/starred.erb
+++ b/app/views/stories/starred.html.erb
@@ -1,9 +1,9 @@
 <div id="action-bar">
-  <%= render_partial :feed_action_bar %>
+  <%= render "feeds/action_bar" %>
 </div>
 
 <% unless @starred_stories.empty? %>
-  <%= render_js :stories, { stories: @starred_stories } %>
+  <%= render "stories/js", { stories: @starred_stories } %>
 
   <div id="stories">
     <ul id="story-list">
diff --git a/app/views/partials/_tutorial_action_bar.erb b/app/views/tutorials/_action_bar.html.erb
similarity index 74%
rename from app/views/partials/_tutorial_action_bar.erb
rename to app/views/tutorials/_action_bar.html.erb
index f5ed6d893..b61ffa136 100644
--- a/app/views/partials/_tutorial_action_bar.erb
+++ b/app/views/tutorials/_action_bar.html.erb
@@ -1,16 +1,19 @@
-<div class="row-fluid">
-  <div class="pull-left">
+<nav>
+  <div>
     <a class="btn" id="mark-all" title="<%= t('partials.action_bar.mark_all') %>">
       <i class="icon-ok"></i>
-      <%= render_partial :mark_all_as_read_form,
-                        {stories: stories} %>
+      <%= render 'stories/mark_all_as_read_form', {stories: stories} %>
     </a>
     <a class="btn" href="/" id="refresh" title="<%= t('partials.action_bar.refresh') %>">
       <i class="icon-repeat"></i>
     </a>
   </div>
 
-  <div class="pull-right">
+  <div>
+    Logged in as <%= link_to(current_user.username, edit_profile_path) %>
+  </div>
+
+  <div>
     <a class="btn btn-primary" id="feeds" href="/feeds" title="<%= t('partials.action_bar.view_feeds') %>">
       <i class="icon-list"></i>
     </a>
@@ -18,4 +21,4 @@
       <i class="icon-plus"></i>
     </a>
   </div>
-</div>
+</nav>
diff --git a/app/views/tutorial.erb b/app/views/tutorials/index.html.erb
similarity index 86%
rename from app/views/tutorial.erb
rename to app/views/tutorials/index.html.erb
index 308918c89..d06a29046 100644
--- a/app/views/tutorial.erb
+++ b/app/views/tutorials/index.html.erb
@@ -1,5 +1,5 @@
 <% content_for :head do %>
-  <link href='/css/reenie-beanie-font.css' rel='stylesheet' type='text/css'>
+  <%= stylesheet_link_tag 'reenie-beanie-font', media: 'all', 'data-turbolinks-track': 'reload' %>
 <% end %>
 
 <div class="tutorial-overlay">
@@ -19,10 +19,10 @@
 </div>
 
 <div id="action-bar">
-  <%= render_partial :tutorial_action_bar, {stories: @sample_stories} %>
+  <%= render 'tutorials/action_bar', {stories: @sample_stories} %>
 </div>
 
-<%= render_js :stories, { stories: @sample_stories } %>
+<%= render 'stories/js', { stories: @sample_stories } %>
 
 <div id="stories">
   <ul id="story-list">
diff --git a/bin/dev b/bin/dev
new file mode 100755
index 000000000..6981d9176
--- /dev/null
+++ b/bin/dev
@@ -0,0 +1,4 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+exec "./bin/rails", "server", *ARGV
diff --git a/bin/rails b/bin/rails
new file mode 100755
index 000000000..22f2d8dee
--- /dev/null
+++ b/bin/rails
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+APP_PATH = File.expand_path("../config/application", __dir__)
+require_relative "../config/boot"
+require "rails/commands"
diff --git a/bin/rake b/bin/rake
new file mode 100755
index 000000000..e436ea54a
--- /dev/null
+++ b/bin/rake
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require_relative "../config/boot"
+require "rake"
+Rake.application.run
diff --git a/bin/rubocop b/bin/rubocop
new file mode 100755
index 000000000..25406fe04
--- /dev/null
+++ b/bin/rubocop
@@ -0,0 +1,10 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "rubygems"
+require "bundler/setup"
+
+# explicit rubocop config increases performance slightly while avoiding config confusion.
+ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
+
+load Gem.bin_path("rubocop", "rubocop")
diff --git a/bin/setup b/bin/setup
new file mode 100755
index 000000000..2afb54f0d
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,36 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "fileutils"
+
+APP_ROOT = File.expand_path("..", __dir__)
+
+def system!(*)
+  system(*, exception: true)
+end
+
+FileUtils.chdir(APP_ROOT) do
+  # This script is a way to set up or update your development environment automatically.
+  # This script is idempotent, so that you can run it at any time and get an expectable outcome.
+  # Add necessary setup steps to this file.
+
+  puts "== Installing dependencies =="
+  system("bundle check") || system!("bundle install")
+
+  # puts "\n== Copying sample files =="
+  # unless File.exist?("config/database.yml")
+  #   FileUtils.cp "config/database.yml.sample", "config/database.yml"
+  # end
+
+  puts "\n== Preparing database =="
+  system! "bin/rails db:prepare"
+
+  puts "\n== Removing old logs and tempfiles =="
+  system! "bin/rails log:clear tmp:clear"
+
+  if ARGV.exclude?("--skip-server")
+    puts "\n== Starting development server =="
+    $stdout.flush # flush the output before exec(2) so that it displays
+    exec "bin/dev"
+  end
+end
diff --git a/bin/thrust b/bin/thrust
new file mode 100755
index 000000000..4b1980888
--- /dev/null
+++ b/bin/thrust
@@ -0,0 +1,7 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "rubygems"
+require "bundler/setup"
+
+load Gem.bin_path("thruster", "thrust")
diff --git a/config.ru b/config.ru
index 3720996aa..2e0308469 100644
--- a/config.ru
+++ b/config.ru
@@ -1,12 +1,8 @@
-require "rubygems"
-require "bundler"
+# frozen_string_literal: true
 
-Bundler.require
+# This file is used by Rack-based servers to start the application.
 
-require "./fever_api"
-map "/fever" do
-  run FeverAPI::Endpoint
-end
+require_relative "config/environment"
 
-require "./app"
-run Stringer
+run Rails.application
+Rails.application.load_server
diff --git a/config/application.rb b/config/application.rb
new file mode 100644
index 000000000..c9f63ac32
--- /dev/null
+++ b/config/application.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require_relative "boot"
+
+require "rails"
+# Pick the frameworks you want:
+require "active_model/railtie"
+require "active_job/railtie"
+require "active_record/railtie"
+# require "active_storage/engine"
+require "action_controller/railtie"
+# require "action_mailer/railtie"
+# require "action_mailbox/engine"
+# require "action_text/engine"
+require "action_view/railtie"
+# require "action_cable/engine"
+require "rails/test_unit/railtie"
+
+# Require the gems listed in Gemfile, including any gems
+# you've limited to :test, :development, or :production.
+Bundler.require(*Rails.groups)
+
+module Stringer
+end
+
+class Stringer::Application < Rails::Application
+  # Initialize configuration defaults for originally generated Rails version.
+  config.load_defaults(8.0)
+
+  # Please, add to the `ignore` list any other `lib` subdirectories that do
+  # not contain `.rb` files, or that should not be reloaded or eager loaded.
+  # Common ones are `templates`, `generators`, or `middleware`, for example.
+  config.autoload_lib(ignore: ["assets", "tasks"])
+
+  # Configuration for the application, engines, and railties goes here.
+  #
+  # These settings can be overridden in specific environments using the files
+  # in config/environments, which are processed later.
+  #
+  # config.time_zone = "Central Time (US & Canada)"
+  config.eager_load_paths << Rails.root.join("app/commands/story")
+  config.eager_load_paths << Rails.root.join("app/commands/user")
+
+  # Don't generate system test files.
+  config.generators.system_tests = nil
+
+  config.active_job.queue_adapter = :good_job
+
+  config.active_record.belongs_to_required_by_default = false
+
+  config.active_record.encryption.primary_key =
+    ENV.fetch("ENCRYPTION_PRIMARY_KEY")
+  config.active_record.encryption.deterministic_key =
+    ENV.fetch("ENCRYPTION_DETERMINISTIC_KEY")
+  config.active_record.encryption.key_derivation_salt =
+    ENV.fetch("ENCRYPTION_KEY_DERIVATION_SALT")
+end
diff --git a/config/asset_pipeline.rb b/config/asset_pipeline.rb
deleted file mode 100644
index 73818fd89..000000000
--- a/config/asset_pipeline.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-module AssetPipeline
-  def registered(app)
-    app.set :sprockets, Sprockets::Environment.new(app.root)
-
-    app.get "/assets/*" do
-      env["PATH_INFO"].sub!(%r{^/assets}, "")
-      settings.sprockets.call(env)
-    end
-
-    append_paths(app)
-    configure_development(app)
-    configure_production(app)
-    register_helpers(app)
-  end
-
-  private
-
-  def append_paths(app)
-    app.sprockets.append_path File.join(app.root, "app", "assets")
-    app.sprockets.append_path File.join(app.root, "app", "assets", "stylesheets")
-    app.sprockets.append_path File.join(app.root, "app", "assets", "javascripts")
-  end
-
-  def configure_development(app)
-    app.configure :development do
-      app.sprockets.cache = Sprockets::Cache::FileStore.new("./tmp")
-    end
-  end
-
-  def configure_production(app)
-    app.configure :production do
-      app.sprockets.css_compressor = :scss
-      app.sprockets.js_compressor = :uglify
-    end
-  end
-
-  def register_helpers(app)
-    Sprockets::Helpers.configure do |config|
-      config.environment = app.sprockets
-      config.prefix = "/assets"
-      config.debug = true if app.development?
-      config.digest = true if app.production?
-    end
-
-    app.helpers Sprockets::Helpers
-  end
-
-  module_function :registered,
-                  :append_paths,
-                  :configure_development,
-                  :configure_production,
-                  :register_helpers
-end
diff --git a/config/boot.rb b/config/boot.rb
new file mode 100644
index 000000000..c2241d707
--- /dev/null
+++ b/config/boot.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
+
+require "bundler/setup" # Set up gems listed in the Gemfile.
diff --git a/config/database.yml b/config/database.yml
index f1c394139..dfb4e4850 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -2,13 +2,17 @@ development:
   adapter: postgresql
   database: stringer_dev
   encoding: unicode
+  host: localhost
   pool: 5
 
 test:
-  adapter: sqlite3
-  database: db/stringer_test.sqlite
+  adapter: postgresql
+  database: stringer_test
+  encoding: unicode
+  host: localhost
+  pool: 5
 
 production:
   url: <%= ENV["DATABASE_URL"] %>
   encoding: unicode
-  pool: 5
+  pool: <%= Integer(ENV.fetch("DB_POOL", 15)) %>
diff --git a/config/environment.rb b/config/environment.rb
new file mode 100644
index 000000000..7df99e89c
--- /dev/null
+++ b/config/environment.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+# Load the Rails application.
+require_relative "application"
+
+# Initialize the Rails application.
+Rails.application.initialize!
diff --git a/config/environments/development.rb b/config/environments/development.rb
new file mode 100644
index 000000000..b0fa8757e
--- /dev/null
+++ b/config/environments/development.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require "active_support/core_ext/integer/time"
+
+Rails.application.configure do
+  # Settings specified here will take precedence over those in
+  # config/application.rb.
+
+  # Make code changes take effect immediately without server restart.
+  config.enable_reloading = true
+
+  # Do not eager load code on boot.
+  config.eager_load = false
+
+  # Show full error reports.
+  config.consider_all_requests_local = true
+
+  # Enable server timing.
+  config.server_timing = true
+
+  # Enable/disable Action Controller caching. By default Action Controller
+  # caching is disabled. Run rails dev:cache to toggle Action Controller
+  # caching.
+  if Rails.root.join("tmp/caching-dev.txt").exist?
+    config.action_controller.perform_caching = true
+    config.action_controller.enable_fragment_cache_logging = true
+    config.public_file_server.headers =
+      { "cache-control" => "public, max-age=#{2.days.to_i}" }
+  else
+    config.action_controller.perform_caching = false
+  end
+
+  # Change to :null_store to avoid any caching.
+  config.cache_store = :memory_store
+
+  # Print deprecation notices to the Rails logger.
+  config.active_support.deprecation = :log
+
+  # Raise an error on page load if there are pending migrations.
+  config.active_record.migration_error = :page_load
+
+  # Highlight code that triggered database queries in logs.
+  config.active_record.verbose_query_logs = true
+
+  # Append comments with runtime information tags to SQL queries in logs.
+  config.active_record.query_log_tags_enabled = true
+
+  # Highlight code that enqueued background job in logs.
+  config.active_job.verbose_enqueue_logs = true
+
+  # Raises error for missing translations.
+  # config.i18n.raise_on_missing_translations = true
+
+  # Annotate rendered view with file names.
+  config.action_view.annotate_rendered_view_with_filenames = true
+
+  # Raise error when a before_action's only/except options reference missing
+  # actions.
+  config.action_controller.raise_on_missing_callback_actions = true
+
+  # Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
+  # config.generators.apply_rubocop_autocorrect_after_generate!
+end
diff --git a/config/environments/production.rb b/config/environments/production.rb
new file mode 100644
index 000000000..1f6af775f
--- /dev/null
+++ b/config/environments/production.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require "active_support/core_ext/integer/time"
+
+Rails.application.configure do
+  # Settings specified here will take precedence over those in
+  # config/application.rb.
+
+  # Code is not reloaded between requests.
+  config.enable_reloading = false
+
+  # Eager load code on boot for better performance and memory savings (ignored
+  # by Rake tasks).
+  config.eager_load = true
+
+  # Full error reports are disabled.
+  config.consider_all_requests_local = false
+
+  # Turn on fragment caching in view templates.
+  config.action_controller.perform_caching = true
+
+  # Cache assets for far-future expiry since they are all digest stamped.
+  config.public_file_server.headers = {
+    "cache-control" => "public, max-age=#{Integer(1.year)}"
+  }
+
+  # Enable serving of images, stylesheets, and JavaScripts from an asset server.
+  # config.asset_host = "http://assets.example.com"
+
+  # Assume all access to the app is happening through a SSL-terminating reverse
+  # proxy.
+  config.assume_ssl = true
+
+  # Force all access to the app over SSL, use Strict-Transport-Security, and
+  # use secure cookies.
+  config.force_ssl = true
+
+  # Skip http-to-https redirect for the default health check endpoint.
+  # config.ssl_options =
+  #   { redirect: { exclude: ->(request) { request.path == "/up" } } }
+
+  # Log to STDOUT with the current request id as a default log tag.
+  config.log_tags = [:request_id]
+  config.logger   = ActiveSupport::TaggedLogging.logger($stdout)
+
+  # Change to "debug" to log everything (including potentially
+  # personally-identifiable information!)
+  config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
+
+  # Prevent health checks from clogging up the logs.
+  config.silence_healthcheck_path = "/up"
+
+  # Don't log any deprecations.
+  config.active_support.report_deprecations = false
+
+  # Replace the default in-process memory cache store with a durable
+  # alternative.
+  # config.cache_store = :mem_cache_store
+
+  # Replace the default in-process and non-durable queuing backend for Active
+  # Job.
+  # config.active_job.queue_adapter = :resque
+
+  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
+  # the I18n.default_locale when a translation cannot be found).
+  config.i18n.fallbacks = true
+
+  # Do not dump schema after migrations.
+  config.active_record.dump_schema_after_migration = false
+
+  # Only use :id for inspections in production.
+  config.active_record.attributes_for_inspect = [:id]
+
+  # Enable DNS rebinding protection and other `Host` header attacks.
+  # config.hosts = [
+  #   "example.com",     # Allow requests from example.com
+  #   /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
+  # ]
+  #
+  # Skip DNS rebinding protection for the default health check endpoint.
+  # config.host_authorization =
+  #   { exclude: ->(request) { request.path == "/up" } }
+
+  config.good_job.execution_mode = :async
+
+  config.assets.compile = false
+end
diff --git a/config/environments/test.rb b/config/environments/test.rb
new file mode 100644
index 000000000..65fd7938e
--- /dev/null
+++ b/config/environments/test.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+# The test environment is used exclusively to run your application's
+# test suite. You never need to work with it otherwise. Remember that
+# your test database is "scratch space" for the test suite and is wiped
+# and recreated between test runs. Don't rely on the data there!
+
+Rails.application.configure do
+  # Settings specified here will take precedence over those in
+  # config/application.rb.
+
+  # While tests run files are not watched, reloading is not necessary.
+  config.enable_reloading = false
+
+  # Eager loading loads your entire application. When running a single test
+  # locally, this is usually not necessary, and can slow down your test suite.
+  # However, it's recommended that you enable it in continuous integration
+  # systems to ensure eager loading is working properly before deploying your
+  # code.
+  config.eager_load = ENV["CI"].present?
+
+  # Configure public file server for tests with cache-control for performance.
+  config.public_file_server.headers =
+    { "cache-control" => "public, max-age=3600" }
+
+  # Show full error reports.
+  config.consider_all_requests_local = true
+  config.cache_store = :null_store
+
+  # Render exception templates for rescuable exceptions and raise for other
+  # exceptions.
+  config.action_dispatch.show_exceptions = :rescuable
+
+  # Disable request forgery protection in test environment.
+  config.action_controller.allow_forgery_protection = false
+
+  # Print deprecation notices to the stderr.
+  config.active_support.deprecation = :stderr
+
+  # Raises error for missing translations.
+  # config.i18n.raise_on_missing_translations = true
+
+  # Annotate rendered view with file names.
+  # config.action_view.annotate_rendered_view_with_filenames = true
+
+  # Raise error when a before_action's only/except options reference missing
+  # actions.
+  config.action_controller.raise_on_missing_callback_actions = true
+end
diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb
new file mode 100644
index 000000000..8544c07c5
--- /dev/null
+++ b/config/initializers/assets.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Version of your assets, change this if you want to expire all your assets.
+Rails.application.config.assets.version = "1.0"
+
+# Add additional assets to the asset load path.
+# Rails.application.config.assets.paths << Emoji.images_path
diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb
new file mode 100644
index 000000000..665eb90b1
--- /dev/null
+++ b/config/initializers/content_security_policy.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Define an application-wide content security policy.
+# See the Securing Rails Applications Guide for more information:
+# https://guides.rubyonrails.org/security.html#content-security-policy-header
+
+# Rails.application.configure do
+#   config.content_security_policy do |policy|
+#     policy.default_src :self, :https
+#     policy.font_src    :self, :https, :data
+#     policy.img_src     :self, :https, :data
+#     policy.object_src  :none
+#     policy.script_src  :self, :https
+#     policy.style_src   :self, :https
+#     # Specify URI for violation reports
+#     # policy.report_uri "/csp-violation-report-endpoint"
+#   end
+#
+#   # Generate session nonces for permitted importmap, inline scripts, and
+#   inline styles.
+#   config.content_security_policy_nonce_generator =
+#     ->(request) { request.session.id.to_s }
+#   config.content_security_policy_nonce_directives = %w(script-src style-src)
+#
+#   # Report violations without enforcing the policy.
+#   # config.content_security_policy_report_only = true
+# end
diff --git a/config/initializers/dotenv.rb b/config/initializers/dotenv.rb
new file mode 100644
index 000000000..62d88ea0a
--- /dev/null
+++ b/config/initializers/dotenv.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+Dotenv.require_keys(
+  "SECRET_KEY_BASE",
+  "ENCRYPTION_PRIMARY_KEY",
+  "ENCRYPTION_DETERMINISTIC_KEY",
+  "ENCRYPTION_KEY_DERIVATION_SALT"
+)
diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb
new file mode 100644
index 000000000..4a1a9a497
--- /dev/null
+++ b/config/initializers/filter_parameter_logging.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Configure parameters to be partially matched (e.g. passw matches password)
+# and filtered from the log file. Use this to limit dissemination of sensitive
+# information. See the ActiveSupport::ParameterFilter documentation for
+# supported notations and behaviors.
+Rails.application.config.filter_parameters += [
+  :passw,
+  :email,
+  :secret,
+  :token,
+  :_key,
+  :crypt,
+  :salt,
+  :certificate,
+  :otp,
+  :ssn,
+  :cvv,
+  :cvc
+]
diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb
new file mode 100644
index 000000000..86eb3555a
--- /dev/null
+++ b/config/initializers/good_job.rb
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+GoodJob.preserve_job_records = false
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
new file mode 100644
index 000000000..b5f7c7c4e
--- /dev/null
+++ b/config/initializers/inflections.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Add new inflection rules using the following format. Inflections
+# are locale specific, and you may define rules for as many different
+# locales as you wish. All of these examples are active by default:
+# ActiveSupport::Inflector.inflections(:en) do |inflect|
+#   inflect.plural /^(ox)$/i, "\\1en"
+#   inflect.singular /^(ox)en/i, "\\1"
+#   inflect.irregular "person", "people"
+#   inflect.uncountable %w( fish sheep )
+# end
+
+# These inflection rules are supported but not enabled by default:
+ActiveSupport::Inflector.inflections(:en) { |inflect| inflect.acronym("API") }
diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb
new file mode 100644
index 000000000..e8d0b2ae8
--- /dev/null
+++ b/config/initializers/permissions_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+# Define an application-wide HTTP permissions policy. For further
+# information see: https://developers.google.com/web/updates/2018/06/feature-policy
+
+# Rails.application.config.permissions_policy do |policy|
+#   policy.camera      :none
+#   policy.gyroscope   :none
+#   policy.microphone  :none
+#   policy.usb         :none
+#   policy.fullscreen  :self
+#   policy.payment     :self, "https://secure.example.com"
+# end
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
new file mode 100644
index 000000000..2aa04adc9
--- /dev/null
+++ b/config/initializers/session_store.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+# Be sure to restart your server when you modify this file.
+
+session_config = { key: "_stringer_session", expire_after: 2.weeks }
+Rails.application.config.session_store(:cookie_store, **session_config)
diff --git a/config/locales/de.yml b/config/locales/de.yml
index bef897ecb..c2497e154 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -25,11 +25,11 @@ de:
       fields:
         feed_url: Feed URL
         submit: Hinzufügen
-      flash:
-        added_successfully: Wir haben deinen Feed hinzugefügt. Schau ein bisschen später wieder vorbei.
-        already_subscribed_error: Du hast diesen Feed bereits abonniert...
-        feed_not_found_error: Wir konnten diesen Feed nicht finden. Probiere es noch einmal
       title: Benötigst du neue Geschichten?
+    create:
+      success: Wir haben deinen Feed hinzugefügt. Schau ein bisschen später wieder vorbei.
+      already_subscribed: Du hast diesen Feed bereits abonniert...
+      feed_not_found: Wir konnten diesen Feed nicht finden. Probiere es noch einmal
     edit:
       fields:
         feed_name: Feed-Name
@@ -40,6 +40,8 @@ de:
     index:
       add: hinzufügen
       add_some_feeds: Hey, du solltest ein paar Feeds %{add}.
+    destroy:
+      success: Feed gelöscht.
   first_run:
     password:
       anti_social: anti-sozial
@@ -56,8 +58,7 @@ de:
     cookies_required: Das ist jetzt peinlich. Die App benötigt Cookies um richtig zu funktionieren.
     js_required: Das ist jetzt peinlich. Die App benötigt JavaScript um richtig zu funktionieren.
   import:
-    description: '%{link} deine Feeds aus Google Reader und importiere sie hier.'
-    export: Exportiere
+    description: Importieren Sie Ihre Feeds von einem anderen Dienst.
     fields:
       import: Importieren
       not_now: Nicht jetzt
diff --git a/config/locales/el-GR.yml b/config/locales/el-GR.yml
index 79f70e364..67b3491dc 100644
--- a/config/locales/el-GR.yml
+++ b/config/locales/el-GR.yml
@@ -25,11 +25,11 @@ el-GR:
       fields:
         feed_url: Διεύθυνση
         submit: Προσθήκη
-      flash:
-        added_successfully: Το καινούριο σας Ιστολόγιο προστέθηκε. Ελέγξτε παλι αργότερα.
-        already_subscribed_error: Είστε ήδη εγγεγραμενος σ' αυτο το ιστολόγιο...
-        feed_not_found_error: Δεν μπορέσαμε να βρούμε αυτο το ιστολόγιο. Προσπαθήστε ξανά.
       title: Όρεξη για καινούριες ειδήσεις?
+    create:
+      success: Το καινούριο σας Ιστολόγιο προστέθηκε. Ελέγξτε παλι αργότερα.
+      already_subscribed: Είστε ήδη εγγεγραμενος σ' αυτο το ιστολόγιο...
+      feed_not_found: Δεν μπορέσαμε να βρούμε αυτο το ιστολόγιο. Προσπαθήστε ξανά.
     edit:
       fields:
         feed_name: 
@@ -40,6 +40,8 @@ el-GR:
     index:
       add: εισάγεις
       add_some_feeds: Επ! Γιατί δεν %{add} μερικά ιστολόγια στη συλλογή σου?
+    destroy:
+      success: Η ροή διαγράφηκε.
   first_run:
     password:
       anti_social: αντι-κοινωνικό
@@ -56,8 +58,7 @@ el-GR:
     cookies_required: Έχετε την καλοσύνη να ενεργοποιήσετε τα Cookies ώστε να λειτουργήσει κανονικά η εφαρμογή?
     js_required: Δεν είναι και τόσο περίεργο, αλλά η εφαρμογή στηρίζεται στην Javascript.
   import:
-    description: Πάρε τα ιστολόγια σου απο δω %{link} και εισήγαγε τα.
-    export: Εξαγωγή
+    description: Εισαγάγετε τις ροές σας από άλλη υπηρεσία.
     fields:
       import: Εισαγωγή
       not_now: Όχι τώρα
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 8eb38af8d..0ad95644c 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -25,11 +25,11 @@ en:
       fields:
         feed_url: Feed URL
         submit: Add
-      flash:
-        added_successfully: We've added your new feed. Check back in a bit.
-        already_subscribed_error: You are already subscribed to this feed...
-        feed_not_found_error: We couldn't find that feed. Try again.
       title: Need new stories?
+    create:
+      success: We've added your new feed. Check back in a bit.
+      already_subscribed: You are already subscribed to this feed...
+      feed_not_found: We couldn't find that feed. Try again.
     edit:
       fields:
         feed_name: Feed Name
@@ -41,6 +41,8 @@ en:
     index:
       add: add
       add_some_feeds: Hey, you should %{add} some feeds.
+    destroy:
+      success: Feed deleted.
   first_run:
     password:
       anti_social: anti-social
@@ -57,19 +59,20 @@ en:
     cookies_required: Well, this is awkward. Cookies are required for the app to work properly.
     js_required: Well, this is awkward. Javascript is required for the app to work properly.
   import:
-    description: '%{link} your feeds from Google Reader and import them.'
-    export: Export
+    description: Import your feeds from another service.
     fields:
       import: Import
       not_now: Not now
     subtitle: Let's setup your feeds.
     title: Welcome aboard.
   layout:
+    admin_settings: Admin Settings
     back_to_work: Get back to work, slacker!
     export: Export
     hey: Hey!
     import: Import
     logout: Logout
+    profile: Profile
     support: Support
     title: stringer | your rss buddy
   partials:
@@ -114,20 +117,49 @@ en:
       archive: view all items
       go_make: go make something
       gtfo: Now stop reading blogs and
-      rss_zero: You've reached RSS Zero&trade;
+      rss_zero: You've reached RSS Zero™
+  passwords:
+    update:
+      success: Password updated
+      failure: 'Unable to update password: %{errors}'
+  profiles:
+    edit:
+      title: Edit profile
+      warning_html:
+        <strong>Warning!!!</strong> Editing your username or password will
+        change your API key as well. You will need to update your credentials
+        in any Fever clients you have connected to Stringer.
+    update:
+      success: Profile updated
+      failure: 'Unable to update profile: %{errors}'
   sessions:
     destroy:
       flash:
         logged_out_successfully: You have been signed out!
     new:
       fields:
+        unknown_username_html:
+          <strong>Note:</strong> if you didn't set a username when you set up
+          your stringer instance, then your username will be "stringer". You
+          can change this on your profile page after logging in.
+        username: Username
         password: Password
         submit: Login
       flash:
         wrong_password: That's the wrong password. Try again.
       rss: RSS
+      sign_up_html: Or <a href=%{href}>sign up</a>.
       subtitle: Welcome back, friend.
       title: Stringer speaks
+  settings:
+    index:
+      heading: Application Settings
+      description: Edit application wide settings
+      signup:
+        enabled: User signups are enabled
+        disabled: User signups are disabled
+        enable: Enable
+        disable: Disable
   starred:
     next: Next
     of: of
@@ -155,3 +187,10 @@ en:
     title: 'Stringer is '
     your_feeds: your feeds
     your_stories: your stories
+  activerecord:
+    attributes:
+      user:
+        stories_order: "Stories feed order"
+      user/stories_order:
+        asc: "Oldest first"
+        desc: "Newest first"
\ No newline at end of file
diff --git a/config/locales/eo.yml b/config/locales/eo.yml
index 4dd73b787..ce37826f9 100644
--- a/config/locales/eo.yml
+++ b/config/locales/eo.yml
@@ -25,11 +25,11 @@ eo:
       fields:
         feed_url: URL de fluo
         submit: Aldoni
-      flash:
-        added_successfully: Ni aldonis vian nova fluon. Revenu pli poste.
-        already_subscribed_error: Vi abonis tiun fluon jam...
-        feed_not_found_error: Ni ne eblas trovi tion fluon. Reprovu.
       title: Ĉu vi bezonas novajn rakontojn?
+    create:
+      success: Ni aldonis vian nova fluon. Revenu pli poste.
+      already_subscribed: Vi abonis tiun fluon jam...
+      feed_not_found: Ni ne eblas trovi tion fluon. Reprovu.
     edit:
       fields:
         feed_name: Nomo de fluo
@@ -40,6 +40,8 @@ eo:
     index:
       add: aldoni
       add_some_feeds: He, vi devas %{add} kelkajn fluojn.
+    destroy:
+      success: Fluo forigita.
   first_run:
     password:
       anti_social: kontraŭsocia
@@ -56,8 +58,7 @@ eo:
     cookies_required: Nu, mallerteco. Kuketoj estas postulita por funkcii dece.
     js_required: Nu, mallerteco. JavaScript estas postulita por funkcii dece.
   import:
-    description: '%{link} viajn fluojn de Google Reader kaj importi ilin.'
-    export: Eksporti
+    description: Importu viajn fluojn de alia servo.
     fields:
       import: Importi
       not_now: Ne nun
diff --git a/config/locales/es.yml b/config/locales/es.yml
index 6f7a21446..6d9371ba8 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -25,11 +25,11 @@ es:
       fields:
         feed_url: URL de la feed
         submit: Añadir
-      flash:
-        added_successfully: Hemos agregado tu nueva feed. Regresa en un ratito.
-        already_subscribed_error: Ya te suscribiste a esta feed...
-        feed_not_found_error: No pudimos encontrar esa feed. Inténtalo de vuelta.
       title: ¿Necesitas nuevas historias?
+    create:
+      success: Hemos agregado tu nueva feed. Regresa en un ratito.
+      already_subscribed: Ya te suscribiste a esta feed...
+      feed_not_found: No pudimos encontrar esa feed. Inténtalo de vuelta.
     edit:
       fields:
         feed_name: Nombre fuente
@@ -40,6 +40,8 @@ es:
     index:
       add: agregar
       add_some_feeds: Oye, deberias %{add} algunas feeds.
+    destroy:
+      success: Feed eliminado.
   first_run:
     password:
       anti_social: anti-social
@@ -56,8 +58,7 @@ es:
     cookies_required: Bueno esto es incomodo. Cookies son requeridas para que la aplicación funcione correctamente.
     js_required: Bueno esto es incomodo. JavaScript es requerido para que la aplicación funcione correctamente.
   import:
-    description: '%{link} tus feeds desde Google Reader e importalas.'
-    export: Exportar
+    description: Importa tus feeds desde otro servicio.
     fields:
       import: Importar
       not_now: No ahora
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 172e6328a..b20767fef 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -25,11 +25,11 @@ fr:
       fields:
         feed_url: URL du flux
         submit: Ajouter
-      flash:
-        added_successfully: Le nouveau flux a été ajouté. Patientez quelques instants.
-        already_subscribed_error: Vous suivez déjà ce flux...
-        feed_not_found_error: Nous n'avons pas pu trouver ce flux. Essayez de nouveau.
       title: Besoin de nouveaux articles ?
+    create:
+      success: Le nouveau flux a été ajouté. Patientez quelques instants.
+      already_subscribed: Vous suivez déjà ce flux...
+      feed_not_found: Nous n'avons pas pu trouver ce flux. Essayez de nouveau.
     edit:
       fields:
         feed_name: 
@@ -40,6 +40,8 @@ fr:
     index:
       add: ajouter
       add_some_feeds: Vous devriez %{add} quelques flux.
+    destroy:
+      success: Flux supprimé.
   first_run:
     password:
       anti_social: anti-social
@@ -56,8 +58,7 @@ fr:
     cookies_required: 'C''est embêtant : les cookies sont obligatoires pour le bon fonctionnement de l''application.'
     js_required: 'C''est embêtant : Javascript est obligatoire pour le bon fonctionnement de l''application.'
   import:
-    description: '%{link} vos flux provenant de Google Reader et importez-les.'
-    export: Exportez
+    description: Importez vos flux depuis un autre service.
     fields:
       import: Importez
       not_now: Pas maintenant
diff --git a/config/locales/he.yml b/config/locales/he.yml
index 9e6d2c96d..35462b88f 100644
--- a/config/locales/he.yml
+++ b/config/locales/he.yml
@@ -25,11 +25,11 @@ he:
       fields:
         feed_url: כתובת של הפיד
         submit: להוסיף
-      flash:
-        added_successfully: אנחנו הוספנו את הפיד החדש. עוד מעט תקבל עידכונים.
-        already_subscribed_error: הפיד הנ"ל כבר נמצא במעקב.
-        feed_not_found_error: לא הצלחנו למצוא את הפיד. נסה שוב.
       title: מחפש מה לקרוא?
+    create:
+      success: אנחנו הוספנו את הפיד החדש. עוד מעט תקבל עידכונים.
+      already_subscribed: הפיד הנ"ל כבר נמצא במעקב.
+      feed_not_found: לא הצלחנו למצוא את הפיד. נסה שוב.
     edit:
       fields:
         feed_name: 
@@ -40,6 +40,8 @@ he:
     index:
       add: להוסיף
       add_some_feeds: הי, כדאי %{add} קצת פידים.
+    destroy:
+      success: הפיד נמחק.
   first_run:
     password:
       anti_social: אינו חברתי
@@ -56,7 +58,7 @@ he:
     cookies_required: 
     js_required: 
   import:
-    description: '%{link} את הפיד שלך מ- Google Reader ויבא אותו.'
+    description: ייבא את העדכונים שלך משירות אחר.
     export: יצוא
     fields:
       import: יבוא
diff --git a/config/locales/it.yml b/config/locales/it.yml
index d0b4cbaea..550e47bbe 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -25,11 +25,11 @@ it:
       fields:
         feed_url: URL del feed
         submit: Aggiungi
-      flash:
-        added_successfully: Abbiamo aggiunto il tuo nuovo feed. Ripassa tra qualche istante.
-        already_subscribed_error: Sei già sottoscritto a questo feed...
-        feed_not_found_error: Non siamo riusciti a trovare il feed. Riprova.
       title: Bisogno di nuove storie?
+    create:
+      success: Abbiamo aggiunto il tuo nuovo feed. Ripassa tra qualche istante.
+      already_subscribed: Sei già sottoscritto a questo feed...
+      feed_not_found: Non siamo riusciti a trovare il feed. Riprova.
     edit:
       fields:
         feed_name: 
@@ -40,6 +40,8 @@ it:
     index:
       add: aggiungere
       add_some_feeds: Hey, dovresti %{add} qualche feed.
+    destroy:
+      success: Feed eliminato.
   first_run:
     password:
       anti_social: anti-social
@@ -56,8 +58,7 @@ it:
     cookies_required: Beh, questo sì che è strano. L'app necessita di cookie per funzionare correttamente.
     js_required: Beh, questo sì che è strano. L'app necessita di Javascript attivato per funzionare correttamente.
   import:
-    description: '%{link} i tuoi feed da Google Reader e importali qui.'
-    export: Esporta
+    description: Importa i tuoi feed da un altro servizio.
     fields:
       import: Importa
       not_now: Non adesso
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 4f30eb756..0e227cf2e 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -25,11 +25,11 @@ ja:
       fields:
         feed_url: フィードURL
         submit: 追加
-      flash:
-        added_successfully: 新しいフィードを追加しました、少し経ってから確認して下さい
-        already_subscribed_error: このフィードは既に登録されてます
-        feed_not_found_error: フィードを見つけられませんでした、もう一度試して下さい
       title: 新しいストーリーが必要ですか?
+    create:
+      success: 新しいフィードを追加しました、少し経ってから確認して下さい
+      already_subscribed: このフィードは既に登録されてます
+      feed_not_found: フィードを見つけられませんでした、もう一度試して下さい
     edit:
       fields:
         feed_name: フィード名
@@ -40,6 +40,8 @@ ja:
     index:
       add: 追加
       add_some_feeds: 何かフィードを%{add}する必要があります
+    destroy:
+      success: フィードを削除しました。
   first_run:
     password:
       anti_social: アンチソーシャル
@@ -56,8 +58,7 @@ ja:
     cookies_required: 正常に動作するためにクッキーを有効にして下さい
     js_required: 正常に動作するためにJavascriptを有効にして下さい
   import:
-    description: '%{link} あなたのGoogle Readerからのフィードです、それを取り込めます'
-    export: 書き出し
+    description: 別のサービスからフィードをインポートします。
     fields:
       import: 取り込み
       not_now: いまはやらない
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 115a908e4..ceb853806 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -25,11 +25,11 @@ nl:
       fields:
         feed_url: Feed-URL
         submit: Toevoegen
-      flash:
-        added_successfully: We hebben je nieuwe feed toegevoegd. Kijk over een tijdje nog eens.
-        already_subscribed_error: Je bent al geabonneerd op deze feed...
-        feed_not_found_error: Die feed konden we niet vinden. Probeer het opnieuw.
       title: Nieuwe artikelen nodig?
+    create:
+      success: We hebben je nieuwe feed toegevoegd. Kijk over een tijdje nog eens.
+      already_subscribed: Je bent al geabonneerd op deze feed...
+      feed_not_found: Die feed konden we niet vinden. Probeer het opnieuw.
     edit:
       fields:
         feed_name: Feednaam
@@ -40,6 +40,8 @@ nl:
     index:
       add: toevoegen
       add_some_feeds: Hé, je zou eens wat feeds kunnen %{add}.
+    destroy:
+      success: Voer verwijderd.
   first_run:
     password:
       anti_social: anti-sociaal
@@ -56,8 +58,7 @@ nl:
     cookies_required: 'Wel, dit is vervelend: deze applicatie heeft cookies nodig om goed te werken.'
     js_required: 'Wel, dit is vervelend: deze applicatie heeft Javascript nodig om goed te werken.'
   import:
-    description: '%{link} je feeds uit Google Reader en importeer ze.'
-    export: Exporteer
+    description: Importeer uw feeds van een andere service.
     fields:
       import: Importeren
       not_now: Niet nu
diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml
index 0a18bfe00..5b04cbdc0 100644
--- a/config/locales/pt-BR.yml
+++ b/config/locales/pt-BR.yml
@@ -25,11 +25,11 @@ pt-BR:
       fields:
         feed_url: URL do Feed
         submit: Adicionar
-      flash:
-        added_successfully: Nós estamos adicionando um novo feed. Volte daqui a pouco.
-        already_subscribed_error: Você já está inscrito neste feed...
-        feed_not_found_error: Não conseguimos achar este feed. Tente novamente.
       title: Precisa de novas histórias?
+    create:
+      success: Nós estamos adicionando um novo feed. Volte daqui a pouco.
+      already_subscribed: Você já está inscrito neste feed...
+      feed_not_found: Não conseguimos achar este feed. Tente novamente.
     edit:
       fields:
         feed_name: 
@@ -40,6 +40,8 @@ pt-BR:
     index:
       add: adicionar
       add_some_feeds: Ei, você deveria %{add} alguns feeds.
+    destroy:
+      success: Feed deletado.
   first_run:
     password:
       anti_social: anti-social
@@ -56,8 +58,7 @@ pt-BR:
     cookies_required: Bem, isso é estranho. É necessário habilitar Cookies para que o aplicativo funcione corretamente.
     js_required: Bem, isso é estranho. É necessário habilitar Javascript para que o aplicativo funcione corretamente.
   import:
-    description: '%{link} seus feeds do Google Reader e importe-os.'
-    export: Exportar
+    description: Importe seus feeds de outro serviço.
     fields:
       import: Importar
       not_now: Agora não
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index 054b19bb8..ae3ad0544 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -25,11 +25,11 @@ pt:
       fields:
         feed_url: URL da Feed
         submit: Adicionar
-      flash:
-        added_successfully: Adicionamos a sua nova feed. Verifique novamente mais tarde.
-        already_subscribed_error: Você já subscreveu esta feed...
-        feed_not_found_error: Não foi possível encontrar a feed. Tente novamente.
       title: Precisa de novas histórias?
+    create:
+      success: Adicionamos a sua nova feed. Verifique novamente mais tarde.
+      already_subscribed: Você já subscreveu esta feed...
+      feed_not_found: Não foi possível encontrar a feed. Tente novamente.
     edit:
       fields:
         feed_name: 
@@ -40,6 +40,8 @@ pt:
     index:
       add: adicionar
       add_some_feeds: Ei, você deveria %{add} algumas feeds.
+    destroy:
+      success:
   first_run:
     password:
       anti_social: anti-social
@@ -56,8 +58,7 @@ pt:
     cookies_required: 
     js_required: 
   import:
-    description: '%{link} as suas feeds do Google Reader e importe-as.'
-    export: Exportar
+    description: Importe seus feeds de outro serviço.
     fields:
       import: Importar
       not_now: Agora não
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 30299b1ab..fb3ad5dbf 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -25,11 +25,11 @@ ru:
       fields:
         feed_url: URL фида
         submit: Добавить
-      flash:
-        added_successfully: Мы добавили новый фид. Скоро информация обновится.
-        already_subscribed_error: Вы уже подписаны на этот фид...
-        feed_not_found_error: Мы не смогли найти этот фид. Попробуйте еще раз.
       title: Нужны новые истории?
+    create:
+      success: Мы добавили новый фид. Скоро информация обновится.
+      already_subscribed: Вы уже подписаны на этот фид...
+      feed_not_found: Мы не смогли найти этот фид. Попробуйте еще раз.
     edit:
       fields:
         feed_name: 
@@ -39,7 +39,9 @@ ru:
         updated_successfully: 
     index:
       add: добавить
-      add_some_feeds: Эй, ​​Вы должны %{add} некоторые фид каналы.
+      add_some_feeds: Эй, Вы должны %{add} некоторые фид каналы.
+    destroy:
+      success: Фид удален.
   first_run:
     password:
       anti_social: антиобщественное
@@ -56,8 +58,7 @@ ru:
     cookies_required: Ну, это неудобно. Cookies необходимы, чтобы приложение работало корректно.
     js_required: Ну, это неудобно. Javascript необходим, чтобы приложение работало корректно.
   import:
-    description: '%{link} фиды из Google Reader и импортировать их.'
-    export: Экспортировать
+    description: Импортируйте свои каналы из другого сервиса.
     fields:
       import: Импортировать
       not_now: Не сейчас
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index 12487e118..d24adef24 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -25,11 +25,11 @@ sv:
       fields:
         feed_url: Feedens URL
         submit: Lägg till
-      flash:
-        added_successfully: Vi har lagt till din nya feed. Kom tillbaks om en stund.
-        already_subscribed_error: Du prenumererar redan på den här feeden...
-        feed_not_found_error: Vi kunde inte hitta feeden. Prova igen.
       title: Behöver du nya berättelser?
+    create:
+      success: Vi har lagt till din nya feed. Kom tillbaks om en stund.
+      already_subscribed: Du prenumererar redan på den här feeden...
+      feed_not_found: Vi kunde inte hitta feeden. Prova igen.
     edit:
       fields:
         feed_name: Feednamn
@@ -40,6 +40,8 @@ sv:
     index:
       add: lägga till
       add_some_feeds: Hej, du borde %{add} några feeds.
+    destroy:
+      success: Flödet raderat.
   first_run:
     password:
       anti_social: anti-social
@@ -56,8 +58,7 @@ sv:
     cookies_required: Jaha, det här var tråkigt. Cookies behövs för att appen ska fungera.
     js_required: Jaha, det här var tråkigt. Javascript behövs för att appen ska fungera.
   import:
-    description: '%{link} dina feeds från Google Reader och importera dem.'
-    export: Exportera
+    description: Importera dina flöden från en annan tjänst.
     fields:
       import: Importera
       not_now: Inte nu
diff --git a/config/locales/tr.yml b/config/locales/tr.yml
index 26523682f..7b5ec359d 100644
--- a/config/locales/tr.yml
+++ b/config/locales/tr.yml
@@ -25,11 +25,11 @@ tr:
       fields:
         feed_url: Besleme URL'si
         submit: Ekle
-      flash:
-        added_successfully: Yeni besleme eklenmistir. Sonra kontrol edin.
-        already_subscribed_error: Bu beslemeye zaten kayitlisiniz...
-        feed_not_found_error: Bu beslemeyi bulamadik. Tekrar deneyiniz.
       title: Yeni hikayelere mi ihtiyaciniz var?
+    create:
+      success: Yeni besleme eklenmistir. Sonra kontrol edin.
+      already_subscribed: Bu beslemeye zaten kayitlisiniz...
+      feed_not_found: Bu beslemeyi bulamadik. Tekrar deneyiniz.
     edit:
       fields:
         feed_name: 
@@ -40,6 +40,8 @@ tr:
     index:
       add: ekle
       add_some_feeds: Hey, you should %{add} some feeds.
+    destroy:
+      success: Besleme silindi.
   first_run:
     password:
       anti_social: anti-social
@@ -56,8 +58,7 @@ tr:
     cookies_required: Aaa, garip!. Uygulamanin duzgun calismasi icin Cookies gerekli.
     js_required: Aaa, garip. Uygulamanin duzgun calismasi icin Javascript gerekli.
   import:
-    description: 
-    export: Disa Aktar
+    description: Beslemelerinizi başka bir hizmetten içe aktarın.
     fields:
       import: Ekle
       not_now: Simdi Degil
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 275dee1ad..3dda78636 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -25,11 +25,11 @@ zh-CN:
       fields:
         feed_url: 供稿地址
         submit: 添加
-      flash:
-        added_successfully: 你的订阅已经添加完毕，稍等一段时间就可以阅读啦
-        already_subscribed_error: 你已经订阅过这个供稿了哟...
-        feed_not_found_error: 呃，我们无法识别这个供稿地址。麻烦你检查后再试一次。
       title: 想要添加新内容？
+    create:
+      success: 你的订阅已经添加完毕，稍等一段时间就可以阅读啦
+      already_subscribed: 你已经订阅过这个供稿了哟...
+      feed_not_found: 呃，我们无法识别这个供稿地址。麻烦你检查后再试一次。
     edit:
       fields:
         feed_name: 
@@ -40,6 +40,8 @@ zh-CN:
     index:
       add: 添加
       add_some_feeds: 你应该%{add}一些订阅哟~
+    destroy:
+      success: 提要已删除。
   first_run:
     password:
       anti_social: 反社交化的
@@ -56,8 +58,7 @@ zh-CN:
     cookies_required: 抱歉，你的浏览器禁用了Cookie记录功能。为了你的正常使用，请开启浏览器的Cookie功能。
     js_required: 抱歉，你的浏览器禁用了Javascript脚本。为了你的正常使用，请开启脚本功能。
   import:
-    description: '%{link}你在Google Reader上的订阅列表然后再将它们导入这里'
-    export: 导出
+    description: 从其他服务导入您的提要。
     fields:
       import: 导入
       not_now: 稍后再说
diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml
new file mode 100644
index 000000000..080fef5ce
--- /dev/null
+++ b/config/locales/zh-TW.yml
@@ -0,0 +1,195 @@
+zh-TW:
+  archive:
+    next: 下一頁
+    of: 之
+    previous: 上一頁
+    sorry: 您目前還沒有任何已讀的文章！
+  date:
+    abbr_month_names:
+    - 
+    - 1 月
+    - 2 月
+    - 3 月
+    - 4 月
+    - 5 月
+    - 6 月
+    - 7 月
+    - 8 月
+    - 9 月
+    - 10 月
+    - 11 月
+    - 12 月
+  feeds:
+    add:
+      description: 請輸入您要訂閱的 RSS 來源網址。
+      fields:
+        feed_url: RSS 網址
+        submit: 新增
+      title: 想要新增新內容嗎？
+    create:
+      success: 您的訂閱已經新增完畢，請稍候一段時間再回來確認。
+      already_subscribed: 您已經訂閱過這個 RSS 了...
+      feed_not_found: 我們找不到這個 RSS，請檢查後再試一次。
+    edit:
+      fields:
+        feed_name: RSS 名稱
+        feed_url: RSS 網址
+        group: 群組
+        submit: 儲存
+      flash:
+        updated_successfully: 已為您更新 RSS 訂閱！
+    index:
+      add: 新增
+      add_some_feeds: 嘿！您應該要%{add}一些 RSS 訂閱。
+    destroy:
+      success: RSS 已刪除。
+  first_run:
+    password:
+      anti_social: 反社交化的
+      description: 讓我們設定一組您的密碼，然後您就可以開始享受獨自閱讀的樂趣了。
+      fields:
+        next: 下一步
+        password: 密碼
+        password_confirmation: 確認密碼
+      flash:
+        passwords_dont_match: 您兩次輸入的密碼不同，麻煩確認並重新輸入。
+      subtitle: 這裡只有一位使用者，那就是您。
+      title: Stringer 是
+  flash:
+    cookies_required: 抱歉，您的瀏覽器似乎停用了 Cookie，請啟用瀏覽器的 Cookie 功能才能正常使用。
+    js_required: 抱歉，您的瀏覽器似乎停用了 JavaScript，請啟用瀏覽器的 JavaScript 功能才能正常使用。
+  import:
+    description: 從其他服務匯入您的 RSS 訂閱。
+    fields:
+      import: 匯入
+      not_now: 稍後再說
+    subtitle: 讓我們開始設定您的 RSS 訂閱吧。
+    title: 歡迎使用。
+  layout:
+    admin_settings: 管理員設定
+    back_to_work: 該回去工作了，偷懶鬼！
+    export: 匯出
+    hey: 嘿！
+    import: 匯入
+    logout: 登出
+    profile: 個人資料
+    support: 支援
+    title: stringer | 您的 RSS 好夥伴
+  partials:
+    action_bar:
+      add_feed: 新增訂閱
+      archived_stories: 已封存的文章
+      mark_all: 全部標示為已讀
+      refresh: 重新整理
+      starred_stories: 星號標記的文章
+      view_feeds: 檢視訂閱列表
+    feed:
+      last_fetched:
+        never: 尚未成功讀取過
+      last_updated: 最後更新於
+      status_bubble:
+        green: 成功！
+        red: 解析時出現錯誤（而且從來沒成功過！此訂閱可能已經失效！）
+        yellow: 解析時出現錯誤（可能只是暫時的）
+    feed_action_bar:
+      add_feed: 新增訂閱
+      archived_stories: 已封存的文章
+      feeds: 檢視訂閱列表
+      home: 返回文章列表
+      starred_stories: 星號標記的文章
+    shortcuts:
+      keys:
+        a: 新增訂閱
+        bv: 打開文章網址
+        f: 打開 RSS 頁面
+        jk: 下一篇/上一篇文章
+        left: 上一頁
+        m: 標示文章為已讀/未讀
+        np: 向下/向上移動
+        oenter: 點選打開/關閉
+        or: 或
+        r: 重新整理
+        right: 下一頁
+        s: 為文章加上/移除星號
+        shifta: 全部標示為已讀
+      title: 鍵盤快速鍵
+    zen:
+      archive: 查看所有文章
+      go_make: 去做些什麼吧
+      gtfo: 現在停止閱讀然後
+      rss_zero: 您已達成清空 RSS 的任務 (RSS Zero™)
+  passwords:
+    update:
+      success: 密碼已更新
+      failure: '無法更新密碼：%{errors}'
+  profiles:
+    edit:
+      title: 編輯個人資料
+      warning_html:
+        <strong>警告！</strong>編輯您的使用者名稱或密碼將同時
+        變更您的 API 金鑰。您需要更新任何已連接至 Stringer 的
+        Fever 客戶端中的認證資訊。
+    update:
+      success: 個人資料已更新
+      failure: '無法更新個人資料：%{errors}'
+  sessions:
+    destroy:
+      flash:
+        logged_out_successfully: 您已成功登出！
+    new:
+      fields:
+        unknown_username_html:
+          <strong>注意：</strong>如果您在設定 Stringer 時沒有設定使用者名稱，
+          則您的使用者名稱將會是「stringer」。登入後您可以在個人資料頁面中修改。
+        username: 使用者名稱
+        password: 密碼
+        submit: 登入
+      flash:
+        wrong_password: 您輸入的密碼錯誤，請重新輸入。
+      rss: RSS
+      sign_up_html: 或<a href=%{href}>註冊</a>。
+      subtitle: 歡迎回來，我的朋友。
+      title: Stringer 說
+  settings:
+    index:
+      heading: 應用程式設定
+      description: 編輯應用程式通用設定
+      signup:
+        enabled: 使用者註冊已啟用
+        disabled: 使用者註冊已停用
+        enable: 啟用
+        disable: 停用
+  starred:
+    next: 下一頁
+    of: 之
+    previous: 上一頁
+    sorry: 抱歉，您還沒有為任何文章加上星號！
+  stories:
+    keep_unread: 保持未讀
+  time:
+    formats:
+      default: '%b %d 日 %H:%M'
+  tutorial:
+    add_feed: 新增訂閱
+    as_read: 為已讀
+    click_to_read: （點選閱讀）
+    description: 我們正在為您取得訂閱的內容，請稍候。
+    heroku_hourly_task: 您需要新增一個每小時執行的工作來檢查新文章。
+    heroku_one_more_thing: 還有一件事...
+    heroku_scheduler: 前往 Heroku Scheduler 並新增這個工作
+    mark_all: 全部標示
+    ready: 好了，準備就緒！
+    refresh: 重新整理
+    simple: 簡單的
+    start: 開始閱讀
+    subtitle: 這是一份簡單的使用手冊。
+    title: 'Stringer 是 '
+    your_feeds: 您的 RSS
+    your_stories: 您的文章
+  activerecord:
+    attributes:
+      user:
+        stories_order: "文章排序方式"
+      user/stories_order:
+        asc: "最舊優先"
+        desc: "最新優先"
\ No newline at end of file
diff --git a/config/puma.rb b/config/puma.rb
new file mode 100644
index 000000000..294f29076
--- /dev/null
+++ b/config/puma.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+# This configuration file will be evaluated by Puma. The top-level methods that
+# are invoked here are part of Puma's configuration DSL. For more information
+# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
+#
+# Puma starts a configurable number of processes (workers) and each process
+# serves each request in a thread from an internal thread pool.
+#
+# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
+# should only set this value when you want to run 2 or more workers. The
+# default is already 1.
+#
+# The ideal number of threads per worker depends both on how much time the
+# application spends waiting for IO operations and on how much you wish to
+# prioritize throughput over latency.
+#
+# As a rule of thumb, increasing the number of threads will increase how much
+# traffic a given process can handle (throughput), but due to CRuby's
+# Global VM Lock (GVL) it has diminishing returns and will degrade the
+# response time (latency) of the application.
+#
+# The default is set to 3 threads as it's deemed a decent compromise between
+# throughput and latency for the average Rails application.
+#
+# Any libraries that use a connection pool or another resource pool should
+# be configured to provide at least as many connections as the number of
+# threads. This includes Active Record's `pool` parameter in `database.yml`.
+threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
+threads threads_count, threads_count
+
+# Specifies the `port` that Puma will listen on to receive requests; default is
+# 3000.
+port ENV.fetch("PORT", 3000)
+
+# Allow puma to be restarted by `bin/rails restart` command.
+plugin :tmp_restart
+
+# Run the Solid Queue supervisor inside of Puma for single-server deployments
+plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]
+
+# Specify the PID file. Defaults to tmp/pids/server.pid in development.
+# In other environments, only set the PID file if requested.
+pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 000000000..3016a3e66
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require_relative "../lib/admin_constraint"
+
+Rails.application.routes.draw do
+  scope :admin, constraints: AdminConstraint.new do
+    mount GoodJob::Engine => "good_job"
+
+    resources :settings, only: [:index, :update]
+    get "/debug", to: "debug#index"
+  end
+
+  resource :profile, only: [:edit, :update]
+  resource :password, only: [:update]
+
+  get "/", to: "stories#index"
+  get "/fever", to: "fever#index"
+  post "/fever", to: "fever#update"
+  get "/archive", to: "stories#archived"
+  get "/feed/:feed_id", to: "feeds#show"
+  post "/feeds", to: "feeds#create"
+  get "/feeds", to: "feeds#index"
+  delete "/feeds/:id", to: "feeds#destroy"
+  put "/feeds/:id", to: "feeds#update"
+  get "/feeds/:id/edit", to: "feeds#edit"
+  get "/feeds/export", to: "exports#index"
+  post "/feeds/import", to: "imports#create"
+  get "/feeds/import", to: "imports#new"
+  get "/feeds/new", to: "feeds#new"
+  get "/heroku", to: "debug#heroku"
+  post "/login", to: "sessions#create"
+  get "/login", to: "sessions#new"
+  get "/logout", to: "sessions#destroy"
+  get "/news", to: "stories#index"
+  post "/setup/password", to: "passwords#create"
+  get "/setup/password", to: "passwords#new"
+  get "/setup/tutorial", to: "tutorials#index"
+  get "/starred", to: "stories#starred"
+  put "/stories/:id", to: "stories#update"
+  post "/stories/mark_all_as_read", to: "stories#mark_all_as_read"
+
+  unless Rails.env.production?
+    require_relative "../spec/javascript/test_controller"
+
+    get "/test", to: "test#index"
+    get "/spec/*splat", to: "test#spec"
+    get "/vendor/js/*splat", to: "test#vendor"
+    get "/vendor/css/*splat", to: "test#vendor"
+  end
+end
diff --git a/config/unicorn.rb b/config/unicorn.rb
deleted file mode 100644
index 0171aba70..000000000
--- a/config/unicorn.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-worker_processes 1
-timeout 30
-preload_app true
-
-@delayed_job_pid = nil
-
-before_fork do |_server, _worker|
-  # the following is highly recommended for Rails + "preload_app true"
-  # as there's no need for the master process to hold a connection
-  ActiveRecord::Base.connection.disconnect! if defined?(ActiveRecord::Base)
-
-  @delayed_job_pid ||= spawn("bundle exec rake work_jobs") unless ENV["WORKER_EMBEDDED"] == "false"
-
-  sleep 1
-end
-
-after_fork do |_server, _worker|
-  if defined?(ActiveRecord::Base)
-    env = ENV["RACK_ENV"] || "development"
-    config = YAML.load(ERB.new(File.read("config/database.yml")).result)[env]
-    ActiveRecord::Base.establish_connection(config)
-  end
-end
diff --git a/db/migrate/20130409010818_create_feeds.rb b/db/migrate/20130409010818_create_feeds.rb
index c1eec66df..26b2ca2e0 100644
--- a/db/migrate/20130409010818_create_feeds.rb
+++ b/db/migrate/20130409010818_create_feeds.rb
@@ -1,11 +1,13 @@
-class CreateFeeds < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class CreateFeeds < ActiveRecord::Migration[4.2]
   def change
     create_table :feeds do |t|
       t.string :name
       t.string :url
       t.timestamp :last_fetched
 
-      t.timestamps
+      t.timestamps null: false
     end
   end
 end
diff --git a/db/migrate/20130409010826_create_stories.rb b/db/migrate/20130409010826_create_stories.rb
index 0d9bcc91e..73b494077 100644
--- a/db/migrate/20130409010826_create_stories.rb
+++ b/db/migrate/20130409010826_create_stories.rb
@@ -1,4 +1,6 @@
-class CreateStories < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class CreateStories < ActiveRecord::Migration[4.2]
   def change
     create_table :stories do |t|
       t.string :title
@@ -7,7 +9,7 @@ def change
 
       t.references :feed
 
-      t.timestamps
+      t.timestamps null: false
     end
   end
 end
diff --git a/db/migrate/20130412185253_add_new_fields_to_stories.rb b/db/migrate/20130412185253_add_new_fields_to_stories.rb
index 58916f7f6..e3c95eda9 100644
--- a/db/migrate/20130412185253_add_new_fields_to_stories.rb
+++ b/db/migrate/20130412185253_add_new_fields_to_stories.rb
@@ -1,4 +1,6 @@
-class AddNewFieldsToStories < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddNewFieldsToStories < ActiveRecord::Migration[4.2]
   def change
     add_column :stories, :published, :timestamp
     add_column :stories, :is_read, :boolean
diff --git a/db/migrate/20130418221144_add_user_model.rb b/db/migrate/20130418221144_add_user_model.rb
index f454fc12e..6c8ccb858 100644
--- a/db/migrate/20130418221144_add_user_model.rb
+++ b/db/migrate/20130418221144_add_user_model.rb
@@ -1,10 +1,12 @@
-class AddUserModel < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddUserModel < ActiveRecord::Migration[4.2]
   def change
     create_table :users do |t|
       t.string :email
       t.string :password_digest
 
-      t.timestamps
+      t.timestamps null: false
     end
   end
 end
diff --git a/db/migrate/20130423001740_drop_email_from_user.rb b/db/migrate/20130423001740_drop_email_from_user.rb
index e07e5eda1..9579bdfa7 100644
--- a/db/migrate/20130423001740_drop_email_from_user.rb
+++ b/db/migrate/20130423001740_drop_email_from_user.rb
@@ -1,4 +1,6 @@
-class DropEmailFromUser < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class DropEmailFromUser < ActiveRecord::Migration[4.2]
   def up
     remove_column :users, :email
   end
diff --git a/db/migrate/20130423180446_remove_author_from_stories.rb b/db/migrate/20130423180446_remove_author_from_stories.rb
index b38517f6d..37061a611 100644
--- a/db/migrate/20130423180446_remove_author_from_stories.rb
+++ b/db/migrate/20130423180446_remove_author_from_stories.rb
@@ -1,4 +1,6 @@
-class RemoveAuthorFromStories < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class RemoveAuthorFromStories < ActiveRecord::Migration[4.2]
   def up
     remove_column :stories, :author
   end
diff --git a/db/migrate/20130425211008_add_setup_complete_to_user.rb b/db/migrate/20130425211008_add_setup_complete_to_user.rb
index 1e5a57f1c..39f29aacb 100644
--- a/db/migrate/20130425211008_add_setup_complete_to_user.rb
+++ b/db/migrate/20130425211008_add_setup_complete_to_user.rb
@@ -1,4 +1,6 @@
-class AddSetupCompleteToUser < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddSetupCompleteToUser < ActiveRecord::Migration[4.2]
   def change
     add_column :users, :setup_complete, :boolean
   end
diff --git a/db/migrate/20130425222157_add_delayed_job.rb b/db/migrate/20130425222157_add_delayed_job.rb
index 6c32be2ca..d4afefab8 100644
--- a/db/migrate/20130425222157_add_delayed_job.rb
+++ b/db/migrate/20130425222157_add_delayed_job.rb
@@ -1,4 +1,6 @@
-class AddDelayedJob < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddDelayedJob < ActiveRecord::Migration[4.2]
   def self.up
     create_table :delayed_jobs, force: true do |table|
       # Allows some jobs to jump to the front of the queue
@@ -30,7 +32,7 @@ def self.up
       # The name of the queue this job is in
       table.string :queue
 
-      table.timestamps
+      table.timestamps null: false
     end
 
     add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority"
diff --git a/db/migrate/20130429232127_add_status_to_feeds.rb b/db/migrate/20130429232127_add_status_to_feeds.rb
index da45f32bf..a458e8528 100644
--- a/db/migrate/20130429232127_add_status_to_feeds.rb
+++ b/db/migrate/20130429232127_add_status_to_feeds.rb
@@ -1,4 +1,6 @@
-class AddStatusToFeeds < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddStatusToFeeds < ActiveRecord::Migration[4.2]
   def change
     add_column :feeds, :status, :int
   end
diff --git a/db/migrate/20130504005816_text_url.rb b/db/migrate/20130504005816_text_url.rb
index a62d7a10d..57e02d5cb 100644
--- a/db/migrate/20130504005816_text_url.rb
+++ b/db/migrate/20130504005816_text_url.rb
@@ -1,4 +1,6 @@
-class TextUrl < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class TextUrl < ActiveRecord::Migration[4.2]
   def up
     change_column :feeds, :url, :text
   end
diff --git a/db/migrate/20130504022615_change_story_permalink_column.rb b/db/migrate/20130504022615_change_story_permalink_column.rb
index 69297bc9e..503a2dbd9 100644
--- a/db/migrate/20130504022615_change_story_permalink_column.rb
+++ b/db/migrate/20130504022615_change_story_permalink_column.rb
@@ -1,4 +1,6 @@
-class ChangeStoryPermalinkColumn < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class ChangeStoryPermalinkColumn < ActiveRecord::Migration[4.2]
   def up
     change_column :stories, :permalink, :text
   end
diff --git a/db/migrate/20130509131045_add_unique_constraints.rb b/db/migrate/20130509131045_add_unique_constraints.rb
index fad3d9f2a..270dde2ba 100644
--- a/db/migrate/20130509131045_add_unique_constraints.rb
+++ b/db/migrate/20130509131045_add_unique_constraints.rb
@@ -1,4 +1,6 @@
-class AddUniqueConstraints < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddUniqueConstraints < ActiveRecord::Migration[4.2]
   def change
     add_index :stories, [:permalink, :feed_id], unique: true
     add_index :feeds, :url, unique: true
diff --git a/db/migrate/20130513025939_add_keep_unread_to_stories.rb b/db/migrate/20130513025939_add_keep_unread_to_stories.rb
index b3311f32c..98b42cdf1 100644
--- a/db/migrate/20130513025939_add_keep_unread_to_stories.rb
+++ b/db/migrate/20130513025939_add_keep_unread_to_stories.rb
@@ -1,4 +1,6 @@
-class AddKeepUnreadToStories < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddKeepUnreadToStories < ActiveRecord::Migration[4.2]
   def change
     add_column :stories, :keep_unread, :boolean, default: false
   end
diff --git a/db/migrate/20130513044029_add_is_starred_status_for_stories.rb b/db/migrate/20130513044029_add_is_starred_status_for_stories.rb
index 63423ab31..4d64c1b19 100644
--- a/db/migrate/20130513044029_add_is_starred_status_for_stories.rb
+++ b/db/migrate/20130513044029_add_is_starred_status_for_stories.rb
@@ -1,4 +1,6 @@
-class AddIsStarredStatusForStories < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddIsStarredStatusForStories < ActiveRecord::Migration[4.2]
   def change
     add_column :stories, :is_starred, :boolean, default: false
   end
diff --git a/db/migrate/20130522014405_add_api_key_to_user.rb b/db/migrate/20130522014405_add_api_key_to_user.rb
index b76d63c02..d8942cfdc 100644
--- a/db/migrate/20130522014405_add_api_key_to_user.rb
+++ b/db/migrate/20130522014405_add_api_key_to_user.rb
@@ -1,4 +1,6 @@
-class AddApiKeyToUser < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddAPIKeyToUser < ActiveRecord::Migration[4.2]
   def change
     add_column :users, :api_key, :string
   end
diff --git a/db/migrate/20130730120312_add_entry_id_to_stories.rb b/db/migrate/20130730120312_add_entry_id_to_stories.rb
index 447e06ca1..fe25c77ed 100644
--- a/db/migrate/20130730120312_add_entry_id_to_stories.rb
+++ b/db/migrate/20130730120312_add_entry_id_to_stories.rb
@@ -1,4 +1,6 @@
-class AddEntryIdToStories < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddEntryIdToStories < ActiveRecord::Migration[4.2]
   def change
     add_column :stories, :entry_id, :string
   end
diff --git a/db/migrate/20130805113712_update_stories_unique_constraints.rb b/db/migrate/20130805113712_update_stories_unique_constraints.rb
index 0cb354727..326049b16 100644
--- a/db/migrate/20130805113712_update_stories_unique_constraints.rb
+++ b/db/migrate/20130805113712_update_stories_unique_constraints.rb
@@ -1,11 +1,19 @@
-class UpdateStoriesUniqueConstraints < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class UpdateStoriesUniqueConstraints < ActiveRecord::Migration[4.2]
   def up
     remove_index :stories, [:permalink, :feed_id]
-    add_index :stories, [:entry_id, :feed_id], unique: true, length: { permalink: 767 }
+    add_index :stories,
+              [:entry_id, :feed_id],
+              unique: true,
+              length: { permalink: 767 }
   end
 
   def down
     remove_index :stories, [:entry_id, :feed_id]
-    add_index :stories, [:permalink, :feed_id], unique: true, length: { permalink: 767 }
+    add_index :stories,
+              [:permalink, :feed_id],
+              unique: true,
+              length: { permalink: 767 }
   end
 end
diff --git a/db/migrate/20130821020313_update_nil_entry_ids.rb b/db/migrate/20130821020313_update_nil_entry_ids.rb
index 57739f60d..62773f217 100644
--- a/db/migrate/20130821020313_update_nil_entry_ids.rb
+++ b/db/migrate/20130821020313_update_nil_entry_ids.rb
@@ -1,12 +1,14 @@
-class UpdateNilEntryIds < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class UpdateNilEntryIds < ActiveRecord::Migration[4.2]
   def up
-    Story.where(entry_id: nil).each do |story|
+    Story.where(entry_id: nil).find_each do |story|
       story.entry_id = story.permalink || story.id
       story.save
     end
   end
 
-  def self.down
+  def down
     # skip
   end
 end
diff --git a/db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb b/db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb
index a08210f18..1d065d2b1 100644
--- a/db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb
+++ b/db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb
@@ -1,10 +1,12 @@
-class UseTextDatatypeForTitleAndEntryId < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class UseTextDatatypeForTitleAndEntryId < ActiveRecord::Migration[4.2]
   def up
     change_column :stories, :title, :text
     change_column :stories, :entry_id, :text
   end
 
-  def self.down
+  def down
     change_column :stories, :title, :string
     change_column :stories, :entry_id, :string
   end
diff --git a/db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb b/db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb
index 1ff359975..d862f5972 100644
--- a/db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb
+++ b/db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb
@@ -1,4 +1,6 @@
-class AddGroupsTableAndForeignKeysToFeeds < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class AddGroupsTableAndForeignKeysToFeeds < ActiveRecord::Migration[4.2]
   def up
     create_table :groups do |t|
       t.string :name, null: false
diff --git a/db/migrate/20140421224454_fix_invalid_unicode.rb b/db/migrate/20140421224454_fix_invalid_unicode.rb
index d9200cd6c..2f556a154 100644
--- a/db/migrate/20140421224454_fix_invalid_unicode.rb
+++ b/db/migrate/20140421224454_fix_invalid_unicode.rb
@@ -1,4 +1,6 @@
-class FixInvalidUnicode < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class FixInvalidUnicode < ActiveRecord::Migration[4.2]
   def up
     Story.find_each do |story|
       valid_body = story.body.delete("\u2028").delete("\u2029")
diff --git a/db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb b/db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb
index 4202b0bc8..bea9b1a2f 100644
--- a/db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb
+++ b/db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb
@@ -1,4 +1,6 @@
-class FixInvalidTitlesWithUnicodeLineEndings < ActiveRecord::Migration
+# frozen_string_literal: true
+
+class FixInvalidTitlesWithUnicodeLineEndings < ActiveRecord::Migration[4.2]
   def up
     Story.find_each do |story|
       unless story.title.nil?
diff --git a/db/migrate/20221206231914_add_enclosure_url_to_stories.rb b/db/migrate/20221206231914_add_enclosure_url_to_stories.rb
new file mode 100644
index 000000000..1c20b317d
--- /dev/null
+++ b/db/migrate/20221206231914_add_enclosure_url_to_stories.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddEnclosureUrlToStories < ActiveRecord::Migration[4.2]
+  def change
+    add_column(:stories, :enclosure_url, :string)
+  end
+end
diff --git a/db/migrate/20230120222742_drop_setup_complete.rb b/db/migrate/20230120222742_drop_setup_complete.rb
new file mode 100644
index 000000000..580081160
--- /dev/null
+++ b/db/migrate/20230120222742_drop_setup_complete.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class DropSetupComplete < ActiveRecord::Migration[7.0]
+  def change
+    remove_column :users, :setup_complete, :boolean
+  end
+end
diff --git a/db/migrate/20230221233057_add_user_id_to_tables.rb b/db/migrate/20230221233057_add_user_id_to_tables.rb
new file mode 100644
index 000000000..ad7e54f71
--- /dev/null
+++ b/db/migrate/20230221233057_add_user_id_to_tables.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddUserIdToTables < ActiveRecord::Migration[7.0]
+  def change
+    add_reference :feeds, :user, index: true, foreign_key: true
+    add_reference :groups, :user, index: true, foreign_key: true
+  end
+end
diff --git a/db/migrate/20230223045525_add_null_false_to_associations.rb b/db/migrate/20230223045525_add_null_false_to_associations.rb
new file mode 100644
index 000000000..0e754e0ee
--- /dev/null
+++ b/db/migrate/20230223045525_add_null_false_to_associations.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AddNullFalseToAssociations < ActiveRecord::Migration[7.0]
+  def change
+    update_null_foreign_keys
+
+    change_column_null :feeds, :user_id, false
+    change_column_null :groups, :user_id, false
+    change_column_null :stories, :feed_id, false
+  end
+
+  private
+
+  def update_null_foreign_keys
+    first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1")
+
+    return unless first_user_id
+
+    connection.update("UPDATE feeds SET user_id = #{first_user_id} WHERE user_id IS NULL")
+    connection.update("UPDATE groups SET user_id = #{first_user_id} WHERE user_id IS NULL")
+  end
+end
diff --git a/db/migrate/20230223231930_add_username_to_users.rb b/db/migrate/20230223231930_add_username_to_users.rb
new file mode 100644
index 000000000..b8e59c2e4
--- /dev/null
+++ b/db/migrate/20230223231930_add_username_to_users.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AddUsernameToUsers < ActiveRecord::Migration[7.0]
+  def change
+    add_column :users, :username, :string
+    add_index :users, :username, unique: true
+
+    set_default_username
+
+    change_column_null :users, :username, false
+  end
+
+  private
+
+  def set_default_username
+    first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1")
+
+    return unless first_user_id
+
+    connection.update("UPDATE users SET username = 'stringer' WHERE id = #{first_user_id}")
+  end
+end
diff --git a/db/migrate/20230224042638_update_unique_indexes.rb b/db/migrate/20230224042638_update_unique_indexes.rb
new file mode 100644
index 000000000..7a4edaf5e
--- /dev/null
+++ b/db/migrate/20230224042638_update_unique_indexes.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class UpdateUniqueIndexes < ActiveRecord::Migration[7.0]
+  def change
+    remove_index :feeds, :url
+    add_index :feeds, [:url, :user_id], unique: true
+    add_index :groups, [:name, :user_id], unique: true
+  end
+end
diff --git a/db/migrate/20230301024452_encrypt_api_key.rb b/db/migrate/20230301024452_encrypt_api_key.rb
new file mode 100644
index 000000000..e9d1fb070
--- /dev/null
+++ b/db/migrate/20230301024452_encrypt_api_key.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class EncryptAPIKey < ActiveRecord::Migration[7.0]
+  def change
+    ActiveRecord::Encryption.config.support_unencrypted_data = true
+
+    encrypt_api_keys
+
+    ActiveRecord::Encryption.config.support_unencrypted_data = false
+
+    change_column_null :users, :api_key, false
+    add_index :users, :api_key, unique: true
+  end
+
+  private
+
+  def encrypt_api_keys
+    connection.select_all("SELECT id, api_key FROM users").each do |user|
+      encrypted_api_key = ActiveRecord::Encryption.encrypt(user["api_key"])
+      connection.update("UPDATE users SET api_key = #{connection.quote(encrypted_api_key)} WHERE id = #{user['id']}")
+    end
+  end
+end
diff --git a/db/migrate/20230305010750_create_good_jobs.rb b/db/migrate/20230305010750_create_good_jobs.rb
new file mode 100644
index 000000000..13020e603
--- /dev/null
+++ b/db/migrate/20230305010750_create_good_jobs.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+class CreateGoodJobs < ActiveRecord::Migration[7.0]
+  def change
+    enable_extension "pgcrypto"
+
+    create_table :good_jobs, id: :uuid do |t|
+      t.text :queue_name
+      t.integer :priority
+      t.jsonb :serialized_params
+      t.datetime :scheduled_at
+      t.datetime :performed_at
+      t.datetime :finished_at
+      t.text :error
+
+      t.timestamps
+
+      t.uuid :active_job_id
+      t.text :concurrency_key
+      t.text :cron_key
+      t.uuid :retried_good_job_id
+      t.datetime :cron_at
+
+      t.uuid :batch_id
+      t.uuid :batch_callback_id
+    end
+
+    create_table :good_job_batches, id: :uuid do |t|
+      t.timestamps
+      t.text :description
+      t.jsonb :serialized_properties
+      t.text :on_finish
+      t.text :on_success
+      t.text :on_discard
+      t.text :callback_queue_name
+      t.integer :callback_priority
+      t.datetime :enqueued_at
+      t.datetime :discarded_at
+      t.datetime :finished_at
+    end
+
+    create_table :good_job_processes, id: :uuid do |t|
+      t.timestamps
+      t.jsonb :state
+    end
+
+    create_table :good_job_settings, id: :uuid do |t|
+      t.timestamps
+      t.text :key
+      t.jsonb :value
+      t.index :key, unique: true
+    end
+
+    add_index :good_jobs,
+              :scheduled_at,
+              where: "(finished_at IS NULL)",
+              name: "index_good_jobs_on_scheduled_at"
+    add_index :good_jobs,
+              [:queue_name, :scheduled_at],
+              where: "(finished_at IS NULL)",
+              name: :index_good_jobs_on_queue_name_and_scheduled_at
+    add_index :good_jobs,
+              [:active_job_id, :created_at],
+              name: :index_good_jobs_on_active_job_id_and_created_at
+    add_index :good_jobs,
+              :concurrency_key,
+              where: "(finished_at IS NULL)",
+              name: :index_good_jobs_on_concurrency_key_when_unfinished
+    add_index :good_jobs,
+              [:cron_key, :created_at],
+              name: :index_good_jobs_on_cron_key_and_created_at
+    add_index :good_jobs,
+              [:cron_key, :cron_at],
+              name: :index_good_jobs_on_cron_key_and_cron_at,
+              unique: true
+    add_index :good_jobs,
+              [:active_job_id],
+              name: :index_good_jobs_on_active_job_id
+    add_index :good_jobs,
+              [:finished_at],
+              where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL",
+              name: :index_good_jobs_jobs_on_finished_at
+    add_index :good_jobs,
+              [:priority, :created_at],
+              order: { priority: "DESC NULLS LAST", created_at: :asc },
+              where: "finished_at IS NULL",
+              name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished
+    add_index :good_jobs, [:batch_id], where: "batch_id IS NOT NULL"
+    add_index :good_jobs,
+              [:batch_callback_id],
+              where: "batch_callback_id IS NOT NULL"
+  end
+end
diff --git a/db/migrate/20230312193113_drop_delayed_job.rb b/db/migrate/20230312193113_drop_delayed_job.rb
new file mode 100644
index 000000000..82a09bc1d
--- /dev/null
+++ b/db/migrate/20230312193113_drop_delayed_job.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class DropDelayedJob < ActiveRecord::Migration[7.0]
+  def up
+    drop_table :delayed_jobs
+  end
+
+  def down
+    raise ActiveRecord::IrreversibleMigration
+  end
+end
diff --git a/db/migrate/20230313034938_add_admin_to_users.rb b/db/migrate/20230313034938_add_admin_to_users.rb
new file mode 100644
index 000000000..251d0dd8b
--- /dev/null
+++ b/db/migrate/20230313034938_add_admin_to_users.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class AddAdminToUsers < ActiveRecord::Migration[7.0]
+  def change
+    add_column :users, :admin, :boolean, default: false
+
+    set_first_user_as_admin
+
+    change_column_null :users, :admin, false
+  end
+
+  private
+
+  def set_first_user_as_admin
+    first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1")
+
+    return unless first_user_id
+
+    connection.update("UPDATE users SET admin = TRUE WHERE id = #{first_user_id}")
+  end
+end
diff --git a/db/migrate/20230330215830_create_subscriptions.rb b/db/migrate/20230330215830_create_subscriptions.rb
new file mode 100644
index 000000000..0e74d4685
--- /dev/null
+++ b/db/migrate/20230330215830_create_subscriptions.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class CreateSubscriptions < ActiveRecord::Migration[7.0]
+  def change
+    create_table :subscriptions do |t|
+      t.references :user,
+                   foreign_key: true,
+                   null: false,
+                   index: { unique: true }
+      t.text :stripe_customer_id, null: false
+      t.text :stripe_subscription_id, null: false
+      t.text :status, null: false
+      t.datetime :current_period_start, null: false
+      t.datetime :current_period_end, null: false
+
+      t.timestamps
+    end
+
+    add_index :subscriptions, :stripe_customer_id, unique: true
+    add_index :subscriptions, :stripe_subscription_id, unique: true
+  end
+end
diff --git a/db/migrate/20230721160939_create_settings.rb b/db/migrate/20230721160939_create_settings.rb
new file mode 100644
index 000000000..cd2ec56f3
--- /dev/null
+++ b/db/migrate/20230721160939_create_settings.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class CreateSettings < ActiveRecord::Migration[7.0]
+  def change
+    create_table :settings do |t|
+      t.string :type, null: false, index: { unique: true }
+      t.jsonb :data, null: false, default: {}
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20230801025230_create_good_job_settings.rb b/db/migrate/20230801025230_create_good_job_settings.rb
new file mode 100644
index 000000000..fa3540174
--- /dev/null
+++ b/db/migrate/20230801025230_create_good_job_settings.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class CreateGoodJobSettings < ActiveRecord::Migration[7.0]
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.table_exists?(:good_job_settings)
+      end
+    end
+
+    create_table :good_job_settings, id: :uuid do |t|
+      t.timestamps
+      t.text :key
+      t.jsonb :value
+      t.index :key, unique: true
+    end
+  end
+end
diff --git a/db/migrate/20230801025231_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb b/db/migrate/20230801025231_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb
new file mode 100644
index 000000000..6da0c07ea
--- /dev/null
+++ b/db/migrate/20230801025231_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class CreateIndexGoodJobsJobsOnPriorityCreatedAtWhenUnfinished < ActiveRecord::Migration[7.0]
+  disable_ddl_transaction!
+
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.index_name_exists?(
+          :good_jobs,
+          :index_good_jobs_jobs_on_priority_created_at_when_unfinished
+        )
+      end
+    end
+
+    add_index :good_jobs,
+              [:priority, :created_at],
+              order: { priority: "DESC NULLS LAST", created_at: :asc },
+              where: "finished_at IS NULL",
+              name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished,
+              algorithm: :concurrently
+  end
+end
diff --git a/db/migrate/20230801025232_create_good_job_batches.rb b/db/migrate/20230801025232_create_good_job_batches.rb
new file mode 100644
index 000000000..ac6c5b37e
--- /dev/null
+++ b/db/migrate/20230801025232_create_good_job_batches.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class CreateGoodJobBatches < ActiveRecord::Migration[7.0]
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.table_exists?(:good_job_batches)
+      end
+    end
+
+    create_table :good_job_batches, id: :uuid do |t|
+      t.timestamps
+      t.text :description
+      t.jsonb :serialized_properties
+      t.text :on_finish
+      t.text :on_success
+      t.text :on_discard
+      t.text :callback_queue_name
+      t.integer :callback_priority
+      t.datetime :enqueued_at
+      t.datetime :discarded_at
+      t.datetime :finished_at
+    end
+
+    change_table :good_jobs do |t|
+      t.uuid :batch_id
+      t.uuid :batch_callback_id
+
+      t.index :batch_id, where: "batch_id IS NOT NULL"
+      t.index :batch_callback_id, where: "batch_callback_id IS NOT NULL"
+    end
+  end
+end
diff --git a/db/migrate/20230801025233_create_good_job_executions.rb b/db/migrate/20230801025233_create_good_job_executions.rb
new file mode 100644
index 000000000..af5c816cc
--- /dev/null
+++ b/db/migrate/20230801025233_create_good_job_executions.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class CreateGoodJobExecutions < ActiveRecord::Migration[7.0]
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.table_exists?(:good_job_executions)
+      end
+    end
+
+    create_table :good_job_executions, id: :uuid do |t|
+      t.timestamps
+
+      t.uuid :active_job_id, null: false
+      t.text :job_class
+      t.text :queue_name
+      t.jsonb :serialized_params
+      t.datetime :scheduled_at
+      t.datetime :finished_at
+      t.text :error
+
+      t.index [:active_job_id, :created_at],
+              name: :index_good_job_executions_on_active_job_id_and_created_at
+    end
+
+    change_table :good_jobs do |t|
+      t.boolean :is_discrete
+      t.integer :executions_count
+      t.text :job_class
+    end
+  end
+end
diff --git a/db/migrate/20230801025234_create_good_jobs_error_event.rb b/db/migrate/20230801025234_create_good_jobs_error_event.rb
new file mode 100644
index 000000000..b07e0f14e
--- /dev/null
+++ b/db/migrate/20230801025234_create_good_jobs_error_event.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class CreateGoodJobsErrorEvent < ActiveRecord::Migration[7.0]
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.column_exists?(:good_jobs, :error_event)
+      end
+    end
+
+    add_column :good_jobs, :error_event, :integer, limit: 2
+    add_column :good_job_executions, :error_event, :integer, limit: 2
+  end
+end
diff --git a/db/migrate/20240226201050_add_precision_to_timestamps.rb b/db/migrate/20240226201050_add_precision_to_timestamps.rb
new file mode 100644
index 000000000..a90ed1ba3
--- /dev/null
+++ b/db/migrate/20240226201050_add_precision_to_timestamps.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class AddPrecisionToTimestamps < ActiveRecord::Migration[7.1]
+  SKIP_TABLES = [:schema_migrations, :ar_internal_metadata].freeze
+
+  def up
+    migrate_precision(precision: 6)
+  end
+
+  def down
+    migrate_precision(precision: nil)
+  end
+
+  def migrate_precision(precision:)
+    table_names = ActiveRecord::Base.connection.tables.map(&:to_sym)
+    table_names.each do |table|
+      next if SKIP_TABLES.include?(table)
+
+      ActiveRecord::Base.connection.columns(table).each do |column|
+        next unless datetime_column?(column)
+
+        change_column(table, column.name, :datetime, precision:)
+      end
+    end
+  end
+
+  private
+
+  def datetime_column?(column)
+    column.sql_type_metadata.type == :datetime
+  end
+end
diff --git a/db/migrate/20240313195404_add_default_to_stories.rb b/db/migrate/20240313195404_add_default_to_stories.rb
new file mode 100644
index 000000000..fbb396dab
--- /dev/null
+++ b/db/migrate/20240313195404_add_default_to_stories.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddDefaultToStories < ActiveRecord::Migration[7.1]
+  def change
+    change_column_default :stories, :is_read, from: nil, to: false
+  end
+end
diff --git a/db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb b/db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb
new file mode 100644
index 000000000..56cab35c3
--- /dev/null
+++ b/db/migrate/20240314031219_recreate_good_job_cron_indexes_with_conditional.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class RecreateGoodJobCronIndexesWithConditional < ActiveRecord::Migration[7.1]
+  disable_ddl_transaction!
+
+  def change
+    reversible do |dir|
+      dir.up do
+        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond)
+          add_index :good_jobs, [:cron_key, :created_at], where: "(cron_key IS NOT NULL)",
+                    name: :index_good_jobs_on_cron_key_and_created_at_cond, algorithm: :concurrently
+        end
+        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond)
+          add_index :good_jobs, [:cron_key, :cron_at], where: "(cron_key IS NOT NULL)", unique: true,
+                    name: :index_good_jobs_on_cron_key_and_cron_at_cond, algorithm: :concurrently
+        end
+
+        if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at)
+          remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at
+        end
+        if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at)
+          remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at
+        end
+      end
+
+      dir.down do
+        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at)
+          add_index :good_jobs, [:cron_key, :created_at],
+                    name: :index_good_jobs_on_cron_key_and_created_at, algorithm: :concurrently
+        end
+        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at)
+          add_index :good_jobs, [:cron_key, :cron_at], unique: true,
+                    name: :index_good_jobs_on_cron_key_and_cron_at, algorithm: :concurrently
+        end
+
+        if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_created_at_cond)
+          remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_created_at_cond
+        end
+        if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_cron_key_and_cron_at_cond)
+          remove_index :good_jobs, name: :index_good_jobs_on_cron_key_and_cron_at_cond
+        end
+      end
+    end
+  end
+end
diff --git a/db/migrate/20240314031220_create_good_job_labels.rb b/db/migrate/20240314031220_create_good_job_labels.rb
new file mode 100644
index 000000000..1ba514e8b
--- /dev/null
+++ b/db/migrate/20240314031220_create_good_job_labels.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CreateGoodJobLabels < ActiveRecord::Migration[7.1]
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.column_exists?(:good_jobs, :labels)
+      end
+    end
+
+    add_column :good_jobs, :labels, :text, array: true
+  end
+end
diff --git a/db/migrate/20240314031221_create_good_job_labels_index.rb b/db/migrate/20240314031221_create_good_job_labels_index.rb
new file mode 100644
index 000000000..65dedd477
--- /dev/null
+++ b/db/migrate/20240314031221_create_good_job_labels_index.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class CreateGoodJobLabelsIndex < ActiveRecord::Migration[7.1]
+  disable_ddl_transaction!
+
+  def change
+    reversible do |dir|
+      dir.up do
+        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels)
+          add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)",
+            name: :index_good_jobs_on_labels, algorithm: :concurrently
+        end
+      end
+
+      dir.down do
+        if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels)
+          remove_index :good_jobs, name: :index_good_jobs_on_labels
+        end
+      end
+    end
+  end
+end
diff --git a/db/migrate/20240314031222_remove_good_job_active_id_index.rb b/db/migrate/20240314031222_remove_good_job_active_id_index.rb
new file mode 100644
index 000000000..8601f071a
--- /dev/null
+++ b/db/migrate/20240314031222_remove_good_job_active_id_index.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class RemoveGoodJobActiveIdIndex < ActiveRecord::Migration[7.1]
+  disable_ddl_transaction!
+
+  def change
+    reversible do |dir|
+      dir.up do
+        if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id)
+          remove_index :good_jobs, name: :index_good_jobs_on_active_job_id
+        end
+      end
+
+      dir.down do
+        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id)
+          add_index :good_jobs, :active_job_id, name: :index_good_jobs_on_active_job_id
+        end
+      end
+    end
+  end
+end
diff --git a/db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb b/db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb
new file mode 100644
index 000000000..70e525626
--- /dev/null
+++ b/db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class CreateIndexGoodJobJobsForCandidateLookup < ActiveRecord::Migration[7.1]
+  disable_ddl_transaction!
+
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.index_name_exists?(:good_jobs, :index_good_job_jobs_for_candidate_lookup)
+      end
+    end
+
+    add_index :good_jobs, [:priority, :created_at], order: { priority: "ASC NULLS LAST", created_at: :asc },
+      where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup,
+      algorithm: :concurrently
+  end
+end
diff --git a/db/migrate/20240316211109_add_stories_order_to_users.rb b/db/migrate/20240316211109_add_stories_order_to_users.rb
new file mode 100644
index 000000000..8aec04941
--- /dev/null
+++ b/db/migrate/20240316211109_add_stories_order_to_users.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddStoriesOrderToUsers < ActiveRecord::Migration[7.1]
+  def change
+    add_column :users, :stories_order, :string, default: "desc"
+  end
+end
diff --git a/db/migrate/20240709172405_create_good_job_execution_error_backtrace.rb b/db/migrate/20240709172405_create_good_job_execution_error_backtrace.rb
new file mode 100644
index 000000000..619ddbe50
--- /dev/null
+++ b/db/migrate/20240709172405_create_good_job_execution_error_backtrace.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration[7.1]
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        if connection.column_exists?(:good_job_executions, :error_backtrace)
+          return
+        end
+      end
+    end
+
+    add_column :good_job_executions, :error_backtrace, :text, array: true
+  end
+end
diff --git a/db/migrate/20240709172406_create_good_job_process_lock_ids.rb b/db/migrate/20240709172406_create_good_job_process_lock_ids.rb
new file mode 100644
index 000000000..f1b70a8f2
--- /dev/null
+++ b/db/migrate/20240709172406_create_good_job_process_lock_ids.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class CreateGoodJobProcessLockIds < ActiveRecord::Migration[7.1]
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.column_exists?(:good_jobs, :locked_by_id)
+      end
+    end
+
+    add_column :good_jobs, :locked_by_id, :uuid
+    add_column :good_jobs, :locked_at, :datetime
+    add_column :good_job_executions, :process_id, :uuid
+    add_column :good_job_processes, :lock_type, :integer, limit: 2
+  end
+end
diff --git a/db/migrate/20240709172407_create_good_job_process_lock_indexes.rb b/db/migrate/20240709172407_create_good_job_process_lock_indexes.rb
new file mode 100644
index 000000000..ee7cd27e0
--- /dev/null
+++ b/db/migrate/20240709172407_create_good_job_process_lock_indexes.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class CreateGoodJobProcessLockIndexes < ActiveRecord::Migration[7.1]
+  disable_ddl_transaction!
+
+  def change
+    reversible do |dir|
+      dir.up do
+        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)
+          add_index :good_jobs,
+                    [:priority, :scheduled_at],
+                    order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
+                    where: "finished_at IS NULL AND locked_by_id IS NULL",
+                    name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked,
+                    algorithm: :concurrently
+        end
+
+        unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id)
+          add_index :good_jobs,
+                    :locked_by_id,
+                    where: "locked_by_id IS NOT NULL",
+                    name: :index_good_jobs_on_locked_by_id,
+                    algorithm: :concurrently
+        end
+
+        unless connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)
+          add_index :good_job_executions,
+                    [:process_id, :created_at],
+                    name: :index_good_job_executions_on_process_id_and_created_at,
+                    algorithm: :concurrently
+        end
+      end
+
+      dir.down do
+        if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)
+          remove_index(:good_jobs, name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)
+        end
+        if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id)
+          remove_index(:good_jobs, name: :index_good_jobs_on_locked_by_id)
+        end
+        if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)
+          remove_index(:good_job_executions, name: :index_good_job_executions_on_process_id_and_created_at)
+        end
+      end
+    end
+  end
+end
diff --git a/db/migrate/20240709172408_create_good_job_execution_duration.rb b/db/migrate/20240709172408_create_good_job_execution_duration.rb
new file mode 100644
index 000000000..fef37f07b
--- /dev/null
+++ b/db/migrate/20240709172408_create_good_job_execution_duration.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.1]
+  def change
+    reversible do |dir|
+      dir.up do
+        # Ensure this incremental update migration is idempotent
+        # with monolithic install migration.
+        return if connection.column_exists?(:good_job_executions, :duration)
+      end
+    end
+
+    add_column :good_job_executions, :duration, :interval
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 3f7ef303d..3563b1070 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -2,75 +2,180 @@
 # of editing this file, please use the migrations feature of Active Record to
 # incrementally modify your database, and then regenerate this schema definition.
 #
-# Note that this schema.rb definition is the authoritative source for your
-# database schema. If you need to create the application database on another
-# system, you should be using db:schema:load, not running all the migrations
-# from scratch. The latter is a flawed and unsustainable approach (the more migrations
-# you'll amass, the slower it'll run and the greater likelihood for issues).
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20141102103617) do
-
+ActiveRecord::Schema[7.1].define(version: 2024_07_09_172408) do
   # These are extensions that must be enabled in order to support this database
+  enable_extension "pgcrypto"
   enable_extension "plpgsql"
 
-  create_table "delayed_jobs", force: true do |t|
-    t.integer  "priority",   default: 0
-    t.integer  "attempts",   default: 0
-    t.text     "handler"
-    t.text     "last_error"
-    t.datetime "run_at"
-    t.datetime "locked_at"
-    t.datetime "failed_at"
-    t.string   "locked_by"
-    t.string   "queue"
-    t.datetime "created_at",             null: false
-    t.datetime "updated_at",             null: false
+  create_table "feeds", id: :serial, force: :cascade do |t|
+    t.string "name", limit: 255
+    t.text "url"
+    t.datetime "last_fetched"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.integer "status"
+    t.integer "group_id"
+    t.bigint "user_id", null: false
+    t.index ["url", "user_id"], name: "index_feeds_on_url_and_user_id", unique: true
+    t.index ["user_id"], name: "index_feeds_on_user_id"
   end
 
-  add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree
+  create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.text "description"
+    t.jsonb "serialized_properties"
+    t.text "on_finish"
+    t.text "on_success"
+    t.text "on_discard"
+    t.text "callback_queue_name"
+    t.integer "callback_priority"
+    t.datetime "enqueued_at"
+    t.datetime "discarded_at"
+    t.datetime "finished_at"
+  end
 
-  create_table "feeds", force: true do |t|
-    t.string   "name"
-    t.text     "url"
-    t.datetime "last_fetched"
-    t.datetime "created_at",   null: false
-    t.datetime "updated_at",   null: false
-    t.integer  "status"
-    t.integer  "group_id"
+  create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.uuid "active_job_id", null: false
+    t.text "job_class"
+    t.text "queue_name"
+    t.jsonb "serialized_params"
+    t.datetime "scheduled_at"
+    t.datetime "finished_at"
+    t.text "error"
+    t.integer "error_event", limit: 2
+    t.text "error_backtrace", array: true
+    t.uuid "process_id"
+    t.interval "duration"
+    t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at"
+    t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at"
+  end
+
+  create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.jsonb "state"
+    t.integer "lock_type", limit: 2
+  end
+
+  create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.text "key"
+    t.jsonb "value"
+    t.index ["key"], name: "index_good_job_settings_on_key", unique: true
   end
 
-  add_index "feeds", ["url"], name: "index_feeds_on_url", unique: true, using: :btree
+  create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+    t.text "queue_name"
+    t.integer "priority"
+    t.jsonb "serialized_params"
+    t.datetime "scheduled_at"
+    t.datetime "performed_at"
+    t.datetime "finished_at"
+    t.text "error"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.uuid "active_job_id"
+    t.text "concurrency_key"
+    t.text "cron_key"
+    t.uuid "retried_good_job_id"
+    t.datetime "cron_at"
+    t.uuid "batch_id"
+    t.uuid "batch_callback_id"
+    t.boolean "is_discrete"
+    t.integer "executions_count"
+    t.text "job_class"
+    t.integer "error_event", limit: 2
+    t.text "labels", array: true
+    t.uuid "locked_by_id"
+    t.datetime "locked_at"
+    t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at"
+    t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)"
+    t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)"
+    t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)"
+    t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)"
+    t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)"
+    t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))"
+    t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin
+    t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)"
+    t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)"
+    t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)"
+    t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))"
+    t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)"
+    t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
+  end
 
-  create_table "groups", force: true do |t|
-    t.string   "name",       null: false
+  create_table "groups", id: :serial, force: :cascade do |t|
+    t.string "name", limit: 255, null: false
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
+    t.bigint "user_id", null: false
+    t.index ["name", "user_id"], name: "index_groups_on_name_and_user_id", unique: true
+    t.index ["user_id"], name: "index_groups_on_user_id"
   end
 
-  create_table "stories", force: true do |t|
-    t.text     "title"
-    t.text     "permalink"
-    t.text     "body"
-    t.integer  "feed_id"
-    t.datetime "created_at",                  null: false
-    t.datetime "updated_at",                  null: false
+  create_table "settings", force: :cascade do |t|
+    t.string "type", null: false
+    t.jsonb "data", default: {}, null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["type"], name: "index_settings_on_type", unique: true
+  end
+
+  create_table "stories", id: :serial, force: :cascade do |t|
+    t.text "title"
+    t.text "permalink"
+    t.text "body"
+    t.integer "feed_id", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
     t.datetime "published"
-    t.boolean  "is_read"
-    t.boolean  "keep_unread", default: false
-    t.boolean  "is_starred",  default: false
-    t.text     "entry_id"
+    t.boolean "is_read", default: false
+    t.boolean "keep_unread", default: false
+    t.boolean "is_starred", default: false
+    t.text "entry_id"
+    t.string "enclosure_url"
+    t.index ["entry_id", "feed_id"], name: "index_stories_on_entry_id_and_feed_id", unique: true
   end
 
-  add_index "stories", ["entry_id", "feed_id"], name: "index_stories_on_entry_id_and_feed_id", unique: true, using: :btree
+  create_table "subscriptions", force: :cascade do |t|
+    t.bigint "user_id", null: false
+    t.text "stripe_customer_id", null: false
+    t.text "stripe_subscription_id", null: false
+    t.text "status", null: false
+    t.datetime "current_period_start", null: false
+    t.datetime "current_period_end", null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["stripe_customer_id"], name: "index_subscriptions_on_stripe_customer_id", unique: true
+    t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true
+    t.index ["user_id"], name: "index_subscriptions_on_user_id", unique: true
+  end
 
-  create_table "users", force: true do |t|
-    t.string   "password_digest"
-    t.datetime "created_at",      null: false
-    t.datetime "updated_at",      null: false
-    t.boolean  "setup_complete"
-    t.string   "api_key"
+  create_table "users", id: :serial, force: :cascade do |t|
+    t.string "password_digest", limit: 255
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.string "api_key", limit: 255, null: false
+    t.string "username", null: false
+    t.boolean "admin", null: false
+    t.string "stories_order", default: "desc"
+    t.index ["api_key"], name: "index_users_on_api_key", unique: true
+    t.index ["username"], name: "index_users_on_username", unique: true
   end
 
+  add_foreign_key "feeds", "users"
+  add_foreign_key "groups", "users"
+  add_foreign_key "subscriptions", "users"
 end
diff --git a/db/seeds.rb b/db/seeds.rb
new file mode 100644
index 000000000..8e9b8f90f
--- /dev/null
+++ b/db/seeds.rb
@@ -0,0 +1 @@
+# frozen_string_literal: true
diff --git a/docker-compose.yml b/docker-compose.yml
index 06ac17b91..f93f33a37 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,23 +1,49 @@
-version: '2'
+version: '3'
+
 services:
-  postgres:
-    image: postgres:9.5-alpine
+  stringer-setup:
+    image: stringerrss/stringer:latest
+    container_name: stringer-setup
+    restart: no
+    env_file: .env
+    volumes:
+      - ./.env:/app/.env
+    entrypoint: ["ruby"]
+    command: ["/app/docker/init_or_update_env.rb"]
+
+  stringer-postgres:
+    image: postgres:16-alpine
+    container_name: stringer-postgres
     restart: always
+    depends_on:
+      stringer-setup:
+        condition: service_completed_successfully
+    networks:
+      - stringer-network
     volumes:
-      - ~/stringer:/var/lib/postgresql/data
-    environment:
-      - POSTGRES_PASSWORD=super_secret_password
-      - POSTGRES_USER=db_user
-      - POSTGRES_DB=stringer
+      - ./.env:/app/.env
+      - /srv/stringer/data:/var/lib/postgresql/data
+    env_file: .env
 
-  web:
-    image: mdswanson/stringer
+  stringer:
+    image: stringerrss/stringer:latest
+    container_name: stringer
+    build: .
     depends_on:
-      - postgres
+      stringer-postgres:
+        condition: service_started
+      stringer-setup:
+        condition: service_completed_successfully
     restart: always
     ports:
       - 80:8080
-    environment:
-      - SECRET_TOKEN=YOUR_SECRET_TOKEN
-      - PORT=8080
-      - DATABASE_URL=postgres://db_user:super_secret_password@postgres:5432/stringer
+    networks:
+      - stringer-network
+    volumes:
+      - ./.env:/app/.env
+    env_file: .env
+
+networks:
+  stringer-network:
+    external: false
+    name: stringer-network
diff --git a/docker/init_or_update_env.rb b/docker/init_or_update_env.rb
new file mode 100644
index 000000000..6b2845c6b
--- /dev/null
+++ b/docker/init_or_update_env.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Secrets
+  def self.generate_secret(length)
+    `openssl rand -hex #{length}`.strip
+  end
+end
+
+pg_user = ENV.fetch("POSTGRES_USER", "stringer")
+pg_password = ENV.fetch("POSTGRES_PASSWORD", Secrets.generate_secret(32))
+pg_host = ENV.fetch("POSTGRES_HOSTNAME", "stringer-postgres")
+pg_db = ENV.fetch("POSTGRES_DB", "stringer")
+
+required_env = {
+  "SECRET_KEY_BASE" => Secrets.generate_secret(64),
+  "ENCRYPTION_PRIMARY_KEY" => Secrets.generate_secret(64),
+  "ENCRYPTION_DETERMINISTIC_KEY" => Secrets.generate_secret(64),
+  "ENCRYPTION_KEY_DERIVATION_SALT" => Secrets.generate_secret(64),
+  "POSTGRES_USER" => pg_user,
+  "POSTGRES_PASSWORD" => pg_password,
+  "POSTGRES_HOSTNAME" => pg_host,
+  "POSTGRES_DB" => pg_db,
+  "FETCH_FEEDS_CRON" => "*/5 * * * *",
+  "CLEANUP_CRON" => "0 0 * * *",
+  "DATABASE_URL" => "postgres://#{pg_user}:#{pg_password}@#{pg_host}/#{pg_db}"
+}
+
+required_env.each do |key, value|
+  next if ENV.key?(key)
+
+  File.open("/app/.env", "a") { |file| file << "#{key}=#{value}\n" }
+end
diff --git a/docker/start.sh b/docker/start.sh
index fb05a3c35..1d603aea2 100755
--- a/docker/start.sh
+++ b/docker/start.sh
@@ -2,14 +2,13 @@
 if [ -z "$DATABASE_URL" ]; then
   cat <<-EOF
 	$(tput setaf 1)Error: no DATABASE_URL was specified.
-
-	For a quick start use DATABASE_URL="sqlite3:':memory:'"
-	(not recommended for production).
 	EOF
 
   exit 1
 fi
 
+rails assets:precompile
+
 : ${FETCH_FEEDS_CRON:='*/5 * * * *'}
 : ${CLEANUP_CRON:='0 0 * * *'}
 
diff --git a/docker/supervisord.conf b/docker/supervisord.conf
index 61f5b300d..12c76be16 100644
--- a/docker/supervisord.conf
+++ b/docker/supervisord.conf
@@ -2,8 +2,8 @@
 nodaemon=true
 loglevel=debug
 
-[program:unicorn]
-command=bash -c 'bundle exec rake db:migrate && bundle exec unicorn -p $PORT -c ./config/unicorn.rb'
+[program:puma]
+command=bash -c 'bundle exec rake db:migrate && bundle exec puma -p $PORT -C ./config/puma.rb'
 directory=/app
 autostart=true
 autorestart=true
diff --git a/docs/docker.md b/docs/Docker.md
similarity index 78%
rename from docs/docker.md
rename to docs/Docker.md
index 606e51576..a146bd741 100644
--- a/docs/docker.md
+++ b/docs/Docker.md
@@ -1,21 +1,16 @@
 # Stringer on Docker
 
-## Quick test setup
+## Production ready setup using docker-compose
+
+Create a local environment file named `.env`, e.g. via `touch .env`.
 
-To quickly try out Stringer on your local machine run the following one liner:
+Download [docker-compose.yml](../docker-compose.yml) and run:
 
 ```sh
-docker run --rm -it -e DATABASE_URL="sqlite3:':memory:'" -p 8080:8080 mdswanson/stringer
+touch .env && docker compose up -d
 ```
 
-Visit `http://localhost:8080` and enjoy Stringer!
-
-**One caveat**: Stringer was not designed to be used with sqlite so you might run into some issues if you
-have Stringer fetch many feeds. See [this issue](https://github.com/swanson/stringer/issues/164) for details.
-
-## Production ready setup using docker-compose
-
-Download [docker-compose.yml](../docker-compose.yml) and in the corresponding foler, run `docker-compose up -d`, give it a second and visit `localhost`
+Give it a second and visit `localhost`!
 
 ## Production ready manual setup
 
@@ -23,9 +18,9 @@ The following steps can be used to setup Stringer on Docker, using a Postgres da
 
 1. Setup a Docker network so the two containers we're going to create can communicate:
 
-   ```Sh
-   docker network create --driver bridge stringer
-   ```
+```Sh
+docker network create --driver bridge stringer-network
+```
 
 2. Setup a Postgres Docker container:
 
@@ -34,10 +29,10 @@ docker run --detach \
     --name stringer-postgres \
     --restart always \
     --volume /srv/stringer/data:/var/lib/postgresql/data \
-    --net stringer \
+    --net stringer-network \
     -e POSTGRES_PASSWORD=myPassword \
     -e POSTGRES_DB=stringer \
-    postgres:9.5-alpine
+    postgres:16-alpine
 ```
 
 3. Run the Stringer Docker image:
@@ -45,15 +40,18 @@ docker run --detach \
 ```sh
 docker run --detach \
     --name stringer \
-    --net stringer \
+    --net stringer-network \
     --restart always \
     -e PORT=8080 \
     -e DATABASE_URL=postgres://postgres:myPassword@stringer-postgres/stringer \
-    -e SECRET_TOKEN=$(openssl rand -hex 20) \
+    -e SECRET_KEY_BASE=$(openssl rand -hex 64) \
+    -e ENCRYPTION_PRIMARY_KEY=$(openssl rand -hex 64) \
+    -e ENCRYPTION_DETERMINISTIC_KEY=$(openssl rand -hex 64) \
+    -e ENCRYPTION_KEY_DERIVATION_SALT=$(openssl rand -hex 64) \
     -e FETCH_FEEDS_CRON="*/5 * * * *" \ # optional
     -e CLEANUP_CRON="0 0 * * *" \ # optional
     -p 127.0.0.1:8080:8080 \
-    mdswanson/stringer
+    stringerrss/stringer:latest
 ```
 
 That's it! You now have a fully working Stringer instance up and running!
diff --git a/docs/Heroku.md b/docs/Heroku.md
index 4a113e20e..a63923f81 100644
--- a/docs/Heroku.md
+++ b/docs/Heroku.md
@@ -1,11 +1,16 @@
 ```sh
-git clone git://github.com/swanson/stringer.git
+git clone git@github.com:stringer-rss/stringer.git
 cd stringer
 heroku create
-git push heroku master
 
-heroku config:set APP_URL=`heroku apps:info --shell | grep web-url | cut -d= -f2`
-heroku config:set SECRET_TOKEN=`openssl rand -hex 20`
+heroku config:set SECRET_KEY_BASE=`openssl rand -hex 64`
+heroku config:set ENCRYPTION_PRIMARY_KEY=`openssl rand -hex 64`
+heroku config:set ENCRYPTION_DETERMINISTIC_KEY=`openssl rand -hex 64`
+heroku config:set ENCRYPTION_KEY_DERIVATION_SALT=`openssl rand -hex 64`
+
+git push heroku main
+
+heroku config:set APP_URL=`heroku apps:info --shell | grep web_url | cut -d= -f2`
 
 heroku run rake db:migrate
 heroku restart
@@ -26,11 +31,7 @@ From the app's directory:
 
 ```sh
 git pull
-git push heroku master
+git push heroku main
 heroku run rake db:migrate
 heroku restart
 ```
-
-## Password Reset
-
-In the event that you need to change your password, run `heroku run rake change_password` from the app folder.
diff --git a/docs/OpenShift.md b/docs/OpenShift.md
index be9d7dbfe..3eb056a29 100644
--- a/docs/OpenShift.md
+++ b/docs/OpenShift.md
@@ -14,15 +14,15 @@ Deploying into OpenShift
 
  ```sh
 	cd feeds
-	git remote add upstream git://github.com/swanson/stringer.git
-	git pull -s recursive -X theirs upstream master
+	git remote add upstream git@github.com:stringer-rss/stringer.git
+	git pull -s recursive -X theirs upstream main
  ```
 
 3. To enable migrations for the application, a new action_hook is required. Add the file, .openshift/action_hooks/deploy,  with the below 3 lines into it.
 
  ```
 	pushd ${OPENSHIFT_REPO_DIR} > /dev/null
-	bundle exec rake db:migrate RACK_ENV="production"
+	bundle exec rake db:migrate RAILS_ENV="production"
 	popd > /dev/null
  ```
 
@@ -32,10 +32,13 @@ Deploying into OpenShift
     chmod +x .openshift/action_hooks/deploy
  ```
 
-5. Set the SECRET_TOKEN as a rhc environment variable by generating it with the command below.
+5. Set the environment variables by generating them with the commands below.
 
  ```sh
-    rhc env set SECRET_TOKEN="`openssl rand -hex 20`"
+    rhc env set SECRET_KEY_BASE="`openssl rand -hex 64`"
+    rhc env set ENCRYPTION_PRIMARY_KEY="`openssl rand -hex 64`"
+    rhc env set ENCRYPTION_DETERMINISTIC_KEY="`openssl rand -hex 64`"
+    rhc env set ENCRYPTION_KEY_DERIVATION_SALT="`openssl rand -hex 64`"
  ```
 
 6. Configuration of the database server is next. Open the file config/database.yml and add in the configuration for Production as shown below. OpenShift is able to use environment variables to push the information into the application.
@@ -99,7 +102,7 @@ After importing feeds, a cron job is needed on OpenShift to fetch feeds.
  ```
 	./usr/bin/rhcsh
 	pushd ${OPENSHIFT_REPO_DIR} > /dev/null
-	bundle exec rake fetch_feeds RACK_ENV="production"
+	bundle exec rake fetch_feeds RAILS_ENV="production"
 	popd > /dev/null
  ```
 
@@ -118,12 +121,3 @@ After importing feeds, a cron job is needed on OpenShift to fetch feeds.
  ```
 
 5. Done! The cron job should fetch feeds every hour.
-
-Password Reset
---------------
-In the event that you need to change your password, run the following commands
-```
-rhc ssh feeds
-cd app-root/repo	
-bundle exec rake change_password RACK_ENV="production"
-```
diff --git a/docs/VPS.md b/docs/VPS.md
index 752f91550..7e4a0ab92 100644
--- a/docs/VPS.md
+++ b/docs/VPS.md
@@ -5,11 +5,11 @@ The first step is installing some essential dependencies from your VPS's package
 
 #### Ubuntu/Debian
 
-    sudo apt-get install git libxml2-dev libxslt-dev libcurl4-openssl-dev libpq-dev libsqlite3-dev build-essential postgresql libreadline-dev
+    sudo apt-get install git libxml2-dev libxslt-dev libcurl4-openssl-dev libpq-dev build-essential postgresql libreadline-dev
 
 #### CentOS/Fedora
 
-    sudo yum install git libxml2-devel libxslt-devel curl-devel postgresql-devel sqlite-devel  make automake gcc gcc-c++ postgresql-server readline-devel openssl-devel
+    sudo yum install git libxml2-devel libxslt-devel curl-devel postgresql-devel make automake gcc gcc-c++ postgresql-server readline-devel openssl-devel
 
 On CentOS after installing Postgres, I needed to run these commands, Fedora likely the same.
 
@@ -17,7 +17,7 @@ On CentOS after installing Postgres, I needed to run these commands, Fedora like
 
 #### Arch Linux
 
-    pacman -S git postgresql base-devel libxml2 libxslt curl sqlite readline postgresql-libs
+    pacman -S git postgresql base-devel libxml2 libxslt curl readline postgresql-libs
 
 Here are some Arch specific instructions for setting up postgres
 
@@ -61,8 +61,8 @@ We are going to use Rbenv to manage the version of Ruby you use.
     git clone git://github.com/sstephenson/ruby-build.git $HOME/.rbenv/plugins/ruby-build
     source ~/.bash_profile
 
-    rbenv install 2.3.3
-    rbenv local 2.3.3
+    rbenv install 2.7.5
+    rbenv local 2.7.5
     rbenv rehash
 
 We also need to install bundler which will handle Stringer's dependencies
@@ -79,7 +79,7 @@ Install Stringer and set it up
 
 Grab Stringer from github
 
-    git clone https://github.com/swanson/stringer.git
+    git clone git@github.com:stringer-rss/stringer.git
     cd stringer
 
 Use bundler to grab and build Stringer's dependencies
@@ -91,13 +91,17 @@ Stringer uses environment variables to configure the application. Edit these val
 
     echo 'export DATABASE_URL="postgres://stringer:EDIT_ME@localhost/stringer_live"' >> $HOME/.bash_profile
     echo 'export RACK_ENV="production"' >> $HOME/.bash_profile
-    echo "export SECRET_TOKEN=`openssl rand -hex 20`" >> $HOME/.bash_profile
+    echo 'export RAILS_ENV="production"' >> $HOME/.bash_profile
+    echo "export SECRET_KEY_BASE=`openssl rand -hex 64`" >> $HOME/.bash_profile
+    echo "export ENCRYPTION_PRIMARY_KEY=`openssl rand -hex 64`" >> $HOME/.bash_profile
+    echo "export ENCRYPTION_DETERMINISTIC_KEY=`openssl rand -hex 64`" >> $HOME/.bash_profile
+    echo "export ENCRYPTION_KEY_DERIVATION_SALT=`openssl rand -hex 64`" >> $HOME/.bash_profile
     source ~/.bash_profile
 
 Tell stringer to run the database in production mode, using the `postgres` database you created earlier.
 
     cd $HOME/stringer
-    rake db:migrate RACK_ENV=production
+    rake db:migrate RAILS_ENV=production
 
 Run the application:
 
@@ -130,7 +134,7 @@ Logout stringer user, install systemd services:
 
 As stringer user, close existing Stringer instance:
 
-    exit # exit racksh and app
+    exit # exit app
 
 Start app as a systemd service and make app run at startup
 
@@ -163,13 +167,13 @@ server {
 Deploy Stringer With Passenger And Apache
 =========================================
 
-You may want to run Stringer with the existing Apache server. We need to install 
+You may want to run Stringer with the existing Apache server. We need to install
 *mod_passenger* and edit few files.
 
 The installation of *mod_passenger* depends on VPS's system distribution release.
 Offical installation guide is available at [Passenger Library](https://www.phusionpassenger.com/library/install/apache/install/oss/)
 
-After validating the *mod_passenger* install, we will fetch dependencies again 
+After validating the *mod_passenger* install, we will fetch dependencies again
 to meet Passenger's default GEM_HOME set. As stringer user:
 
     cd ~/stringer
@@ -185,14 +189,14 @@ Add VirtualHost to your Apache installation, here's a sample configuration:
 ```bash
 <VirtualHost *:80>
     ServerName example.com
-    DocumentRoot /home/stringer/stringer/app/public
+    DocumentRoot /home/stringer/stringer/public
 
     PassengerEnabled On
     PassengerAppRoot /home/stringer/stringer
     PassengerRuby /home/stringer/.rbenv/shims/ruby
     # PassengerLogFile /dev/null # don't flow logs to apache error.log
 
-    <Directory /home/stringer/stringer/app/public>
+    <Directory /home/stringer/stringer/public>
         Options FollowSymLinks
         Require all granted
         AllowOverride All
diff --git a/fever_api.rb b/fever_api.rb
deleted file mode 100644
index 6de858ee3..000000000
--- a/fever_api.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-require "sinatra/base"
-require "sinatra/activerecord"
-
-require_relative "./app/fever_api/response"
-
-module FeverAPI
-  class Endpoint < Sinatra::Base
-    configure do
-      set :database_file, "config/database.yml"
-
-      register Sinatra::ActiveRecordExtension
-      ActiveRecord::Base.include_root_in_json = false
-    end
-
-    before do
-      headers = { "Content-Type" => "application/json" }
-      body = { api_version: FeverAPI::API_VERSION, auth: 0 }.to_json
-      halt 200, headers, body unless authenticated?(params[:api_key])
-    end
-
-    def authenticated?(api_key)
-      return unless api_key
-      user = User.first
-      user.api_key && api_key.casecmp(user.api_key).zero?
-    end
-
-    get "/" do
-      content_type :json
-      build_response(params)
-    end
-
-    post "/" do
-      content_type :json
-      build_response(params)
-    end
-
-    def build_response(params)
-      FeverAPI::Response.new(params).to_json
-    end
-  end
-end
diff --git a/lib/admin_constraint.rb b/lib/admin_constraint.rb
new file mode 100644
index 000000000..0b17c2ade
--- /dev/null
+++ b/lib/admin_constraint.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AdminConstraint
+  def matches?(request)
+    request.session.key?(:user_id) &&
+      User.find(request.session[:user_id]).admin?
+  end
+end
diff --git a/lib/assets/.keep b/lib/assets/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/lib/tasks/.keep b/lib/tasks/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/log/.keep b/log/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/public/400.html b/public/400.html
new file mode 100644
index 000000000..282dbc8cc
--- /dev/null
+++ b/public/400.html
@@ -0,0 +1,114 @@
+<!doctype html>
+
+<html lang="en">
+
+  <head>
+
+    <title>The server cannot process the request due to a client error (400 Bad Request)</title>
+
+    <meta charset="utf-8">
+    <meta name="viewport" content="initial-scale=1, width=device-width">
+    <meta name="robots" content="noindex, nofollow">
+
+    <style>
+
+      *, *::before, *::after {
+        box-sizing: border-box;
+      }
+
+      * {
+        margin: 0;
+      }
+
+      html {
+        font-size: 16px;
+      }
+
+      body {
+        background: #FFF;
+        color: #261B23;
+        display: grid;
+        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+        font-size: clamp(1rem, 2.5vw, 2rem);
+        -webkit-font-smoothing: antialiased;
+        font-style: normal;
+        font-weight: 400;
+        letter-spacing: -0.0025em;
+        line-height: 1.4;
+        min-height: 100vh;
+        place-items: center;
+        text-rendering: optimizeLegibility;
+        -webkit-text-size-adjust: 100%;
+      }
+
+      a {
+        color: inherit;
+        font-weight: 700;
+        text-decoration: underline;
+        text-underline-offset: 0.0925em;
+      }
+
+      b, strong {
+        font-weight: 700;
+      }
+
+      i, em {
+        font-style: italic;
+      }
+
+      main {
+        display: grid;
+        gap: 1em;
+        padding: 2em;
+        place-items: center;
+        text-align: center;
+      }
+
+      main header {
+        width: min(100%, 12em);
+      }
+
+      main header svg {
+        height: auto;
+        max-width: 100%;
+        width: 100%;
+      }
+
+      main article {
+        width: min(100%, 30em);
+      }
+
+      main article p {
+        font-size: 75%;
+      }
+
+      main article br {
+
+        display: none;
+
+        @media(min-width: 48em) {
+          display: inline;
+        }
+
+      }
+
+    </style>
+
+  </head>
+
+  <body>
+
+    <!-- This file lives in public/400.html -->
+
+    <main>
+      <header>
+        <svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" fill="#f0eff0"/><path d="m123.606 85.4445c3.212 1.0523 5.538 4.2089 5.538 8.0301 0 6.1472-4.209 9.5254-11.298 9.5254h-15.617v-34.0033h14.565c7.089 0 11.353 3.1566 11.353 9.2484 0 3.6551-2.049 6.3134-4.541 7.1994zm-12.904-2.9905h5.095c2.603 0 3.988-.9968 3.988-3.1013 0-2.1044-1.385-3.0459-3.988-3.0459h-5.095zm0 6.6456v6.5902h5.981c2.492 0 3.877-1.3291 3.877-3.2674 0-2.049-1.385-3.3228-3.877-3.3228zm43.786 13.9004h-8.362v-1.274c-.831.831-3.323 1.717-5.981 1.717-4.929 0-9.083-2.769-9.083-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.483 1.3845v-1.606c0-1.606-.942-2.9905-3.046-2.9905-1.606 0-2.548.7199-2.935 1.8275h-8.197c.72-4.8181 4.985-8.6393 11.409-8.6393 7.088 0 11.131 3.7659 11.131 10.2453zm-8.362-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.434.7199-3.434 2.3813 0 1.7168 1.717 2.4367 3.434 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm27.996 6.9779v-1.994c-1.163 1.329-3.599 2.548-6.147 2.548-7.199 0-11.131-5.8151-11.131-13.0145s3.932-13.0143 11.131-13.0143c2.548 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.664-1.3291-2.159-2.326-3.821-2.326-2.99 0-4.763 2.4368-4.763 5.6488s1.773 5.5934 4.763 5.5934c1.717 0 3.157-.9415 3.821-2.326zm35.471-2.049h-3.101v11.2421h-8.806v-34.0033h15.285c7.31 0 12.35 4.1535 12.35 11.5744 0 5.1503-2.603 8.6947-6.757 10.2453l7.975 12.1836h-9.858zm-3.101-15.2849v8.1962h5.538c3.156 0 4.596-1.606 4.596-4.0981s-1.44-4.0981-4.596-4.0981zm36.957 17.8323h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm30.98 27.5234v-10.799c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.9259-11.132-13.0145 0-7.144 3.932-13.0143 11.132-13.0143 2.547 0 4.984 1.2184 6.147 2.5475v-1.9937h8.695v33.726zm0-17.9981v-6.5902c-.665-1.3291-2.105-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.661 0 3.156-.9415 3.821-2.326zm36.789-15.7279v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.996 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm19.084 16.2263h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.963 5.095 11.963 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm13.428 11.0206h8.474c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.939-1.5506l-4.873-.9969c-4.154-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.762-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.741-8.2518zm27.538-.8861v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.993-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.871 0-9.193-2.769-9.193-9.0819z" fill="#d30001"/></svg>
+      </header>
+      <article>
+        <p><strong>The server cannot process the request due to a client error.</strong> Please check the request and try again. If you’re the application owner check the logs for more information.</p>
+      </article>
+    </main>
+
+  </body>
+
+</html>
diff --git a/public/404.html b/public/404.html
new file mode 100644
index 000000000..c0670bc87
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,114 @@
+<!doctype html>
+
+<html lang="en">
+
+  <head>
+
+    <title>The page you were looking for doesn’t exist (404 Not found)</title>
+
+    <meta charset="utf-8">
+    <meta name="viewport" content="initial-scale=1, width=device-width">
+    <meta name="robots" content="noindex, nofollow">
+
+    <style>
+
+      *, *::before, *::after {
+        box-sizing: border-box;
+      }
+
+      * {
+        margin: 0;
+      }
+
+      html {
+        font-size: 16px;
+      }
+
+      body {
+        background: #FFF;
+        color: #261B23;
+        display: grid;
+        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+        font-size: clamp(1rem, 2.5vw, 2rem);
+        -webkit-font-smoothing: antialiased;
+        font-style: normal;
+        font-weight: 400;
+        letter-spacing: -0.0025em;
+        line-height: 1.4;
+        min-height: 100vh;
+        place-items: center;
+        text-rendering: optimizeLegibility;
+        -webkit-text-size-adjust: 100%;
+      }
+
+      a {
+        color: inherit;
+        font-weight: 700;
+        text-decoration: underline;
+        text-underline-offset: 0.0925em;
+      }
+
+      b, strong {
+        font-weight: 700;
+      }
+
+      i, em {
+        font-style: italic;
+      }
+
+      main {
+        display: grid;
+        gap: 1em;
+        padding: 2em;
+        place-items: center;
+        text-align: center;
+      }
+
+      main header {
+        width: min(100%, 12em);
+      }
+
+      main header svg {
+        height: auto;
+        max-width: 100%;
+        width: 100%;
+      }
+
+      main article {
+        width: min(100%, 30em);
+      }
+
+      main article p {
+        font-size: 75%;
+      }
+
+      main article br {
+
+        display: none;
+
+        @media(min-width: 48em) {
+          display: inline;
+        }
+
+      }
+
+    </style>
+
+  </head>
+
+  <body>
+
+    <!-- This file lives in public/404.html -->
+
+    <main>
+      <header>
+        <svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm165.328-35.41581-45.689 100.02991h26.224v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.184v-31.901l50.285-103.27391z" fill="#f0eff0"/><path d="m157.758 68.9967v34.0033h-7.199l-14.233-19.8814v19.8814h-8.584v-34.0033h8.307l13.125 18.7184v-18.7184zm28.454 21.5428c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.528 0c0-3.4336-1.496-5.8703-4.209-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.209-2.3813 4.209-5.8149zm13.184 3.8766v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm37.027 8.5839h-8.806v-34.0033h23.924v7.6978h-15.118v6.7564h13.9v7.5316h-13.9zm41.876-12.4605c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm35.337-12.4605v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.997 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm4.076 24.921v-24.921h8.694v2.1598c1.385-1.5506 3.822-2.7136 6.701-2.7136 5.538 0 8.806 3.5997 8.806 9.1377v16.3371h-8.639v-14.2327c0-2.049-1.053-3.5443-3.268-3.5443-1.717 0-3.156.9969-3.6 2.7136v15.0634zm44.113 0v-1.994c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.8151-11.132-13.0145s3.932-13.0143 11.132-13.0143c2.547 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.665-1.3291-2.16-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.717 0 3.156-.9415 3.821-2.326z" fill="#d30001"/></svg>
+      </header>
+      <article>
+        <p><strong>The page you were looking for doesn’t exist.</strong> You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.</p>
+      </article>
+    </main>
+
+  </body>
+
+</html>
diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html
new file mode 100644
index 000000000..9532a9ccd
--- /dev/null
+++ b/public/406-unsupported-browser.html
@@ -0,0 +1,114 @@
+<!doctype html>
+
+<html lang="en">
+
+  <head>
+
+    <title>Your browser is not supported (406 Not Acceptable)</title>
+
+    <meta charset="utf-8">
+    <meta name="viewport" content="initial-scale=1, width=device-width">
+    <meta name="robots" content="noindex, nofollow">
+
+    <style>
+
+      *, *::before, *::after {
+        box-sizing: border-box;
+      }
+
+      * {
+        margin: 0;
+      }
+
+      html {
+        font-size: 16px;
+      }
+
+      body {
+        background: #FFF;
+        color: #261B23;
+        display: grid;
+        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+        font-size: clamp(1rem, 2.5vw, 2rem);
+        -webkit-font-smoothing: antialiased;
+        font-style: normal;
+        font-weight: 400;
+        letter-spacing: -0.0025em;
+        line-height: 1.4;
+        min-height: 100vh;
+        place-items: center;
+        text-rendering: optimizeLegibility;
+        -webkit-text-size-adjust: 100%;
+      }
+
+      a {
+        color: inherit;
+        font-weight: 700;
+        text-decoration: underline;
+        text-underline-offset: 0.0925em;
+      }
+
+      b, strong {
+        font-weight: 700;
+      }
+
+      i, em {
+        font-style: italic;
+      }
+
+      main {
+        display: grid;
+        gap: 1em;
+        padding: 2em;
+        place-items: center;
+        text-align: center;
+      }
+
+      main header {
+        width: min(100%, 12em);
+      }
+
+      main header svg {
+        height: auto;
+        max-width: 100%;
+        width: 100%;
+      }
+
+      main article {
+        width: min(100%, 30em);
+      }
+
+      main article p {
+        font-size: 75%;
+      }
+
+      main article br {
+
+        display: none;
+
+        @media(min-width: 48em) {
+          display: inline;
+        }
+
+      }
+
+    </style>
+
+  </head>
+
+  <body>
+
+    <!-- This file lives in public/406-unsupported-browser.html -->
+
+    <main>
+      <header>
+        <svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm202.906 9.7326h-41.093c-2.433-7.2994-7.84-12.4361-17.302-12.4361-16.221 0-25.413 17.5728-25.954 34.8752v1.3517c5.137-7.0291 16.221-12.4361 30.82-12.4361 33.524 0 54.881 24.0612 54.881 53.7998 0 33.253-23.791 58.396-61.64 58.396-21.628 0-39.741-10.003-50.825-27.576-9.733-14.599-13.788-32.442-13.788-54.3406 0-51.9072 24.331-89.485807 66.236-89.485807 32.712 0 53.258 18.654107 58.665 47.851907zm-82.727 66.2355c0 13.247 9.463 22.439 22.71 22.439 12.977 0 22.439-9.192 22.439-22.439 0-13.517-9.462-22.7091-22.439-22.7091-13.247 0-22.71 9.1921-22.71 22.7091z" fill="#f0eff0"/><path d="m100.761 68.9967v34.0033h-7.1991l-14.2326-19.8814v19.8814h-8.5839v-34.0033h8.307l13.125 18.7184v-18.7184zm28.454 21.5428c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm13.185 3.8766v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.02-25.4194h9.083l12.958 34.0033h-9.027l-2.436-6.5902h-12.35l-2.381 6.5902h-8.806zm4.431 10.5222-3.489 9.5807h6.978zm17.44 11.0206c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm25.676 0c0-7.6978 5.095-13.0143 12.572-13.0143 6.701 0 10.854 3.9874 11.574 9.8023h-8.418c-.221-1.4953-1.384-2.6029-3.156-2.6029-2.437 0-3.988 2.2706-3.988 5.8149s1.551 5.7595 3.988 5.7595c1.772 0 2.935-1.0522 3.156-2.5475h8.418c-.72 5.7596-4.873 9.8025-11.574 9.8025-7.477 0-12.572-5.3167-12.572-13.0145zm42.013 3.7658h8.031c-.887 5.7597-5.206 9.2487-11.686 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.317-13.0143 12.516-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.319 4.5965 1.773 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm23.4 16.7244v10.799h-8.694v-33.726h8.694v1.9937c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8703 11.131 13.0143 0 7.0886-3.932 13.0145-11.131 13.0145-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.16 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.717 0-3.157.9969-3.822 2.326zm21.892 7.1994v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.942 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm39.458 8.5839h-8.363v-1.274c-.83.831-3.322 1.717-5.981 1.717-4.928 0-9.082-2.769-9.082-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.045-2.9905-1.606 0-2.548.7199-2.936 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.553-1.0522-2.049-1.7167-3.655-1.7167-1.716 0-3.433.7199-3.433 2.3813 0 1.7168 1.717 2.4367 3.433 2.4367 1.606 0 3.102-.6645 3.655-1.6614zm20.742 4.9839v1.994h-8.694v-35.997h8.694v13.0697c1.163-1.3291 3.6-2.5475 6.148-2.5475 7.199 0 11.131 5.8149 11.131 13.0143s-3.932 13.0145-11.131 13.0145c-2.548 0-4.985-1.219-6.148-2.548zm0-13.7893v6.5902c.665 1.3845 2.105 2.326 3.822 2.326 2.99 0 4.762-2.3814 4.762-5.5934s-1.772-5.6488-4.762-5.6488c-1.662 0-3.157.9969-3.822 2.326zm28.759-20.2137v35.997h-8.695v-35.997zm19.172 27.3023h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.384-3.5997-3.544-3.5997z" fill="#d30001"/></svg>
+      </header>
+      <article>
+        <p><strong>Your browser is not supported.</strong><br> Please upgrade your browser to continue.</p>
+      </article>
+    </main>
+
+  </body>
+
+</html>
diff --git a/public/422.html b/public/422.html
new file mode 100644
index 000000000..8bcf06014
--- /dev/null
+++ b/public/422.html
@@ -0,0 +1,114 @@
+<!doctype html>
+
+<html lang="en">
+
+  <head>
+
+    <title>The change you wanted was rejected (422 Unprocessable Entity)</title>
+
+    <meta charset="utf-8">
+    <meta name="viewport" content="initial-scale=1, width=device-width">
+    <meta name="robots" content="noindex, nofollow">
+
+    <style>
+
+      *, *::before, *::after {
+        box-sizing: border-box;
+      }
+
+      * {
+        margin: 0;
+      }
+
+      html {
+        font-size: 16px;
+      }
+
+      body {
+        background: #FFF;
+        color: #261B23;
+        display: grid;
+        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+        font-size: clamp(1rem, 2.5vw, 2rem);
+        -webkit-font-smoothing: antialiased;
+        font-style: normal;
+        font-weight: 400;
+        letter-spacing: -0.0025em;
+        line-height: 1.4;
+        min-height: 100vh;
+        place-items: center;
+        text-rendering: optimizeLegibility;
+        -webkit-text-size-adjust: 100%;
+      }
+
+      a {
+        color: inherit;
+        font-weight: 700;
+        text-decoration: underline;
+        text-underline-offset: 0.0925em;
+      }
+
+      b, strong {
+        font-weight: 700;
+      }
+
+      i, em {
+        font-style: italic;
+      }
+
+      main {
+        display: grid;
+        gap: 1em;
+        padding: 2em;
+        place-items: center;
+        text-align: center;
+      }
+
+      main header {
+        width: min(100%, 12em);
+      }
+
+      main header svg {
+        height: auto;
+        max-width: 100%;
+        width: 100%;
+      }
+
+      main article {
+        width: min(100%, 30em);
+      }
+
+      main article p {
+        font-size: 75%;
+      }
+
+      main article br {
+
+        display: none;
+
+        @media(min-width: 48em) {
+          display: inline;
+        }
+
+      }
+
+    </style>
+
+  </head>
+
+  <body>
+
+    <!-- This file lives in public/422.html -->
+
+    <main>
+      <header>
+        <svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm130.453 51.63681c0-8.9215-6.218-15.4099-15.681-15.4099-10.273 0-15.95 7.5698-16.491 16.4913h-44.608c3.244-30.8199 25.683-55.421707 61.099-55.421707 36.498 0 59.477 20.816907 59.477 51.636807 0 21.3577-14.869 36.7676-31.901 52.7186l-27.305 27.035h59.747v37.308h-120.306v-27.846l57.044-56.7736c11.084-11.8954 18.925-20.0059 18.925-29.7385zm140.455 0c0-8.9215-6.218-15.4099-15.68-15.4099-10.274 0-15.951 7.5698-16.492 16.4913h-44.608c3.245-30.8199 25.684-55.421707 61.1-55.421707 36.497 0 59.477 20.816907 59.477 51.636807 0 21.3577-14.87 36.7676-31.902 52.7186l-27.305 27.035h59.747v37.308h-120.305v-27.846l57.043-56.7736c11.085-11.8954 18.925-20.0059 18.925-29.7385z" fill="#f0eff0"/><path d="m19.3936 103.554c-8.9715 0-14.84183-5.0952-14.84183-14.4544v-20.1029h8.86083v19.3276c0 4.8181 2.2706 7.3102 5.981 7.3102 3.6551 0 5.9257-2.4921 5.9257-7.3102v-19.3276h8.8608v20.1583c0 9.3038-5.8149 14.399-14.7865 14.399zm18.734-.554v-24.921h8.6947v2.1598c1.3845-1.5506 3.8212-2.7136 6.701-2.7136 5.538 0 8.8054 3.5997 8.8054 9.1377v16.3371h-8.6393v-14.2327c0-2.049-1.0522-3.5443-3.2674-3.5443-1.7168 0-3.1567.9969-3.5997 2.7136v15.0634zm36.8584-1.994v10.799h-8.6946v-33.726h8.6946v1.9937c1.163-1.3291 3.5997-2.5475 6.1472-2.5475 7.1994 0 11.1314 5.8703 11.1314 13.0143 0 7.0886-3.932 13.0145-11.1314 13.0145-2.5475 0-4.9842-1.219-6.1472-2.548zm0-13.7893v6.5902c.6646 1.3845 2.1599 2.326 3.8213 2.326 2.9905 0 4.7626-2.3814 4.7626-5.5934s-1.7721-5.6488-4.7626-5.6488c-1.7168 0-3.1567.9969-3.8213 2.326zm36.789-9.2485v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.6949v-24.921h8.6949v2.2152c1.218-1.6614 3.156-2.769 5.648-2.769 1.108 0 1.994.2215 2.382.443zm26.769 12.5713c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.528 0c0-3.4336-1.496-5.8703-4.209-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.209-2.3813 4.209-5.8149zm10.352 0c0-7.6978 5.095-13.0143 12.571-13.0143 6.701 0 10.855 3.9874 11.574 9.8023h-8.417c-.222-1.4953-1.385-2.6029-3.157-2.6029-2.437 0-3.987 2.2706-3.987 5.8149s1.55 5.7595 3.987 5.7595c1.772 0 2.935-1.0522 3.157-2.5475h8.417c-.719 5.7596-4.873 9.8025-11.574 9.8025-7.476 0-12.571-5.3167-12.571-13.0145zm42.013 3.7658h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.544-3.5997zm13.428 11.0206h8.473c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.938-1.5506l-4.874-.9969c-4.153-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.763-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.74-8.2518zm24.269 0h8.474c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.939-1.5506l-4.873-.9969c-4.154-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.763-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.741-8.2518zm47.918 7.6978h-8.363v-1.274c-.831.831-3.323 1.717-5.981 1.717-4.929 0-9.082-2.769-9.082-8.0301 0-4.818 4.153-7.9193 9.581-7.9193 2.049 0 4.485.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.046-2.9905-1.606 0-2.547.7199-2.935 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.434.7199-3.434 2.3813 0 1.7168 1.717 2.4367 3.434 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm20.742 4.9839v1.994h-8.695v-35.997h8.695v13.0697c1.163-1.3291 3.6-2.5475 6.147-2.5475 7.2 0 11.132 5.8149 11.132 13.0143s-3.932 13.0145-11.132 13.0145c-2.547 0-4.984-1.219-6.147-2.548zm0-13.7893v6.5902c.665 1.3845 2.105 2.326 3.821 2.326 2.991 0 4.763-2.3814 4.763-5.5934s-1.772-5.6488-4.763-5.6488c-1.661 0-3.156.9969-3.821 2.326zm28.759-20.2137v35.997h-8.695v-35.997zm19.172 27.3023h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm25.461-15.2849h24.311v7.6424h-15.561v5.3165h14.232v7.4763h-14.232v5.8703h15.561v7.6978h-24.311zm27.942 34.0033v-24.921h8.694v2.1598c1.385-1.5506 3.822-2.7136 6.701-2.7136 5.538 0 8.806 3.5997 8.806 9.1377v16.3371h-8.639v-14.2327c0-2.049-1.053-3.5443-3.268-3.5443-1.717 0-3.157.9969-3.6 2.7136v15.0634zm29.991-8.5839v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.942 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm26.161-16.3371v24.921h-8.694v-24.921zm.61-6.7564c0 2.8244-2.271 4.652-4.929 4.652s-4.929-1.8276-4.929-4.652c0-2.8797 2.271-4.7073 4.929-4.7073s4.929 1.8276 4.929 4.7073zm5.382 23.0935v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.206v6.7564h-5.206v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.994-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.87 0-9.193-2.769-9.193-9.0819zm29.22 17.3889h-8.584l3.655-9.414-9.303-24.312h9.026l4.763 14.1773 4.652-14.1773h8.639z" fill="#d30001"/></svg>
+      </header>
+      <article>
+        <p><strong>The change you wanted was rejected.</strong> Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.</p>
+      </article>
+    </main>
+
+  </body>
+
+</html>
diff --git a/public/500.html b/public/500.html
new file mode 100644
index 000000000..d77718c3a
--- /dev/null
+++ b/public/500.html
@@ -0,0 +1,114 @@
+<!doctype html>
+
+<html lang="en">
+
+  <head>
+
+    <title>We’re sorry, but something went wrong (500 Internal Server Error)</title>
+
+    <meta charset="utf-8">
+    <meta name="viewport" content="initial-scale=1, width=device-width">
+    <meta name="robots" content="noindex, nofollow">
+
+    <style>
+
+      *, *::before, *::after {
+        box-sizing: border-box;
+      }
+
+      * {
+        margin: 0;
+      }
+
+      html {
+        font-size: 16px;
+      }
+
+      body {
+        background: #FFF;
+        color: #261B23;
+        display: grid;
+        font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+        font-size: clamp(1rem, 2.5vw, 2rem);
+        -webkit-font-smoothing: antialiased;
+        font-style: normal;
+        font-weight: 400;
+        letter-spacing: -0.0025em;
+        line-height: 1.4;
+        min-height: 100vh;
+        place-items: center;
+        text-rendering: optimizeLegibility;
+        -webkit-text-size-adjust: 100%;
+      }
+
+      a {
+        color: inherit;
+        font-weight: 700;
+        text-decoration: underline;
+        text-underline-offset: 0.0925em;
+      }
+
+      b, strong {
+        font-weight: 700;
+      }
+
+      i, em {
+        font-style: italic;
+      }
+
+      main {
+        display: grid;
+        gap: 1em;
+        padding: 2em;
+        place-items: center;
+        text-align: center;
+      }
+
+      main header {
+        width: min(100%, 12em);
+      }
+
+      main header svg {
+        height: auto;
+        max-width: 100%;
+        width: 100%;
+      }
+
+      main article {
+        width: min(100%, 30em);
+      }
+
+      main article p {
+        font-size: 75%;
+      }
+
+      main article br {
+
+        display: none;
+
+        @media(min-width: 48em) {
+          display: inline;
+        }
+
+      }
+
+    </style>
+
+  </head>
+
+  <body>
+
+    <!-- This file lives in public/500.html -->
+
+    <main>
+      <header>
+        <svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m101.23 93.8427c-8.1103 0-15.4098 3.7849-19.7354 8.3813h-36.2269v-99.21891h103.8143v37.03791h-68.3984v24.8722c5.1366-2.7035 15.1396-5.9477 24.6014-5.9477 35.146 0 56.233 22.7094 56.233 55.4215 0 34.605-23.791 57.315-60.558 57.315-37.8492 0-61.64-22.169-63.8028-55.963h42.9857c1.0814 10.814 9.1919 19.195 21.6281 19.195 11.355 0 19.465-8.381 19.465-20.547 0-11.625-7.299-20.5463-20.006-20.5463zm138.833 77.8613c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" fill="#f0eff0"/><path d="m23.1377 68.9967v34.0033h-8.9162v-34.0033zm4.3157 34.0033v-24.921h8.6947v2.1598c1.3845-1.5506 3.8212-2.7136 6.701-2.7136 5.538 0 8.8054 3.5997 8.8054 9.1377v16.3371h-8.6393v-14.2327c0-2.049-1.0522-3.5443-3.2674-3.5443-1.7168 0-3.1567.9969-3.5997 2.7136v15.0634zm29.9913-8.5839v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.5839v6.8671h5.2058v6.7564h-5.2058v8.307c0 1.9383.9415 2.769 2.6583 2.769.9414 0 1.9937-.2216 2.769-.5538v7.3654c-.9969.443-2.8798.775-4.8181.775-5.8703 0-9.1931-2.769-9.1931-9.0819zm32.3666-.1108h8.0301c-.8861 5.7597-5.2057 9.2487-11.6852 9.2487-7.6424 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.3165-13.0143 12.5159-13.0143 7.6424 0 11.9621 5.095 11.9621 12.5159v2.1598h-16.1156c.2769 2.9905 1.8275 4.5965 4.3196 4.5965 1.7722 0 3.1567-.7753 3.6551-2.4921zm-3.8212-10.0237c-2.0491 0-3.4336 1.2737-3.9874 3.5997h7.5317c-.1107-2.0491-1.3845-3.5997-3.5443-3.5997zm31.4299-6.3134v8.3624c-1.052-.5538-2.215-.7753-3.599-.7753-2.382 0-3.988 1.0522-4.431 2.8244v14.6203h-8.694v-24.921h8.694v2.2152c1.219-1.6614 3.157-2.769 5.649-2.769 1.108 0 1.994.2215 2.381.443zm2.949 25.0318v-24.921h8.694v2.1598c1.385-1.5506 3.821-2.7136 6.701-2.7136 5.538 0 8.806 3.5997 8.806 9.1377v16.3371h-8.64v-14.2327c0-2.049-1.052-3.5443-3.267-3.5443-1.717 0-3.157.9969-3.6 2.7136v15.0634zm50.371 0h-8.363v-1.274c-.83.831-3.323 1.717-5.981 1.717-4.929 0-9.082-2.769-9.082-8.0301 0-4.818 4.153-7.9193 9.581-7.9193 2.049 0 4.485.6646 5.482 1.3845v-1.606c0-1.606-.941-2.9905-3.046-2.9905-1.606 0-2.547.7199-2.935 1.8275h-8.196c.72-4.8181 4.984-8.6393 11.408-8.6393 7.089 0 11.132 3.7659 11.132 10.2453zm-8.363-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.433.7199-3.433 2.3813 0 1.7168 1.716 2.4367 3.433 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm20.742-29.0191v35.997h-8.694v-35.997zm13.036 25.9178h9.248c.72 2.326 2.714 3.489 5.483 3.489 2.713 0 4.596-1.163 4.596-3.2674 0-1.6061-1.052-2.326-3.212-2.8244l-6.534-1.3845c-4.985-1.1076-8.751-3.7105-8.751-9.47 0-6.6456 5.538-11.0206 13.07-11.0206 8.307 0 13.014 4.5411 13.956 10.4114h-8.695c-.72-1.8829-2.27-3.3228-5.205-3.3228-2.548 0-4.265 1.1076-4.265 2.9905 0 1.4953 1.052 2.326 2.825 2.7137l6.645 1.5506c5.815 1.3845 9.027 4.5412 9.027 9.8023 0 6.9778-5.87 10.9654-13.291 10.9654-8.141 0-13.679-3.9322-14.897-10.6332zm46.509 1.3845h8.031c-.887 5.7597-5.206 9.2487-11.686 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.317-13.0143 12.516-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.319 4.5965 1.773 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm31.431-6.3134v8.3624c-1.053-.5538-2.216-.7753-3.6-.7753-2.381 0-3.988 1.0522-4.431 2.8244v14.6203h-8.694v-24.921h8.694v2.2152c1.219-1.6614 3.157-2.769 5.649-2.769 1.108 0 1.994.2215 2.382.443zm18.288 25.0318h-7.809l-9.47-24.921h8.861l4.763 14.288 4.652-14.288h8.528zm25.614-8.6947h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.642 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.516-13.0143 7.642 0 11.962 5.095 11.962 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.157-.7753 3.655-2.4921zm-3.821-10.0237c-2.049 0-3.434 1.2737-3.988 3.5997h7.532c-.111-2.0491-1.384-3.5997-3.544-3.5997zm31.43-6.3134v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.157-2.769 5.649-2.769 1.107 0 1.993.2215 2.381.443zm13.703-8.9715h24.312v7.6424h-15.562v5.3165h14.232v7.4763h-14.232v5.8703h15.562v7.6978h-24.312zm44.667 8.9715v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.156-2.769 5.648-2.769 1.108 0 1.994.2215 2.382.443zm19.673 0v8.3624c-1.053-.5538-2.216-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.156-2.769 5.648-2.769 1.108 0 1.994.2215 2.382.443zm26.769 12.5713c0 7.6978-5.15 13.0145-12.737 13.0145-7.532 0-12.738-5.3167-12.738-13.0145s5.206-13.0143 12.738-13.0143c7.587 0 12.737 5.3165 12.737 13.0143zm-8.529 0c0-3.4336-1.495-5.8703-4.208-5.8703-2.659 0-4.154 2.4367-4.154 5.8703s1.495 5.8149 4.154 5.8149c2.713 0 4.208-2.3813 4.208-5.8149zm28.082-12.5713v8.3624c-1.052-.5538-2.215-.7753-3.6-.7753-2.381 0-3.987 1.0522-4.43 2.8244v14.6203h-8.695v-24.921h8.695v2.2152c1.218-1.6614 3.157-2.769 5.649-2.769 1.107 0 1.993.2215 2.381.443z" fill="#d30001"/></svg>
+      </header>
+      <article>
+        <p><strong>We’re sorry, but something went wrong.</strong><br> If you’re the application owner check the logs for more information.</p>
+      </article>
+    </main>
+
+  </body>
+
+</html>
diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png
new file mode 100644
index 000000000..e69de29bb
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 000000000..e69de29bb
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/public/fonts/FontAwesome.otf b/public/fonts/FontAwesome.otf
similarity index 100%
rename from app/public/fonts/FontAwesome.otf
rename to public/fonts/FontAwesome.otf
diff --git a/app/public/fonts/fontawesome-webfont.eot b/public/fonts/fontawesome-webfont.eot
similarity index 100%
rename from app/public/fonts/fontawesome-webfont.eot
rename to public/fonts/fontawesome-webfont.eot
diff --git a/app/public/fonts/fontawesome-webfont.svg b/public/fonts/fontawesome-webfont.svg
similarity index 100%
rename from app/public/fonts/fontawesome-webfont.svg
rename to public/fonts/fontawesome-webfont.svg
diff --git a/app/public/fonts/fontawesome-webfont.ttf b/public/fonts/fontawesome-webfont.ttf
similarity index 100%
rename from app/public/fonts/fontawesome-webfont.ttf
rename to public/fonts/fontawesome-webfont.ttf
diff --git a/app/public/fonts/fontawesome-webfont.woff b/public/fonts/fontawesome-webfont.woff
similarity index 100%
rename from app/public/fonts/fontawesome-webfont.woff
rename to public/fonts/fontawesome-webfont.woff
diff --git a/app/public/fonts/lato/Lato-Black.eot b/public/fonts/lato/Lato-Black.eot
similarity index 100%
rename from app/public/fonts/lato/Lato-Black.eot
rename to public/fonts/lato/Lato-Black.eot
diff --git a/app/public/fonts/lato/Lato-Black.ttf b/public/fonts/lato/Lato-Black.ttf
similarity index 100%
rename from app/public/fonts/lato/Lato-Black.ttf
rename to public/fonts/lato/Lato-Black.ttf
diff --git a/app/public/fonts/lato/Lato-Black.woff b/public/fonts/lato/Lato-Black.woff
similarity index 100%
rename from app/public/fonts/lato/Lato-Black.woff
rename to public/fonts/lato/Lato-Black.woff
diff --git a/app/public/fonts/lato/Lato-Black.woff2 b/public/fonts/lato/Lato-Black.woff2
similarity index 100%
rename from app/public/fonts/lato/Lato-Black.woff2
rename to public/fonts/lato/Lato-Black.woff2
diff --git a/app/public/fonts/lato/Lato-Bold.eot b/public/fonts/lato/Lato-Bold.eot
similarity index 100%
rename from app/public/fonts/lato/Lato-Bold.eot
rename to public/fonts/lato/Lato-Bold.eot
diff --git a/app/public/fonts/lato/Lato-Bold.ttf b/public/fonts/lato/Lato-Bold.ttf
similarity index 100%
rename from app/public/fonts/lato/Lato-Bold.ttf
rename to public/fonts/lato/Lato-Bold.ttf
diff --git a/app/public/fonts/lato/Lato-Bold.woff b/public/fonts/lato/Lato-Bold.woff
similarity index 100%
rename from app/public/fonts/lato/Lato-Bold.woff
rename to public/fonts/lato/Lato-Bold.woff
diff --git a/app/public/fonts/lato/Lato-Bold.woff2 b/public/fonts/lato/Lato-Bold.woff2
similarity index 100%
rename from app/public/fonts/lato/Lato-Bold.woff2
rename to public/fonts/lato/Lato-Bold.woff2
diff --git a/app/public/fonts/lato/Lato-Italic.eot b/public/fonts/lato/Lato-Italic.eot
similarity index 100%
rename from app/public/fonts/lato/Lato-Italic.eot
rename to public/fonts/lato/Lato-Italic.eot
diff --git a/app/public/fonts/lato/Lato-Italic.ttf b/public/fonts/lato/Lato-Italic.ttf
similarity index 100%
rename from app/public/fonts/lato/Lato-Italic.ttf
rename to public/fonts/lato/Lato-Italic.ttf
diff --git a/app/public/fonts/lato/Lato-Italic.woff b/public/fonts/lato/Lato-Italic.woff
similarity index 100%
rename from app/public/fonts/lato/Lato-Italic.woff
rename to public/fonts/lato/Lato-Italic.woff
diff --git a/app/public/fonts/lato/Lato-Italic.woff2 b/public/fonts/lato/Lato-Italic.woff2
similarity index 100%
rename from app/public/fonts/lato/Lato-Italic.woff2
rename to public/fonts/lato/Lato-Italic.woff2
diff --git a/app/public/fonts/lato/Lato-Light.eot b/public/fonts/lato/Lato-Light.eot
similarity index 100%
rename from app/public/fonts/lato/Lato-Light.eot
rename to public/fonts/lato/Lato-Light.eot
diff --git a/app/public/fonts/lato/Lato-Light.ttf b/public/fonts/lato/Lato-Light.ttf
similarity index 100%
rename from app/public/fonts/lato/Lato-Light.ttf
rename to public/fonts/lato/Lato-Light.ttf
diff --git a/app/public/fonts/lato/Lato-Light.woff b/public/fonts/lato/Lato-Light.woff
similarity index 100%
rename from app/public/fonts/lato/Lato-Light.woff
rename to public/fonts/lato/Lato-Light.woff
diff --git a/app/public/fonts/lato/Lato-Light.woff2 b/public/fonts/lato/Lato-Light.woff2
similarity index 100%
rename from app/public/fonts/lato/Lato-Light.woff2
rename to public/fonts/lato/Lato-Light.woff2
diff --git a/app/public/fonts/lato/Lato-Regular.eot b/public/fonts/lato/Lato-Regular.eot
similarity index 100%
rename from app/public/fonts/lato/Lato-Regular.eot
rename to public/fonts/lato/Lato-Regular.eot
diff --git a/app/public/fonts/lato/Lato-Regular.ttf b/public/fonts/lato/Lato-Regular.ttf
similarity index 100%
rename from app/public/fonts/lato/Lato-Regular.ttf
rename to public/fonts/lato/Lato-Regular.ttf
diff --git a/app/public/fonts/lato/Lato-Regular.woff b/public/fonts/lato/Lato-Regular.woff
similarity index 100%
rename from app/public/fonts/lato/Lato-Regular.woff
rename to public/fonts/lato/Lato-Regular.woff
diff --git a/app/public/fonts/lato/Lato-Regular.woff2 b/public/fonts/lato/Lato-Regular.woff2
similarity index 100%
rename from app/public/fonts/lato/Lato-Regular.woff2
rename to public/fonts/lato/Lato-Regular.woff2
diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.eot b/public/fonts/reenie-beanie/ReenieBeanie.eot
similarity index 100%
rename from app/public/fonts/reenie-beanie/ReenieBeanie.eot
rename to public/fonts/reenie-beanie/ReenieBeanie.eot
diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.svg b/public/fonts/reenie-beanie/ReenieBeanie.svg
similarity index 100%
rename from app/public/fonts/reenie-beanie/ReenieBeanie.svg
rename to public/fonts/reenie-beanie/ReenieBeanie.svg
diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.ttf b/public/fonts/reenie-beanie/ReenieBeanie.ttf
similarity index 100%
rename from app/public/fonts/reenie-beanie/ReenieBeanie.ttf
rename to public/fonts/reenie-beanie/ReenieBeanie.ttf
diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.woff b/public/fonts/reenie-beanie/ReenieBeanie.woff
similarity index 100%
rename from app/public/fonts/reenie-beanie/ReenieBeanie.woff
rename to public/fonts/reenie-beanie/ReenieBeanie.woff
diff --git a/app/public/fonts/reenie-beanie/ReenieBeanie.woff2 b/public/fonts/reenie-beanie/ReenieBeanie.woff2
similarity index 100%
rename from app/public/fonts/reenie-beanie/ReenieBeanie.woff2
rename to public/fonts/reenie-beanie/ReenieBeanie.woff2
diff --git a/public/icon.png b/public/icon.png
new file mode 100644
index 000000000..c4c9dbfbb
Binary files /dev/null and b/public/icon.png differ
diff --git a/public/icon.svg b/public/icon.svg
new file mode 100644
index 000000000..04b34bf83
--- /dev/null
+++ b/public/icon.svg
@@ -0,0 +1,3 @@
+<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
+  <circle cx="256" cy="256" r="256" fill="red"/>
+</svg>
diff --git a/app/public/img/apple-touch-icon-precomposed.png b/public/img/apple-touch-icon-precomposed.png
similarity index 100%
rename from app/public/img/apple-touch-icon-precomposed.png
rename to public/img/apple-touch-icon-precomposed.png
diff --git a/app/public/img/arrow-left.png b/public/img/arrow-left.png
similarity index 100%
rename from app/public/img/arrow-left.png
rename to public/img/arrow-left.png
diff --git a/app/public/img/arrow-right-up.png b/public/img/arrow-right-up.png
similarity index 100%
rename from app/public/img/arrow-right-up.png
rename to public/img/arrow-right-up.png
diff --git a/app/public/img/arrow-right.png b/public/img/arrow-right.png
similarity index 100%
rename from app/public/img/arrow-right.png
rename to public/img/arrow-right.png
diff --git a/app/public/img/arrow-up-left.png b/public/img/arrow-up-left.png
similarity index 100%
rename from app/public/img/arrow-up-left.png
rename to public/img/arrow-up-left.png
diff --git a/app/public/img/arrow-up-right.png b/public/img/arrow-up-right.png
similarity index 100%
rename from app/public/img/arrow-up-right.png
rename to public/img/arrow-up-right.png
diff --git a/app/public/img/favicon.png b/public/img/favicon.png
similarity index 100%
rename from app/public/img/favicon.png
rename to public/img/favicon.png
diff --git a/app/public/img/glyphicons-halflings-white.png b/public/img/glyphicons-halflings-white.png
similarity index 100%
rename from app/public/img/glyphicons-halflings-white.png
rename to public/img/glyphicons-halflings-white.png
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 000000000..e6d6c8261
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,7 @@
+{
+  "name": "Stringer",
+  "theme_color": "#F67100",
+  "background_color": "#FAF2E5",
+  "display": "standalone",
+  "start_url": "/"
+}
diff --git a/public/robots.txt b/public/robots.txt
new file mode 100644
index 000000000..c19f78ab6
--- /dev/null
+++ b/public/robots.txt
@@ -0,0 +1 @@
+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
diff --git a/spec/commands/cast_boolean_spec.rb b/spec/commands/cast_boolean_spec.rb
new file mode 100644
index 000000000..6eec442e5
--- /dev/null
+++ b/spec/commands/cast_boolean_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+RSpec.describe CastBoolean do
+  ["true", true, "1"].each do |true_value|
+    it "returns true when passed #{true_value.inspect}" do
+      expect(described_class.call(true_value)).to be(true)
+    end
+  end
+
+  ["false", false, "0"].each do |false_value|
+    it "returns false when passed #{false_value.inspect}" do
+      expect(described_class.call(false_value)).to be(false)
+    end
+  end
+
+  it "raises an error when passed non-boolean value" do
+    ["butt", 0, nil, "", []].each do |bad_value|
+      expected_message = "cannot cast to boolean: #{bad_value.inspect}"
+      expect { described_class.call(bad_value) }
+        .to raise_error(ArgumentError, expected_message)
+    end
+  end
+end
diff --git a/spec/commands/feed/create_spec.rb b/spec/commands/feed/create_spec.rb
new file mode 100644
index 000000000..f2b342c70
--- /dev/null
+++ b/spec/commands/feed/create_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+RSpec.describe Feed::Create do
+  context "feed cannot be discovered" do
+    it "returns false if cant discover any feeds" do
+      expect(FeedDiscovery).to receive(:call).and_return(false)
+      result = described_class.call("http://not-a-feed.com", user: default_user)
+
+      expect(result).to be(false)
+    end
+  end
+
+  context "feed can be discovered" do
+    it "parses and creates the feed if discovered" do
+      feed = build(:feed)
+      feed_result = double(title: feed.name, feed_url: feed.url)
+      expect(FeedDiscovery).to receive(:call).and_return(feed_result)
+
+      expect { described_class.call("http://feed.com", user: default_user) }
+        .to change(Feed, :count).by(1)
+    end
+
+    context "title includes a script tag" do
+      it "deletes the script tag from the title" do
+        feed =
+          double(title: "foo<script>alert('xss');</script>bar", feed_url: nil)
+
+        expect(FeedDiscovery).to receive(:call).and_return(feed)
+
+        feed = described_class.call("http://feed.com", user: default_user)
+
+        expect(feed.name).to eq("foobar")
+      end
+    end
+  end
+
+  it "uses feed_url as name when title is not present" do
+    feed_url = "https://protomen.com/news/feed"
+    result = instance_double(Feedjira::Parser::RSS, title: nil, feed_url:)
+    expect(FeedDiscovery).to receive(:call).and_return(result)
+
+    expect { described_class.call(feed_url, user: default_user) }
+      .to change(Feed, :count).by(1)
+
+    expect(Feed.last.name).to eq(feed_url)
+  end
+end
diff --git a/spec/commands/feed/export_to_opml_spec.rb b/spec/commands/feed/export_to_opml_spec.rb
new file mode 100644
index 000000000..79ca42985
--- /dev/null
+++ b/spec/commands/feed/export_to_opml_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+RSpec.describe Feed::ExportToOpml do
+  it "returns OPML XML" do
+    feed_one, feed_two = build_pair(:feed)
+    result = described_class.call([feed_one, feed_two])
+
+    outlines = Nokogiri.XML(result).xpath("//body//outline")
+    expect(outlines.size).to eq(2)
+    expect(outlines.first["title"]).to eq(feed_one.name)
+    expect(outlines.first["xmlUrl"]).to eq(feed_one.url)
+    expect(outlines.last["title"]).to eq(feed_two.name)
+    expect(outlines.last["xmlUrl"]).to eq(feed_two.url)
+  end
+
+  it "handles empty feeds" do
+    result = described_class.call([])
+
+    outlines = Nokogiri.XML(result).xpath("//body//outline")
+    expect(outlines).to be_empty
+  end
+
+  it "has a proper title" do
+    feed_one, feed_two = build_pair(:feed)
+    result = described_class.call([feed_one, feed_two])
+
+    title = Nokogiri.XML(result).xpath("//head//title").first
+    expect(title.content).to eq("Feeds from Stringer")
+  end
+end
diff --git a/spec/commands/feed/fetch_all_for_user_spec.rb b/spec/commands/feed/fetch_all_for_user_spec.rb
new file mode 100644
index 000000000..9cc0a9371
--- /dev/null
+++ b/spec/commands/feed/fetch_all_for_user_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+RSpec.describe Feed::FetchAllForUser do
+  def stub_pool
+    pool = instance_double(Thread::Pool)
+    expect(Thread).to receive(:pool).and_return(pool)
+    expect(pool).to receive(:process).at_least(:once).and_yield
+    expect(pool).to receive(:shutdown)
+  end
+
+  it "calls Feed::FetchOne for every feed" do
+    stub_pool
+    feed1, feed2 = create_pair(:feed)
+
+    expect { described_class.call(default_user) }
+      .to invoke(:call).on(Feed::FetchOne).with(feed1)
+      .and invoke(:call).on(Feed::FetchOne).with(feed2)
+  end
+end
diff --git a/spec/commands/feed/fetch_all_spec.rb b/spec/commands/feed/fetch_all_spec.rb
new file mode 100644
index 000000000..2232b7a91
--- /dev/null
+++ b/spec/commands/feed/fetch_all_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+RSpec.describe Feed::FetchAll do
+  def stub_pool
+    pool = instance_double(Thread::Pool)
+    expect(Thread).to receive(:pool).and_return(pool)
+    expect(pool).to receive(:process).at_least(:once).and_yield
+    expect(pool).to receive(:shutdown)
+  end
+
+  it "calls Feed::FetchOne for every feed" do
+    stub_pool
+    feed1, feed2 = create_pair(:feed)
+
+    expect { described_class.call }
+      .to invoke(:call).on(Feed::FetchOne).with(feed1)
+      .and invoke(:call).on(Feed::FetchOne).with(feed2)
+  end
+end
diff --git a/spec/commands/feed/fetch_one_spec.rb b/spec/commands/feed/fetch_one_spec.rb
new file mode 100644
index 000000000..12257a74f
--- /dev/null
+++ b/spec/commands/feed/fetch_one_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+RSpec.describe Feed::FetchOne do
+  include ActiveSupport::Testing::TimeHelpers
+
+  def create_entry(**options)
+    entry = {
+      published: Time.zone.now,
+      title: "Run LLMs on my own Mac fast and efficient",
+      url: "https://www.secondstate.io/articles/fast-llm-inference",
+      content: "",
+      id: "test",
+      **options
+    }
+    double(entry)
+  end
+
+  def stub_raw_feed(feed, entries: [])
+    xml = GenerateXml.call(feed, entries)
+    stub_request(:get, feed.url).to_return(status: 200, body: xml)
+  end
+
+  context "when no new posts have been added" do
+    it "does not add any new posts" do
+      feed = create(:feed)
+      stub_raw_feed(feed)
+
+      expect { described_class.call(feed) }.not_to change(feed.stories, :count)
+    end
+
+    it "does not add posts that are old" do
+      feed = create(:feed)
+      entry = create_entry(published: Time.zone.local(2013, 1, 1))
+      stub_raw_feed(feed, entries: [entry])
+
+      expect { described_class.call(feed) }.not_to change(feed.stories, :count)
+    end
+  end
+
+  context "when new posts have been added" do
+    it "only adds posts that are new" do
+      feed = create(:feed)
+      stub_raw_feed(feed, entries: [create_entry])
+
+      expect { described_class.call(feed) }
+        .to change(feed.stories, :count).by(1)
+    end
+
+    it "updates the last fetched time for the feed" do
+      feed = create(:feed, last_fetched: Time.zone.local(2013, 1, 1))
+      freeze_time
+      stub_raw_feed(feed, entries: [create_entry])
+
+      expect { described_class.call(feed) }
+        .to change(feed, :last_fetched).to(Time.zone.now)
+    end
+  end
+
+  context "feed status" do
+    it "sets the status to green if things are all good" do
+      feed = create(:feed)
+      stub_raw_feed(feed)
+
+      expect { described_class.call(feed) }.to change(feed, :status).to("green")
+    end
+
+    it "sets the status to red if things go wrong" do
+      feed = create(:feed)
+      allow(described_class).to receive(:fetch_raw_feed).and_return(404)
+
+      expect { described_class.call(feed) }.to change(feed, :status).to("red")
+    end
+
+    it "outputs a message when things go wrong" do
+      feed = create(:feed)
+      allow(described_class).to receive(:fetch_raw_feed).and_return(404)
+
+      expect { described_class.call(feed) }
+        .to invoke(:error).on(Rails.logger).with(/Something went wrong/)
+    end
+  end
+end
diff --git a/spec/commands/feed/find_new_stories_spec.rb b/spec/commands/feed/find_new_stories_spec.rb
new file mode 100644
index 000000000..3cc645fad
--- /dev/null
+++ b/spec/commands/feed/find_new_stories_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+RSpec.describe Feed::FindNewStories do
+  def create_entry(**options)
+    entry = {
+      published: nil,
+      title: "story1",
+      url: "www.story1.com",
+      content: "",
+      id: "www.story1.com",
+      **options
+    }
+    double(entry)
+  end
+
+  def create_raw_feed(feed, entries: [])
+    xml = GenerateXml.call(feed, entries)
+    Feedjira.parse(xml)
+  end
+
+  context "the feed contains no new stories" do
+    it "finds zero new stories" do
+      feed = create(:feed)
+      create(:story, entry_id: "www.story1.com", feed_id: feed.id)
+      raw_feed = create_raw_feed(feed, entries: [create_entry])
+      result = described_class.call(raw_feed, feed.id)
+
+      expect(result).to be_empty
+    end
+  end
+
+  context "the feed contains new stories" do
+    it "returns stories that are not found in the database" do
+      feed = create(:feed)
+      create(:story, entry_id: "www.story1.com", feed_id: feed.id)
+      entry = create_entry(published: Time.zone.now, url: "www.story2.com")
+      raw_feed = create_raw_feed(feed, entries: [entry])
+
+      expect(described_class.call(raw_feed, feed.id)).to eq(raw_feed.entries)
+    end
+  end
+
+  it "scans until matching the last story id" do
+    new_entry = create_entry(published: Time.zone.now, url: "www.new.com")
+    old_entry = create_entry(title: "old entry", url: "www.old.com")
+    raw_feed = create_raw_feed(create(:feed), entries: [new_entry, old_entry])
+    result = described_class.call(raw_feed, 1, "www.old.com")
+
+    expect(result).to eq([raw_feed.entries.first])
+  end
+
+  it "ignores stories older than 3 days" do
+    new_entry = create_entry(title: "new", published: 2.days.ago)
+    old_entry = create_entry(title: "old", published: 4.days.ago)
+    raw_feed = create_raw_feed(create(:feed), entries: [old_entry, new_entry])
+
+    expect(described_class.call(raw_feed, 1))
+      .to eq(raw_feed.entries.filter { |entry| entry.title == "new" })
+  end
+end
diff --git a/spec/commands/feed/import_from_opml_spec.rb b/spec/commands/feed/import_from_opml_spec.rb
new file mode 100644
index 000000000..d5ab46139
--- /dev/null
+++ b/spec/commands/feed/import_from_opml_spec.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+RSpec.describe Feed::ImportFromOpml do
+  tmw_football = {
+    name: "TMW Football Transfer News",
+    url: "http://www.transfermarketweb.com/rss"
+  }
+
+  giant_robots = {
+    name: "GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS - Home",
+    url: "http://feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots"
+  }
+
+  autoblog = { name: "Autoblog", url: "http://feeds.autoblog.com/weblogsinc/autoblog/" }
+  city_guide = { name: "City Guide News", url: "http://www.probki.net/news/RSS_news_feed.asp" }
+  macrumors = { name: "MacRumors: Mac News and Rumors - Front Page", url: "http://feeds.macrumors.com/MacRumors-Front" }
+  dead_feed = { name: "http://deadfeed.example.com/feed.rss", url: "http://deadfeed.example.com/feed.rss" }
+
+  def read_subscriptions
+    File.open(
+      File.expand_path("../../support/files/subscriptions.xml", __dir__)
+    )
+  end
+
+  def create_feed(feed_details)
+    create(:feed, feed_details)
+  end
+
+  def find_feed(feed_details)
+    Feed.where(feed_details)
+  end
+
+  before do
+    stub_request(:get, "http://feeds.macrumors.com/MacRumors-Front").to_return(
+      status: 200,
+      body: File.read("spec/sample_data/feeds/feed01_valid_feed/feed.xml")
+    )
+    stub_request(
+      :get,
+      "http://deadfeed.example.com/feed.rss"
+    ).to_return(status: 404)
+  end
+
+  context "adds title for existing feed" do
+    it "changes existing feed if name is nil" do
+      create_feed({ name: nil, url: macrumors[:url] })
+
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(find_feed(macrumors)).to exist
+    end
+
+    it "keeps existing feed name if name is something else" do
+      feed1 = create_feed({ name: "MacRumors", url: macrumors[:url] })
+
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(find_feed(macrumors)).not_to exist
+      feed1.reload
+      expect(feed1.name).to eq("MacRumors")
+    end
+  end
+
+  context "adding group_id for existing feeds" do
+    it "retains exising feeds" do
+      feed1 = create_feed(tmw_football)
+      feed2 = create_feed(giant_robots)
+
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(feed1).to be_valid
+      expect(feed2).to be_valid
+    end
+
+    it "creates new groups" do
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(Group.find_by(name: "Football News")).to be
+      expect(Group.find_by(name: "RoR")).to be
+    end
+
+    it "sets group for existing feeds" do
+      feed1 = create_feed(tmw_football)
+      feed2 = create_feed(giant_robots)
+
+      expect { described_class.call(read_subscriptions, user: default_user) }
+        .to change_record(feed1, :group_name).from(nil).to("Football News")
+        .and change_record(feed2, :group_name).from(nil).to("RoR")
+    end
+  end
+
+  context "creates new feeds with groups" do
+    it "creates groups" do
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(Group.find_by(name: "Football News")).to be
+      expect(Group.find_by(name: "RoR")).to be
+    end
+
+    it "creates feeds" do
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(find_feed(tmw_football)).to exist
+      expect(find_feed(giant_robots)).to exist
+      expect(find_feed(macrumors)).to exist
+      expect(find_feed(dead_feed)).to exist
+    end
+
+    it "sets group" do
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(find_feed(tmw_football).first.group)
+        .to eq(Group.find_by(name: "Football News"))
+      expect(find_feed(giant_robots).first.group)
+        .to eq(Group.find_by(name: "RoR"))
+    end
+
+    it "does not create empty group" do
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(Group.find_by(name: "Empty Group")).to be_nil
+    end
+  end
+
+  context "creates new feeds without group" do
+    it "does not create any new group for feeds without group" do
+      described_class.call(read_subscriptions, user: default_user)
+
+      group1 = Group.find_by(name: "Football News")
+      group2 = Group.find_by(name: "RoR")
+
+      expect(Group.where.not(id: [group1.id, group2.id]).count).to eq(0)
+    end
+
+    it "creates feeds without group_id" do
+      described_class.call(read_subscriptions, user: default_user)
+
+      expect(find_feed(autoblog).first.group_id).to be_nil
+      expect(find_feed(city_guide).first.group_id).to be_nil
+    end
+  end
+end
diff --git a/spec/commands/feeds/add_new_feed_spec.rb b/spec/commands/feeds/add_new_feed_spec.rb
deleted file mode 100644
index 3e113a6c8..000000000
--- a/spec/commands/feeds/add_new_feed_spec.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-require "spec_helper"
-
-app_require "/commands/feeds/add_new_feed"
-
-describe AddNewFeed do
-  describe "#add" do
-    context "feed cannot be discovered" do
-      let(:discoverer) { double(discover: false) }
-      it "returns false if cant discover any feeds" do
-        result = AddNewFeed.add("http://not-a-feed.com", discoverer)
-
-        expect(result).to eq(false)
-      end
-    end
-
-    context "feed can be discovered" do
-      let(:feed_url) { "http://feed.com/atom.xml" }
-      let(:feed_result) { double(title: feed.name, feed_url: feed.url) }
-      let(:discoverer) { double(discover: feed_result) }
-      let(:feed) { FeedFactory.build }
-      let(:repo) { double }
-
-      it "parses and creates the feed if discovered" do
-        expect(repo).to receive(:create).and_return(feed)
-
-        result = AddNewFeed.add("http://feed.com", discoverer, repo)
-
-        expect(result).to be feed
-      end
-    end
-  end
-end
diff --git a/spec/commands/feeds/export_to_opml_spec.rb b/spec/commands/feeds/export_to_opml_spec.rb
deleted file mode 100644
index cb4c231db..000000000
--- a/spec/commands/feeds/export_to_opml_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-require "spec_helper"
-
-app_require "commands/feeds/export_to_opml"
-
-describe ExportToOpml do
-  describe "#to_xml" do
-    let(:feed_one) { FeedFactory.build }
-    let(:feed_two) { FeedFactory.build }
-    let(:feeds) { [feed_one, feed_two] }
-
-    it "returns OPML XML" do
-      result = ExportToOpml.new(feeds).to_xml
-
-      outlines = Nokogiri.XML(result).xpath("//body//outline")
-      expect(outlines.size).to eq(2)
-      expect(outlines.first["title"]).to eq feed_one.name
-      expect(outlines.first["xmlUrl"]).to eq feed_one.url
-      expect(outlines.last["title"]).to eq feed_two.name
-      expect(outlines.last["xmlUrl"]).to eq feed_two.url
-    end
-
-    it "handles empty feeds" do
-      result = ExportToOpml.new([]).to_xml
-
-      outlines = Nokogiri.XML(result).xpath("//body//outline")
-      expect(outlines).to be_empty
-    end
-
-    it "has a proper title" do
-      result = ExportToOpml.new(feeds).to_xml
-
-      title = Nokogiri.XML(result).xpath("//head//title").first
-      expect(title.content).to eq "Feeds from Stringer"
-    end
-  end
-end
diff --git a/spec/commands/feeds/import_from_opml_spec.rb b/spec/commands/feeds/import_from_opml_spec.rb
deleted file mode 100644
index 2f691e9d2..000000000
--- a/spec/commands/feeds/import_from_opml_spec.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-require "spec_helper"
-
-app_require "commands/feeds/import_from_opml"
-
-describe ImportFromOpml do
-  let(:subscriptions) { File.open(File.expand_path("../../../support/files/subscriptions.xml", __FILE__)) }
-
-  def import
-    described_class.import(subscriptions)
-  end
-
-  after do
-    Feed.delete_all
-    Group.delete_all
-  end
-
-  let(:group_1) { Group.find_by_name("Football News") }
-  let(:group_2) { Group.find_by_name("RoR") }
-
-  context "adding group_id for existing feeds" do
-    let!(:feed_1) do
-      Feed.create(name: "TMW Football Transfer News", url: "http://www.transfermarketweb.com/rss")
-    end
-    let!(:feed_2) do
-      Feed.create(
-        name: "GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS - Home",
-        url: "http://feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots"
-      )
-    end
-    before { import }
-
-    it "retains exising feeds" do
-      expect(feed_1).to be_valid
-      expect(feed_2).to be_valid
-    end
-
-    it "creates new groups" do
-      expect(group_1).to be
-      expect(group_2).to be
-    end
-
-    it "sets group_id for existing feeds" do
-      expect(feed_1.reload.group).to eq group_1
-      expect(feed_2.reload.group).to eq group_2
-    end
-  end
-
-  context "creates new feeds with groups" do
-    let(:feed_1) do
-      Feed.where(name: "TMW Football Transfer News", url: "http://www.transfermarketweb.com/rss")
-    end
-    let(:feed_2) do
-      Feed.where(
-        name: "GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS - Home",
-        url: "http://feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots"
-      )
-    end
-    before { import }
-
-    it "creates groups" do
-      expect(group_1).to be
-      expect(group_1).to be
-    end
-
-    it "creates feeds" do
-      expect(feed_1).to exist
-      expect(feed_2).to exist
-    end
-
-    it "sets group" do
-      expect(feed_1.first.group).to eq group_1
-      expect(feed_2.first.group).to eq group_2
-    end
-  end
-
-  context "creates new feeds without group" do
-    let(:feed_1) { Feed.where(name: "Autoblog", url: "http://feeds.autoblog.com/weblogsinc/autoblog/").first }
-    let(:feed_2) { Feed.where(name: "City Guide News", url: "http://www.probki.net/news/RSS_news_feed.asp").first }
-
-    before { import }
-
-    it "does not create any new group for feeds without group" do
-      expect(Group.where("id NOT IN (?)", [group_1.id, group_2.id]).count).to eq 0
-    end
-
-    it "creates feeds without group_id" do
-      expect(feed_1.group_id).to be_nil
-      expect(feed_2.group_id).to be_nil
-    end
-  end
-end
diff --git a/spec/commands/fever_api/authentication_spec.rb b/spec/commands/fever_api/authentication_spec.rb
new file mode 100644
index 000000000..a46df29f7
--- /dev/null
+++ b/spec/commands/fever_api/authentication_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::Authentication do
+  def authorization
+    Authorization.new(default_user)
+  end
+
+  it "returns the latest feed's last_fetched time" do
+    feed = create(:feed, last_fetched: 1.month.ago)
+    create(:feed, last_fetched: 1.year.ago)
+
+    result = described_class.call(authorization:)
+    expect(result[:last_refreshed_on_time]).to eq(Integer(feed.last_fetched))
+  end
+
+  it "returns 0 for last_refreshed_on_time when there are no feeds" do
+    result = described_class.call(authorization:)
+    expect(result[:last_refreshed_on_time]).to eq(0)
+  end
+
+  it "returns a hash with keys :auth and :last_refreshed_on_time" do
+    result = described_class.call(authorization:)
+    expect(result).to eq(auth: 1, last_refreshed_on_time: 0)
+  end
+end
diff --git a/spec/commands/fever_api/read_favicons_spec.rb b/spec/commands/fever_api/read_favicons_spec.rb
new file mode 100644
index 000000000..982123ef8
--- /dev/null
+++ b/spec/commands/fever_api/read_favicons_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::ReadFavicons do
+  it "returns a fixed icon list if requested" do
+    expect(described_class.call({ favicons: nil })).to eq(
+      favicons: [
+        {
+          id: 0,
+          data: "image/gif;base64,#{described_class::ICON}"
+        }
+      ]
+    )
+  end
+
+  it "returns an empty hash otherwise" do
+    expect(described_class.call({})).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/read_feeds_groups_spec.rb b/spec/commands/fever_api/read_feeds_groups_spec.rb
new file mode 100644
index 000000000..6d8c75454
--- /dev/null
+++ b/spec/commands/fever_api/read_feeds_groups_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::ReadFeedsGroups do
+  it "returns a list of groups requested through feeds" do
+    group = create(:group)
+    feeds = create_list(:feed, 3, group:)
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:, feeds: nil)).to eq(
+      feeds_groups: [
+        {
+          group_id: group.id,
+          feed_ids: feeds.map(&:id).join(",")
+        }
+      ]
+    )
+  end
+
+  it "returns a list of groups requested through groups" do
+    group = create(:group)
+    feeds = create_list(:feed, 3, group:)
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:, groups: nil)).to eq(
+      feeds_groups: [
+        {
+          group_id: group.id,
+          feed_ids: feeds.map(&:id).join(",")
+        }
+      ]
+    )
+  end
+
+  it "returns an empty hash otherwise" do
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:)).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/read_feeds_spec.rb b/spec/commands/fever_api/read_feeds_spec.rb
new file mode 100644
index 000000000..defbc2cd5
--- /dev/null
+++ b/spec/commands/fever_api/read_feeds_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::ReadFeeds do
+  it "returns a list of feeds" do
+    feeds = create_list(:feed, 3)
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:, feeds: nil))
+      .to eq(feeds: feeds.map(&:as_fever_json))
+  end
+
+  it "returns an empty hash otherwise" do
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:)).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/read_groups_spec.rb b/spec/commands/fever_api/read_groups_spec.rb
new file mode 100644
index 000000000..11664f749
--- /dev/null
+++ b/spec/commands/fever_api/read_groups_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::ReadGroups do
+  it "returns a group list if requested" do
+    groups = create_pair(:group)
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:, groups: nil))
+      .to eq(groups: [Group::UNGROUPED, *groups].map(&:as_fever_json))
+  end
+
+  it "returns an empty hash otherwise" do
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:)).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/read_items_spec.rb b/spec/commands/fever_api/read_items_spec.rb
new file mode 100644
index 000000000..93a998c68
--- /dev/null
+++ b/spec/commands/fever_api/read_items_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::ReadItems do
+  it "returns a list of unread items including total count" do
+    stories = create_list(:story, 3)
+    authorization = Authorization.new(default_user)
+
+    items = stories.map(&:as_fever_json)
+    expect(described_class.call(authorization:, items: nil))
+      .to eq(items:, total_items: 3)
+  end
+
+  it "returns a list of unread items with empty since_id" do
+    stories = create_list(:story, 3)
+    authorization = Authorization.new(default_user)
+
+    items = stories.map(&:as_fever_json)
+    expect(described_class.call(authorization:, items: nil, since_id: ""))
+      .to eq(items:, total_items: 3)
+  end
+
+  it "returns a list of unread items since id including total count" do
+    story, *other_stories = create_list(:story, 3)
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:, items: nil, since_id: story.id))
+      .to eq(items: other_stories.map(&:as_fever_json), total_items: 3)
+  end
+
+  it "returns a list of specified items including total count" do
+    _story1, *other_stories = create_list(:story, 3)
+    with_ids = other_stories.map(&:id)
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:, items: nil, with_ids:)).to eq(
+      items: other_stories.reverse.map(&:as_fever_json),
+      total_items: 2
+    )
+  end
+
+  it "returns an empty hash otherwise" do
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:)).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/read_links_spec.rb b/spec/commands/fever_api/read_links_spec.rb
new file mode 100644
index 000000000..375ca997a
--- /dev/null
+++ b/spec/commands/fever_api/read_links_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::ReadLinks do
+  it "returns a fixed link list if requested" do
+    expect(described_class.call(links: nil)).to eq(links: [])
+  end
+
+  it "returns an empty hash otherwise" do
+    expect(described_class.call({})).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/sync_saved_item_ids_spec.rb b/spec/commands/fever_api/sync_saved_item_ids_spec.rb
new file mode 100644
index 000000000..03abff2ff
--- /dev/null
+++ b/spec/commands/fever_api/sync_saved_item_ids_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::SyncSavedItemIds do
+  it "returns a list of starred items if requested" do
+    stories = create_list(:story, 3, :starred)
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:, saved_item_ids: nil))
+      .to eq(saved_item_ids: stories.map(&:id).join(","))
+  end
+
+  it "returns an empty hash otherwise" do
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:)).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/sync_unread_item_ids_spec.rb b/spec/commands/fever_api/sync_unread_item_ids_spec.rb
new file mode 100644
index 000000000..0155cec68
--- /dev/null
+++ b/spec/commands/fever_api/sync_unread_item_ids_spec.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::SyncUnreadItemIds do
+  it "returns a list of unread items if requested" do
+    stories = create_list(:story, 3)
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:, unread_item_ids: nil))
+      .to eq(unread_item_ids: stories.map(&:id).join(","))
+  end
+
+  it "returns an empty hash otherwise" do
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:)).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/write_mark_feed_spec.rb b/spec/commands/fever_api/write_mark_feed_spec.rb
new file mode 100644
index 000000000..466072e98
--- /dev/null
+++ b/spec/commands/fever_api/write_mark_feed_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::WriteMarkFeed do
+  def params(feed, before:)
+    authorization = Authorization.new(feed.user)
+
+    { authorization:, mark: "feed", id: feed.id, before: }
+  end
+
+  it "marks the feed stories as read before the given timestamp" do
+    feed = create(:feed)
+    story = create(:story, feed:, created_at: 1.week.ago)
+
+    expect { described_class.call(**params(feed, before: 1.day.ago.to_i)) }
+      .to change { story.reload.is_read? }.from(false).to(true)
+  end
+
+  it "does not mark the feed stories as read after the given timestamp" do
+    feed = create(:feed)
+    story = create(:story, feed:)
+
+    expect { described_class.call(**params(feed, before: 1.day.ago.to_i)) }
+      .to not_change { story.reload.is_read? }.from(false)
+  end
+
+  it "returns an empty hash otherwise" do
+    authorization = Authorization.new(default_user)
+
+    expect(described_class.call(authorization:)).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/write_mark_group_spec.rb b/spec/commands/fever_api/write_mark_group_spec.rb
new file mode 100644
index 000000000..1d4770d26
--- /dev/null
+++ b/spec/commands/fever_api/write_mark_group_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::WriteMarkGroup do
+  def authorization
+    Authorization.new(default_user)
+  end
+
+  it "marks the group stories as read before the given timestamp" do
+    story = create(:story, :with_group, created_at: 1.week.ago)
+    before = 1.day.ago
+    id = story.group_id
+
+    expect { described_class.call(authorization:, mark: "group", id:, before:) }
+      .to change_record(story, :is_read).from(false).to(true)
+  end
+
+  it "returns an empty hash otherwise" do
+    expect(described_class.call(authorization:)).to eq({})
+  end
+end
diff --git a/spec/commands/fever_api/write_mark_item_spec.rb b/spec/commands/fever_api/write_mark_item_spec.rb
new file mode 100644
index 000000000..4c52b27aa
--- /dev/null
+++ b/spec/commands/fever_api/write_mark_item_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverAPI::WriteMarkItem do
+  context "when as: 'read'" do
+    it "marks the story as read" do
+      story = create(:story)
+      authorization = Authorization.new(story.user)
+      params = { authorization:, mark: "item", as: "read", id: story.id }
+
+      expect { subject.call(**params) }
+        .to change_record(story, :is_read).from(false).to(true)
+    end
+  end
+
+  context "when as: 'unread'" do
+    it "marks the story as unread" do
+      story = create(:story, :read)
+      authorization = Authorization.new(story.user)
+      params = { authorization:, mark: "item", as: "unread", id: story.id }
+
+      expect { subject.call(**params) }
+        .to change_record(story, :is_read).from(true).to(false)
+    end
+  end
+
+  context "when as: 'saved'" do
+    it "marks the story as starred" do
+      story = create(:story)
+      authorization = Authorization.new(story.user)
+      params = { authorization:, mark: "item", as: "saved", id: story.id }
+
+      expect { subject.call(**params) }
+        .to change_record(story, :is_starred).from(false).to(true)
+    end
+  end
+
+  context "when as: 'unsaved'" do
+    it "marks the story as unstarred" do
+      story = create(:story, :starred)
+      authorization = Authorization.new(story.user)
+      params = { authorization:, mark: "item", as: "unsaved", id: story.id }
+
+      expect { subject.call(**params) }
+        .to change_record(story, :is_starred).from(true).to(false)
+    end
+  end
+
+  it "returns an empty hash when :as is not present" do
+    authorization = Authorization.new(default_user)
+
+    expect(subject.call(authorization:, mark: "item")).to eq({})
+  end
+end
diff --git a/spec/commands/find_new_stories_spec.rb b/spec/commands/find_new_stories_spec.rb
deleted file mode 100644
index 41cce2054..000000000
--- a/spec/commands/find_new_stories_spec.rb
+++ /dev/null
@@ -1,63 +0,0 @@
-require "spec_helper"
-
-app_require "repositories/story_repository"
-app_require "commands/feeds/find_new_stories"
-
-describe FindNewStories do
-  describe "#new_stories" do
-    context "the feed contains no new stories" do
-      before do
-        allow(StoryRepository).to receive(:exists?).and_return(true)
-      end
-
-      it "should find zero new stories" do
-        story1 = double(published: nil, id: "story1")
-        story2 = double(published: nil, id: "story2")
-        feed   = double(entries: [story1, story2])
-
-        result = FindNewStories.new(feed, 1, Time.new(2013, 1, 2)).new_stories
-        expect(result).to be_empty
-      end
-    end
-
-    context "the feed contains new stories" do
-      it "should return stories that are not found in the database" do
-        story1 = double(published: nil, id: "story1")
-        story2 = double(published: nil, id: "story2")
-        feed   = double(entries: [story1, story2])
-
-        allow(StoryRepository).to receive(:exists?).with("story1", 1).and_return(true)
-        allow(StoryRepository).to receive(:exists?).with("story2", 1).and_return(false)
-
-        result = FindNewStories.new(feed, 1, Time.new(2013, 1, 2)).new_stories
-        expect(result).to eq [story2]
-      end
-    end
-
-    it "should scan until matching the last story id" do
-      new_story = double(published: nil, id: "new-story")
-      old_story = double(published: nil, id: "old-story")
-      feed = double(last_modified: nil, entries: [new_story, old_story])
-
-      result = FindNewStories.new(feed, 1, Time.new(2013, 1, 3), "old-story").new_stories
-      expect(result).to eq [new_story]
-    end
-
-    it "should ignore stories older than 3 days" do
-      new_stories = [
-        double(published: 1.hour.ago, id: "new-story"),
-        double(published: 2.days.ago, id: "new-story")
-      ]
-
-      stories_older_than_3_days = [
-        double(published: 3.days.ago, id: "new-story"),
-        double(published: 4.days.ago, id: "new-story")
-      ]
-
-      feed = double(last_modified: nil, entries: new_stories + stories_older_than_3_days)
-
-      result = FindNewStories.new(feed, 1, nil, nil).new_stories
-      expect(result).not_to include(stories_older_than_3_days)
-    end
-  end
-end
diff --git a/spec/commands/stories/mark_all_as_read_spec.rb b/spec/commands/stories/mark_all_as_read_spec.rb
deleted file mode 100644
index b72c75b78..000000000
--- a/spec/commands/stories/mark_all_as_read_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require "spec_helper"
-
-app_require "commands/stories/mark_all_as_read"
-
-describe MarkAllAsRead do
-  describe "#mark_as_read" do
-    let(:stories) { double }
-    let(:repo) { double(fetch_by_ids: stories) }
-
-    it "marks all stories as read" do
-      command = MarkAllAsRead.new([1, 2], repo)
-      expect(stories).to receive(:update_all).with(is_read: true)
-      command.mark_as_read
-    end
-  end
-end
diff --git a/spec/commands/stories/mark_as_read_spec.rb b/spec/commands/stories/mark_as_read_spec.rb
deleted file mode 100644
index b24ff9d74..000000000
--- a/spec/commands/stories/mark_as_read_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require "spec_helper"
-
-app_require "commands/stories/mark_as_read"
-
-describe MarkAsRead do
-  describe "#mark_as_read" do
-    let(:story) { double }
-    let(:repo) { double(fetch: story) }
-
-    it "marks a story as read" do
-      command = MarkAsRead.new(1, repo)
-      expect(story).to receive(:update_attributes).with(is_read: true)
-      command.mark_as_read
-    end
-  end
-end
diff --git a/spec/commands/stories/mark_as_starred.rb b/spec/commands/stories/mark_as_starred.rb
deleted file mode 100644
index ecfca860a..000000000
--- a/spec/commands/stories/mark_as_starred.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require "spec_helper"
-
-app_require "commands/stories/mark_as_starred"
-
-describe MarkAsStarred do
-  describe "#mark_as_starred" do
-    let(:story) { double }
-    let(:repo) { double(fetch: story) }
-
-    it "marks a story as starred" do
-      command = MarkAsStarred.new(1, repo)
-      expect(story).to receive(:update_attributes).with(is_starred: true)
-      command.mark_as_starred
-    end
-  end
-end
diff --git a/spec/commands/stories/mark_as_unread_spec.rb b/spec/commands/stories/mark_as_unread_spec.rb
deleted file mode 100644
index 51716ed01..000000000
--- a/spec/commands/stories/mark_as_unread_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require "spec_helper"
-
-app_require "commands/stories/mark_as_unread"
-
-describe MarkAsUnread do
-  describe "#mark_as_unread" do
-    let(:story) { double }
-    let(:repo) { double(fetch: story) }
-
-    it "marks a story as unread" do
-      command = MarkAsUnread.new(1, repo)
-      expect(story).to receive(:update_attributes).with(is_read: false)
-      command.mark_as_unread
-    end
-  end
-end
diff --git a/spec/commands/stories/mark_as_unstarred_spec.rb b/spec/commands/stories/mark_as_unstarred_spec.rb
deleted file mode 100644
index d55d94418..000000000
--- a/spec/commands/stories/mark_as_unstarred_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require "spec_helper"
-
-app_require "commands/stories/mark_as_unstarred"
-
-describe MarkAsUnstarred do
-  describe "#mark_as_unstarred" do
-    let(:story) { double }
-    let(:repo) { double(fetch: story) }
-
-    it "marks a story as unstarred" do
-      command = MarkAsUnstarred.new(1, repo)
-      expect(story).to receive(:update_attributes).with(is_starred: false)
-      command.mark_as_unstarred
-    end
-  end
-end
diff --git a/spec/commands/stories/mark_feed_as_read_spec.rb b/spec/commands/stories/mark_feed_as_read_spec.rb
deleted file mode 100644
index 00e5dff69..000000000
--- a/spec/commands/stories/mark_feed_as_read_spec.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require "spec_helper"
-
-app_require "commands/stories/mark_feed_as_read"
-
-describe MarkFeedAsRead do
-  describe "#mark_feed_as_read" do
-    let(:stories) { double }
-    let(:repo) { double(fetch_unread_for_feed_by_timestamp: stories) }
-
-    it "marks feed 1 as read" do
-      command = MarkFeedAsRead.new(1, Time.now.to_i, repo)
-      expect(stories).to receive(:update_all).with(is_read: true)
-      command.mark_feed_as_read
-    end
-  end
-end
diff --git a/spec/commands/stories/mark_group_as_read_spec.rb b/spec/commands/stories/mark_group_as_read_spec.rb
deleted file mode 100644
index fcbbfc702..000000000
--- a/spec/commands/stories/mark_group_as_read_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-require "spec_helper"
-
-app_require "commands/stories/mark_group_as_read"
-
-describe MarkGroupAsRead do
-  describe '#mark_group_as_read' do
-    let(:stories) { double }
-    let(:repo) { double }
-    let(:timestamp) { Time.now.to_i }
-
-    def run_command(group_id)
-      MarkGroupAsRead.new(group_id, timestamp, repo)
-    end
-
-    it "marks group as read" do
-      command = run_command(2)
-      expect(stories).to receive(:update_all).with(is_read: true)
-      expect(repo).to receive(:fetch_unread_by_timestamp_and_group).with(timestamp, 2).and_return(stories)
-      command.mark_group_as_read
-    end
-
-    it "does not mark any group as read when group is not provided" do
-      command = run_command(nil)
-      expect(repo).not_to receive(:fetch_unread_by_timestamp_and_group)
-      expect(repo).not_to receive(:fetch_unread_by_timestamp)
-      command.mark_group_as_read
-    end
-
-    context "SPARKS_GROUP_ID and KINDLING_GROUP_ID" do
-      before do
-        expect(stories).to receive(:update_all).with(is_read: true)
-        expect(repo).to receive(:fetch_unread_by_timestamp).and_return(stories)
-      end
-
-      it "marks as read all feeds when group is 0" do
-        command = run_command(0)
-        command.mark_group_as_read
-      end
-
-      it "marks as read all feeds when group is -1" do
-        command = run_command(-1)
-        command.mark_group_as_read
-      end
-    end
-  end
-end
diff --git a/spec/commands/story/mark_all_as_read_spec.rb b/spec/commands/story/mark_all_as_read_spec.rb
new file mode 100644
index 000000000..f30eea330
--- /dev/null
+++ b/spec/commands/story/mark_all_as_read_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.describe MarkAllAsRead do
+  it "marks all stories as read" do
+    stories = create_pair(:story)
+
+    expect { described_class.call(stories.map(&:id)) }
+      .to change_all_records(stories, :is_read).from(false).to(true)
+  end
+end
diff --git a/spec/commands/story/mark_as_read_spec.rb b/spec/commands/story/mark_as_read_spec.rb
new file mode 100644
index 000000000..aa0a7630c
--- /dev/null
+++ b/spec/commands/story/mark_as_read_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.describe MarkAsRead do
+  it "marks a story as read" do
+    story = create(:story, is_read: false)
+
+    expect { described_class.call(story.id) }
+      .to change_record(story, :is_read).from(false).to(true)
+  end
+end
diff --git a/spec/commands/story/mark_as_starred_spec.rb b/spec/commands/story/mark_as_starred_spec.rb
new file mode 100644
index 000000000..45c811301
--- /dev/null
+++ b/spec/commands/story/mark_as_starred_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+RSpec.describe MarkAsStarred do
+  describe "#mark_as_starred" do
+    it "marks a story as starred" do
+      story = create(:story)
+
+      expect { described_class.call(story.id) }
+        .to change_record(story, :is_starred).from(false).to(true)
+    end
+  end
+end
diff --git a/spec/commands/story/mark_as_unread_spec.rb b/spec/commands/story/mark_as_unread_spec.rb
new file mode 100644
index 000000000..c42fbf5af
--- /dev/null
+++ b/spec/commands/story/mark_as_unread_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.describe MarkAsUnread do
+  it "marks a story as unread" do
+    story = create(:story, is_read: true)
+
+    expect { described_class.call(story.id) }
+      .to change_record(story, :is_read).from(true).to(false)
+  end
+end
diff --git a/spec/commands/story/mark_as_unstarred_spec.rb b/spec/commands/story/mark_as_unstarred_spec.rb
new file mode 100644
index 000000000..86d4e0104
--- /dev/null
+++ b/spec/commands/story/mark_as_unstarred_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.describe MarkAsUnstarred do
+  it "marks a story as unstarred" do
+    story = create(:story, :starred)
+
+    expect { described_class.call(story.id) }
+      .to change_record(story, :is_starred).from(true).to(false)
+  end
+end
diff --git a/spec/commands/story/mark_feed_as_read_spec.rb b/spec/commands/story/mark_feed_as_read_spec.rb
new file mode 100644
index 000000000..87d5aa43f
--- /dev/null
+++ b/spec/commands/story/mark_feed_as_read_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+RSpec.describe MarkFeedAsRead do
+  it "marks feed stories as read before timestamp" do
+    story = create(:story, created_at: 1.week.ago)
+    before = 1.day.ago
+
+    expect { described_class.call(story.feed_id, before) }
+      .to change_record(story, :is_read).from(false).to(true)
+  end
+end
diff --git a/spec/commands/story/mark_group_as_read_spec.rb b/spec/commands/story/mark_group_as_read_spec.rb
new file mode 100644
index 000000000..2204b3438
--- /dev/null
+++ b/spec/commands/story/mark_group_as_read_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+RSpec.describe MarkGroupAsRead do
+  describe "#mark_group_as_read" do
+    it "marks group as read" do
+      story = create(:story, :with_group, created_at: 1.week.ago)
+      timestamp = 1.day.ago
+
+      expect { described_class.call(story.group_id, timestamp) }
+        .to change_record(story, :is_read).from(false).to(true)
+    end
+
+    it "does not mark any group as read when group is not provided" do
+      story = create(:story, :with_group, created_at: 1.week.ago)
+      timestamp = 1.day.ago
+
+      expect { described_class.call(nil, timestamp) }
+        .not_to change_record(story, :is_read).from(false)
+    end
+
+    it "marks as read all feeds when group is 0 (KINDLING_GROUP_ID)" do
+      story = create(:story, :with_group, created_at: 1.week.ago)
+      timestamp = 1.day.ago
+
+      expect { described_class.call(0, timestamp) }
+        .to change_record(story, :is_read).from(false).to(true)
+    end
+
+    it "marks as read all feeds when group is -1 (SPARKS_GROUP_ID)" do
+      story = create(:story, :with_group, created_at: 1.week.ago)
+      timestamp = 1.day.ago
+
+      expect { described_class.call(-1, timestamp) }
+        .to change_record(story, :is_read).from(false).to(true)
+    end
+  end
+end
diff --git a/spec/commands/user/sign_in_user_spec.rb b/spec/commands/user/sign_in_user_spec.rb
new file mode 100644
index 000000000..47908db47
--- /dev/null
+++ b/spec/commands/user/sign_in_user_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+RSpec.describe SignInUser do
+  it "returns the user if the password is valid" do
+    result = described_class.call(default_user.username, default_user.password)
+
+    expect(result).to eq(default_user)
+  end
+
+  it "returns nil if password is invalid" do
+    create(:user)
+
+    result = described_class.call(default_user.username, "not-the-pw")
+
+    expect(result).to be_nil
+  end
+
+  it "returns nil if the user does not exist" do
+    result = described_class.call("not-the-username", "not-the-pw")
+
+    expect(result).to be_nil
+  end
+end
diff --git a/spec/commands/users/change_user_password_spec.rb b/spec/commands/users/change_user_password_spec.rb
deleted file mode 100644
index caae4e3ee..000000000
--- a/spec/commands/users/change_user_password_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-require "spec_helper"
-require "support/active_record"
-
-app_require "commands/users/change_user_password"
-
-describe ChangeUserPassword do
-  let(:repo) { double }
-  let(:user) { User.create(password: old_password) }
-
-  let(:old_password) { "old-pw" }
-  let(:new_password) { "new-pw" }
-
-  describe "#change_user_password" do
-    it "changes the password of the user" do
-      expect(repo).to receive(:first).and_return(user)
-      expect(repo).to receive(:save)
-
-      command = ChangeUserPassword.new(repo)
-      result = command.change_user_password(new_password)
-
-      expect(BCrypt::Password.new(result.password_digest)).to eq new_password
-    end
-
-    it "changes the API key of the user" do
-      expect(repo).to receive(:first).and_return(user)
-      expect(repo).to receive(:save)
-
-      command = ChangeUserPassword.new(repo)
-      result = command.change_user_password(new_password)
-
-      expect(result.api_key).to eq ApiKey.compute(new_password)
-    end
-  end
-end
diff --git a/spec/commands/users/complete_setup_spec.rb b/spec/commands/users/complete_setup_spec.rb
deleted file mode 100644
index df34301c3..000000000
--- a/spec/commands/users/complete_setup_spec.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-require "spec_helper"
-
-app_require "commands/users/complete_setup"
-
-describe CompleteSetup do
-  let(:user) { UserFactory.build }
-  it "marks setup as complete" do
-    expect(user).to receive(:save).once
-
-    result = CompleteSetup.complete(user)
-    expect(result.setup_complete).to eq(true)
-  end
-end
diff --git a/spec/commands/users/create_user_spec.rb b/spec/commands/users/create_user_spec.rb
deleted file mode 100644
index ffbcc5133..000000000
--- a/spec/commands/users/create_user_spec.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-require "spec_helper"
-
-app_require "commands/users/create_user"
-
-describe CreateUser do
-  let(:repo) { double }
-
-  describe "#create" do
-    it "remove any existing users and create a user with the password supplied" do
-      command = CreateUser.new(repo)
-
-      expect(repo).to receive(:create)
-      expect(repo).to receive(:delete_all)
-
-      command.create("password")
-    end
-  end
-end
diff --git a/spec/commands/users/sign_in_user_spec.rb b/spec/commands/users/sign_in_user_spec.rb
deleted file mode 100644
index f3f921810..000000000
--- a/spec/commands/users/sign_in_user_spec.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-require "spec_helper"
-
-app_require "commands/users/sign_in_user"
-
-describe SignInUser do
-  let(:valid_password) { "valid-pw" }
-  let(:repo) { double(first: user) }
-
-  let(:user) do
-    double(password_digest: BCrypt::Password.create(valid_password), id: 1)
-  end
-
-  describe "#sign_in" do
-    it "returns the user if the password is valid" do
-      result = SignInUser.sign_in(valid_password, repo)
-
-      expect(result.id).to eq 1
-    end
-
-    it "returns nil if password is invalid" do
-      result = SignInUser.sign_in("not-the-pw", repo)
-
-      expect(result).to be_nil
-    end
-  end
-end
diff --git a/spec/controllers/debug_controller_spec.rb b/spec/controllers/debug_controller_spec.rb
deleted file mode 100644
index f4e1a1922..000000000
--- a/spec/controllers/debug_controller_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-require "spec_helper"
-require "support/active_record"
-
-app_require "controllers/debug_controller"
-
-describe "DebugController" do
-  describe "GET /debug" do
-    before do
-      delayed_job = double "Delayed::Job"
-      allow(delayed_job).to receive(:count).and_return(42)
-      stub_const("Delayed::Job", delayed_job)
-
-      migration_status_instance = double "migration_status_instance"
-      allow(migration_status_instance).to receive(:pending_migrations).and_return ["Migration B - 2", "Migration C - 3"]
-      migration_status = double "MigrationStatus"
-      allow(migration_status).to receive(:new).and_return(migration_status_instance)
-      stub_const("MigrationStatus", migration_status)
-    end
-
-    it "displays the current Ruby version" do
-      get "/debug"
-
-      page = last_response.body
-      expect(page).to have_tag("dd", text: /#{RUBY_VERSION}/)
-    end
-
-    it "displays the user agent" do
-      get "/debug", {}, "HTTP_USER_AGENT" => "test"
-
-      page = last_response.body
-      expect(page).to have_tag("dd", text: /test/)
-    end
-
-    it "displays the delayed job count" do
-      get "/debug"
-
-      page = last_response.body
-      expect(page).to have_tag("dd", text: /42/)
-    end
-
-    it "displays pending migrations" do
-      get "/debug"
-
-      page = last_response.body
-      expect(page).to have_tag("li", text: /Migration B - 2/)
-      expect(page).to have_tag("li", text: /Migration C - 3/)
-    end
-  end
-end
diff --git a/spec/controllers/feeds_controller_spec.rb b/spec/controllers/feeds_controller_spec.rb
deleted file mode 100644
index 90c8c3d3d..000000000
--- a/spec/controllers/feeds_controller_spec.rb
+++ /dev/null
@@ -1,162 +0,0 @@
-require "spec_helper"
-
-app_require "controllers/feeds_controller"
-
-describe "FeedsController" do
-  let(:feeds) { [FeedFactory.build, FeedFactory.build] }
-
-  describe "GET /feeds" do
-    it "renders a list of feeds" do
-      expect(FeedRepository).to receive(:list).and_return(feeds)
-
-      get "/feeds"
-
-      page = last_response.body
-      expect(page).to have_tag("ul#feed-list")
-      expect(page).to have_tag("li.feed", count: 2)
-    end
-
-    it "displays message to add feeds if there are none" do
-      expect(FeedRepository).to receive(:list).and_return([])
-
-      get "/feeds"
-
-      page = last_response.body
-      expect(page).to have_tag("#add-some-feeds")
-    end
-  end
-
-  describe "GET /feeds/:feed_id/edit" do
-    it "fetches a feed given the id" do
-      feed = Feed.new(name: "Rainbows and unicorns", url: "example.com/feed")
-      expect(FeedRepository).to receive(:fetch).with("123").and_return(feed)
-
-      get "/feeds/123/edit"
-
-      expect(last_response.body).to include("Rainbows and unicorns")
-      expect(last_response.body).to include("example.com/feed")
-    end
-  end
-
-  describe "PUT /feeds/:feed_id" do
-    it "updates a feed given the id" do
-      feed = FeedFactory.build(url: "example.com/atom")
-      expect(FeedRepository).to receive(:fetch).with("123").and_return(feed)
-      expect(FeedRepository).to receive(:update_feed).with(feed, "Test", "example.com/feed", nil)
-
-      put "/feeds/123", feed_id: "123", feed_name: "Test", feed_url: "example.com/feed"
-
-      expect(last_response).to be_redirect
-    end
-
-    it "updates a feed group given the id" do
-      feed = FeedFactory.build(url: "example.com/atom")
-      expect(FeedRepository).to receive(:fetch).with("123").and_return(feed)
-      expect(FeedRepository).to receive(:update_feed).with(feed, feed.name, feed.url, "321")
-
-      put "/feeds/123", feed_id: "123", feed_name: feed.name, feed_url: feed.url, group_id: "321"
-
-      expect(last_response).to be_redirect
-    end
-  end
-
-  describe "DELETE /feeds/:feed_id" do
-    it "deletes a feed given the id" do
-      expect(FeedRepository).to receive(:delete).with("123")
-
-      delete "/feeds/123"
-    end
-  end
-
-  describe "GET /feeds/new" do
-    it "displays a form and submit button" do
-      get "/feeds/new"
-
-      page = last_response.body
-      expect(page).to have_tag("form#add-feed-setup")
-      expect(page).to have_tag("input#submit")
-    end
-  end
-
-  describe "POST /feeds" do
-    context "when the feed url is valid" do
-      let(:feed_url) { "http://example.com/" }
-      let(:valid_feed) { double(valid?: true) }
-
-      it "adds the feed and queues it to be fetched" do
-        expect(AddNewFeed).to receive(:add).with(feed_url).and_return(valid_feed)
-        expect(FetchFeeds).to receive(:enqueue).with([valid_feed])
-
-        post "/feeds", feed_url: feed_url
-
-        expect(last_response.status).to be 302
-        expect(URI.parse(last_response.location).path).to eq "/"
-      end
-    end
-
-    context "when the feed url is invalid" do
-      let(:feed_url) { "http://not-a-valid-feed.com/" }
-
-      it "adds the feed and queues it to be fetched" do
-        expect(AddNewFeed).to receive(:add).with(feed_url).and_return(false)
-
-        post "/feeds", feed_url: feed_url
-
-        page = last_response.body
-        expect(page).to have_tag(".error")
-      end
-    end
-
-    context "when the feed url is one we already subscribe to" do
-      let(:feed_url) { "http://example.com/" }
-      let(:invalid_feed) { double(valid?: false) }
-
-      it "adds the feed and queues it to be fetched" do
-        expect(AddNewFeed).to receive(:add).with(feed_url).and_return(invalid_feed)
-
-        post "/feeds", feed_url: feed_url
-
-        page = last_response.body
-        expect(page).to have_tag(".error")
-      end
-    end
-  end
-
-  describe "GET /feeds/import" do
-    it "displays the import options" do
-      get "/feeds/import"
-
-      page = last_response.body
-      expect(page).to have_tag("input#opml_file")
-      expect(page).to have_tag("a#skip")
-    end
-  end
-
-  describe "POST /feeds/import" do
-    let(:opml_file) { Rack::Test::UploadedFile.new("spec/sample_data/subscriptions.xml", "application/xml") }
-
-    it "parse OPML and starts fetching" do
-      expect(ImportFromOpml).to receive(:import).once
-
-      post "/feeds/import", "opml_file" => opml_file
-
-      expect(last_response.status).to be 302
-      expect(URI.parse(last_response.location).path).to eq "/setup/tutorial"
-    end
-  end
-
-  describe "GET /feeds/export" do
-    let(:some_xml) { "<xml>some dummy opml</xml>" }
-    before { allow(Feed).to receive(:all) }
-
-    it "returns an OPML file" do
-      expect_any_instance_of(ExportToOpml).to receive(:to_xml).and_return(some_xml)
-
-      get "/feeds/export"
-
-      expect(last_response.body).to eq some_xml
-      expect(last_response.header["Content-Type"]).to include "application/xml"
-      expect(last_response.header["Content-Disposition"]).to eq("attachment; filename=\"stringer.opml\"")
-    end
-  end
-end
diff --git a/spec/controllers/first_run_controller_spec.rb b/spec/controllers/first_run_controller_spec.rb
deleted file mode 100644
index 22155e133..000000000
--- a/spec/controllers/first_run_controller_spec.rb
+++ /dev/null
@@ -1,93 +0,0 @@
-require "spec_helper"
-
-app_require "controllers/first_run_controller"
-
-describe "FirstRunController" do
-  context "when a user has not been setup" do
-    before do
-      allow(UserRepository).to receive(:setup_complete?).and_return(false)
-    end
-
-    describe "GET /setup/password" do
-      it "displays a form to enter your password" do
-        get "/setup/password"
-
-        page = last_response.body
-        expect(page).to have_tag("form#password_setup")
-        expect(page).to have_tag("input#password")
-        expect(page).to have_tag("input#password-confirmation")
-        expect(page).to have_tag("input#submit")
-      end
-    end
-
-    describe "POST /setup/password" do
-      it "rejects empty passwords" do
-        post "/setup/password"
-
-        page = last_response.body
-        expect(page).to have_tag("div.error")
-      end
-
-      it "rejects when password isn't confirmed" do
-        post "/setup/password", password: "foo", password_confirmation: "bar"
-
-        page = last_response.body
-        expect(page).to have_tag("div.error")
-      end
-
-      it "accepts confirmed passwords and redirects to next step" do
-        expect_any_instance_of(CreateUser).to receive(:create).with("foo").and_return(double(id: 1))
-
-        post "/setup/password", password: "foo", password_confirmation: "foo"
-
-        expect(last_response.status).to be 302
-        expect(URI.parse(last_response.location).path).to eq "/feeds/import"
-      end
-    end
-
-    describe "GET /setup/tutorial" do
-      let(:user) { double }
-      let(:feeds) { [double, double] }
-
-      before do
-        allow(UserRepository).to receive(:fetch).and_return(user)
-        allow(Feed).to receive(:all).and_return(feeds)
-      end
-
-      it "displays the tutorial and completes setup" do
-        expect(CompleteSetup).to receive(:complete).with(user).once
-        expect(FetchFeeds).to receive(:enqueue).with(feeds).once
-
-        get "/setup/tutorial"
-
-        page = last_response.body
-        expect(page).to have_tag("#mark-all-instruction")
-        expect(page).to have_tag("#refresh-instruction")
-        expect(page).to have_tag("#feeds-instruction")
-        expect(page).to have_tag("#add-feed-instruction")
-        expect(page).to have_tag("#story-instruction")
-        expect(page).to have_tag("#start")
-      end
-    end
-  end
-
-  context "when a user has been setup" do
-    before do
-      allow(UserRepository).to receive(:setup_complete?).and_return(true)
-    end
-
-    it "should redirect any requests to first run stuff" do
-      get "/"
-      expect(last_response.status).to be 302
-      expect(URI.parse(last_response.location).path).to eq "/news"
-
-      get "/setup/password"
-      expect(last_response.status).to be 302
-      expect(URI.parse(last_response.location).path).to eq "/news"
-
-      get "/setup/tutorial"
-      expect(last_response.status).to be 302
-      expect(URI.parse(last_response.location).path).to eq "/news"
-    end
-  end
-end
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
deleted file mode 100644
index cb67be117..000000000
--- a/spec/controllers/sessions_controller_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require "spec_helper"
-
-app_require "controllers/sessions_controller"
-
-describe "SessionsController" do
-  describe "GET /login" do
-    it "has a password input and login button" do
-      get "/login"
-
-      page = last_response.body
-      expect(page).to have_tag("input#password")
-      expect(page).to have_tag("#login")
-    end
-  end
-
-  describe "POST /login" do
-    it "denies access when password is incorrect" do
-      allow(SignInUser).to receive(:sign_in).and_return(nil)
-
-      post "/login", password: "not-the-password"
-
-      page = last_response.body
-      expect(page).to have_tag(".error")
-    end
-
-    it "allows access when password is correct" do
-      allow(SignInUser).to receive(:sign_in).and_return(double(id: 1))
-
-      post "/login", password: "the-password"
-
-      expect(session[:user_id]).to eq 1
-
-      expect(last_response.status).to be 302
-      expect(URI.parse(last_response.location).path).to eq "/"
-    end
-
-    it "redirects to the previous path when present" do
-      allow(SignInUser).to receive(:sign_in).and_return(double(id: 1))
-
-      post "/login", { password: "the-password" },
-           "rack.session" => { redirect_to: "/archive" }
-
-      expect(session[:redirect_to]).to be_nil
-      expect(URI.parse(last_response.location).path).to eq "/archive"
-    end
-  end
-
-  describe "GET /logout" do
-    it "clears the session and redirects" do
-      get "/logout", {}, "rack.session" => { userid: 1 }
-
-      expect(session[:user_id]).to be_nil
-
-      expect(last_response.status).to be 302
-      expect(URI.parse(last_response.location).path).to eq "/"
-    end
-  end
-end
diff --git a/spec/controllers/stories_controller_spec.rb b/spec/controllers/stories_controller_spec.rb
deleted file mode 100644
index d0ebe15d9..000000000
--- a/spec/controllers/stories_controller_spec.rb
+++ /dev/null
@@ -1,191 +0,0 @@
-require "spec_helper"
-require "will_paginate/array"
-
-app_require "controllers/stories_controller"
-
-describe "StoriesController" do
-  let(:story_one) { StoryFactory.build }
-  let(:story_two) { StoryFactory.build }
-  let(:stories) { [story_one, story_two] }
-
-  describe "GET /news" do
-    before do
-      allow(StoryRepository).to receive(:unread).and_return(stories)
-      allow(UserRepository).to receive(:fetch).and_return(double)
-    end
-
-    it "display list of unread stories" do
-      get "/news"
-
-      expect(last_response.body).to have_tag("#stories")
-    end
-
-    it "displays the blog title and article title" do
-      expect(StoryRepository).to receive(:unread).and_return([story_one])
-
-      get "/news"
-
-      expect(last_response.body).to include(story_one.headline)
-      expect(last_response.body).to include(story_one.source)
-    end
-
-    it "displays all user actions" do
-      get "/news"
-
-      expect(last_response.body).to have_tag("#mark-all")
-      expect(last_response.body).to have_tag("#refresh")
-      expect(last_response.body).to have_tag("#feeds")
-      expect(last_response.body).to have_tag("#add-feed")
-    end
-
-    it "should have correct footer links" do
-      get "/news"
-
-      page = last_response.body
-      expect(page).to have_tag("a", with: { href: "/feeds/export" })
-      expect(page).to have_tag("a", with: { href: "/logout" })
-      expect(page).to have_tag("a", with: { href: "https://github.com/swanson/stringer" })
-    end
-
-    it "displays a zen-like message when there are no unread stories" do
-      allow(StoryRepository).to receive(:unread).and_return([])
-
-      get "/news"
-
-      expect(last_response.body).to have_tag("#zen")
-    end
-  end
-
-  describe "GET /archive" do
-    let(:read_one) { StoryFactory.build(is_read: true) }
-    let(:read_two) { StoryFactory.build(is_read: true) }
-    let(:stories) { [read_one, read_two].paginate }
-    before { allow(StoryRepository).to receive(:read).and_return(stories) }
-
-    it "displays the list of read stories with pagination" do
-      get "/archive"
-
-      page = last_response.body
-      expect(page).to have_tag("#stories")
-      expect(page).to have_tag("div#pagination")
-    end
-  end
-
-  describe "GET /starred" do
-    let(:starred_one) { StoryFactory.build(is_starred: true) }
-    let(:starred_two) { StoryFactory.build(is_starred: true) }
-    let(:stories) { [starred_one, starred_two].paginate }
-    before { allow(StoryRepository).to receive(:starred).and_return(stories) }
-
-    it "displays the list of starred stories with pagination" do
-      get "/starred"
-
-      page = last_response.body
-      expect(page).to have_tag("#stories")
-      expect(page).to have_tag("div#pagination")
-    end
-  end
-
-  describe "PUT /stories/:id" do
-    before { allow(StoryRepository).to receive(:fetch).and_return(story_one) }
-    context "is_read parameter" do
-      context "when it is not malformed" do
-        it "marks a story as read" do
-          expect(StoryRepository).to receive(:save).once
-
-          put "/stories/#{story_one.id}", { is_read: true }.to_json
-
-          expect(story_one.is_read).to eq true
-        end
-      end
-
-      context "when it is malformed" do
-        it "marks a story as read" do
-          expect(StoryRepository).to receive(:save).once
-
-          put "/stories/#{story_one.id}", { is_read: "malformed" }.to_json
-
-          expect(story_one.is_read).to eq true
-        end
-      end
-    end
-
-    context "keep_unread parameter" do
-      context "when it is not malformed" do
-        it "marks a story as permanently unread" do
-          put "/stories/#{story_one.id}", { keep_unread: false }.to_json
-
-          expect(story_one.keep_unread).to eq false
-        end
-      end
-
-      context "when it is malformed" do
-        it "marks a story as permanently unread" do
-          put "/stories/#{story_one.id}", { keep_unread: "malformed" }.to_json
-
-          expect(story_one.keep_unread).to eq true
-        end
-      end
-    end
-
-    context "is_starred parameter" do
-      context "when it is not malformed" do
-        it "marks a story as permanently starred" do
-          put "/stories/#{story_one.id}", { is_starred: true }.to_json
-
-          expect(story_one.is_starred).to eq true
-        end
-      end
-
-      context "when it is malformed" do
-        it "marks a story as permanently starred" do
-          put "/stories/#{story_one.id}", { is_starred: "malformed" }.to_json
-
-          expect(story_one.is_starred).to eq true
-        end
-      end
-    end
-  end
-
-  describe "POST /stories/mark_all_as_read" do
-    it "marks all unread stories as read and reload the page" do
-      expect_any_instance_of(MarkAllAsRead).to receive(:mark_as_read).once
-
-      post "/stories/mark_all_as_read", story_ids: %w(1 2 3)
-
-      expect(last_response.status).to be 302
-      expect(URI.parse(last_response.location).path).to eq "/news"
-    end
-  end
-
-  describe "GET /feed/:feed_id" do
-    it "looks for a particular feed" do
-      expect(FeedRepository).to receive(:fetch).with(story_one.feed.id.to_s).and_return(story_one.feed)
-      expect(StoryRepository).to receive(:feed).with(story_one.feed.id.to_s).and_return([story_one])
-
-      get "/feed/#{story_one.feed.id}"
-    end
-
-    it "displays a list of stories" do
-      allow(FeedRepository).to receive(:fetch).and_return(story_one.feed)
-      allow(StoryRepository).to receive(:feed).and_return(stories)
-
-      get "/feed/#{story_one.feed.id}"
-
-      expect(last_response.body).to have_tag("#stories")
-    end
-
-    it "differentiates between read and unread" do
-      allow(FeedRepository).to receive(:fetch).and_return(story_one.feed)
-      allow(StoryRepository).to receive(:feed).and_return(stories)
-
-      story_one.is_read = false
-      story_two.is_read = true
-
-      get "/feed/#{story_one.feed.id}"
-
-      expect(last_response.body).to have_tag("li", class: "story")
-      expect(last_response.body).to have_tag("li", class: "unread")
-    end
-  end
-end
diff --git a/spec/factories/feed_factory.rb b/spec/factories/feed_factory.rb
deleted file mode 100644
index ccad1c46f..000000000
--- a/spec/factories/feed_factory.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-class FeedFactory
-  class FakeFeed < OpenStruct
-    def as_fever_json
-      {
-        id: id,
-        favicon_id: 0,
-        title: name,
-        url: url,
-        site_url: url,
-        is_spark: 0,
-        last_updated_on_time: last_fetched.to_i
-      }
-    end
-  end
-
-  def self.build(params = {})
-    FakeFeed.new(
-      id: rand(100),
-      group_id: params[:group_id] || rand(100),
-      name: params[:name] || Faker::Name.name + " on Software",
-      url: params[:url] || Faker::Internet.url,
-      last_fetched: params[:last_fetched] || Time.now,
-      stories: params[:stories] || [],
-      unread_stories: []
-    )
-  end
-end
diff --git a/spec/factories/feeds.rb b/spec/factories/feeds.rb
new file mode 100644
index 000000000..624fb7cb7
--- /dev/null
+++ b/spec/factories/feeds.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory(:feed) do
+    user { default_user }
+
+    sequence(:name, 100) { |n| "Feed #{n}" }
+    sequence(:url, 100) { |n| "http://exampoo.com/#{n}" }
+
+    trait :with_group do
+      group
+    end
+  end
+end
diff --git a/spec/factories/group_factory.rb b/spec/factories/group_factory.rb
deleted file mode 100644
index de9d16c2f..000000000
--- a/spec/factories/group_factory.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-class GroupFactory
-  class FakeGroup < OpenStruct
-    def as_fever_json
-      {
-        id: id,
-        title: name
-      }
-    end
-  end
-
-  def self.build(params = {})
-    FakeGroup.new(
-      id: rand(100),
-      name: params[:name] || Faker::Name.name + " group")
-  end
-end
diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb
new file mode 100644
index 000000000..5d73411a0
--- /dev/null
+++ b/spec/factories/groups.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory(:group) do
+    user { default_user }
+    sequence(:name, 100) { |n| "Group #{n}" }
+  end
+end
diff --git a/spec/factories/stories.rb b/spec/factories/stories.rb
new file mode 100644
index 000000000..9f0895c5b
--- /dev/null
+++ b/spec/factories/stories.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory(:story) do
+    feed
+
+    sequence(:entry_id, 100) { |n| "entry-#{n}" }
+    sequence(:published, 100) { |n| n.days.ago }
+
+    trait :read do
+      is_read { true }
+    end
+
+    trait :starred do
+      is_starred { true }
+    end
+
+    trait :with_group do
+      feed factory: [:feed, :with_group]
+    end
+  end
+end
diff --git a/spec/factories/story_factory.rb b/spec/factories/story_factory.rb
deleted file mode 100644
index 0b4557465..000000000
--- a/spec/factories/story_factory.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-require_relative "./feed_factory"
-
-class StoryFactory
-  class FakeStory < OpenStruct
-    def headline
-      title[0, 50]
-    end
-
-    def source
-      feed.name
-    end
-
-    def as_fever_json
-      {
-        id: id,
-        feed_id: feed_id,
-        title: title,
-        author: source,
-        html: body,
-        url: permalink,
-        is_saved: is_starred ? 1 : 0,
-        is_read: is_read ? 1 : 0,
-        created_on_time: published.to_i
-      }
-    end
-  end
-
-  def self.build(params = {})
-    default_params = {
-      id: rand(100),
-      title: Faker::Lorem.sentence,
-      permalink: Faker::Internet.url,
-      body: Faker::Lorem.paragraph,
-      feed: FeedFactory.build,
-      is_read: false,
-      is_starred: false,
-      published: Time.now
-    }
-    FakeStory.new(default_params.merge(params))
-  end
-end
diff --git a/spec/factories/user_factory.rb b/spec/factories/user_factory.rb
deleted file mode 100644
index da91ad011..000000000
--- a/spec/factories/user_factory.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-require_relative "./feed_factory"
-
-class UserFactory
-  class FakeUser < OpenStruct; end
-
-  def self.build
-    FakeUser.new(
-      id: rand(100),
-      setup_complete: false)
-  end
-end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
new file mode 100644
index 000000000..634283b3a
--- /dev/null
+++ b/spec/factories/users.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory(:user) do
+    sequence(:username, 100) { |n| "user-#{n}" }
+    password { "super-secret" }
+    admin { false }
+  end
+end
diff --git a/spec/fever_api/authentication_spec.rb b/spec/fever_api/authentication_spec.rb
deleted file mode 100644
index 0c96932eb..000000000
--- a/spec/fever_api/authentication_spec.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/authentication"
-
-describe FeverAPI::Authentication do
-  it "returns a hash with keys :auth and :last_refreshed_on_time" do
-    fake_clock = double("clock")
-    expect(fake_clock).to receive(:now).and_return(1234567890)
-    result = FeverAPI::Authentication.new(clock: fake_clock).call(double)
-    expect(result).to eq(auth: 1, last_refreshed_on_time: 1234567890)
-  end
-end
diff --git a/spec/fever_api/read_favicons_spec.rb b/spec/fever_api/read_favicons_spec.rb
deleted file mode 100644
index 5b91e4b5e..000000000
--- a/spec/fever_api/read_favicons_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/read_favicons"
-
-describe FeverAPI::ReadFavicons do
-  subject { FeverAPI::ReadFavicons.new }
-
-  it "returns a fixed icon list if requested" do
-    expect(subject.call("favicons" => nil)).to eq(
-      favicons: [
-        {
-          id: 0,
-          data: "image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
-        }
-      ]
-    )
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/read_feeds_groups_spec.rb b/spec/fever_api/read_feeds_groups_spec.rb
deleted file mode 100644
index 7da9707de..000000000
--- a/spec/fever_api/read_feeds_groups_spec.rb
+++ /dev/null
@@ -1,43 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/read_feeds_groups"
-
-describe FeverAPI::ReadFeedsGroups do
-  let(:feed_ids) { [5, 7, 11] }
-  let(:feeds) { feed_ids.map { |id| double("feed", id: id, group_id: 1) } }
-  let(:feed_repository) { double("repo") }
-
-  subject do
-    FeverAPI::ReadFeedsGroups.new(feed_repository: feed_repository)
-  end
-
-  it "returns a list of groups requested through feeds" do
-    allow(feed_repository).to receive_message_chain(:in_group, :order).and_return(feeds)
-
-    expect(subject.call("feeds" => nil)).to eq(
-      feeds_groups: [
-        {
-          group_id: 1,
-          feed_ids: feed_ids.join(",")
-        }
-      ]
-    )
-  end
-
-  it "returns a list of groups requested through groups" do
-    allow(feed_repository).to receive_message_chain(:in_group, :order).and_return(feeds)
-
-    expect(subject.call("groups" => nil)).to eq(
-      feeds_groups: [
-        {
-          group_id: 1,
-          feed_ids: feed_ids.join(",")
-        }
-      ]
-    )
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/read_feeds_spec.rb b/spec/fever_api/read_feeds_spec.rb
deleted file mode 100644
index e09fa0ab7..000000000
--- a/spec/fever_api/read_feeds_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/read_feeds"
-
-describe FeverAPI::ReadFeeds do
-  let(:feed_ids) { [5, 7, 11] }
-  let(:feeds) { feed_ids.map { |id| double("feed", id: id, as_fever_json: { id: id }) } }
-  let(:feed_repository) { double("repo") }
-
-  subject do
-    FeverAPI::ReadFeeds.new(feed_repository: feed_repository)
-  end
-
-  it "returns a list of feeds" do
-    expect(feed_repository).to receive(:list).and_return(feeds)
-    expect(subject.call("feeds" => nil)).to eq(
-      feeds: [
-        { id: 5 },
-        { id: 7 },
-        { id: 11 }
-      ]
-    )
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/read_groups_spec.rb b/spec/fever_api/read_groups_spec.rb
deleted file mode 100644
index b2c958bb6..000000000
--- a/spec/fever_api/read_groups_spec.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/read_groups"
-
-describe FeverAPI::ReadGroups do
-  let(:group_1) { double("group_1", as_fever_json: { id: 1, title: "IT news" }) }
-  let(:group_2) { double("group_2", as_fever_json: { id: 2, title: "World news" }) }
-  let(:group_repository) { double("repo") }
-
-  subject { FeverAPI::ReadGroups.new(group_repository: group_repository) }
-
-  it "returns a group list if requested" do
-    expect(group_repository).to receive(:list).and_return([group_1, group_2])
-    expect(subject.call("groups" => nil)).to eq(
-      groups: [
-        {
-          id: 1,
-          title: "IT news"
-        },
-        {
-          id: 2,
-          title: "World news"
-        }
-      ]
-    )
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/read_items_spec.rb b/spec/fever_api/read_items_spec.rb
deleted file mode 100644
index a124341d0..000000000
--- a/spec/fever_api/read_items_spec.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/read_items"
-
-describe FeverAPI::ReadItems do
-  let(:story_repository) { double("repo") }
-
-  subject do
-    FeverAPI::ReadItems.new(story_repository: story_repository)
-  end
-
-  it "returns a list of unread items including total count" do
-    stories = [
-      double("story", as_fever_json: { id: 5 }),
-      double("story", as_fever_json: { id: 7 }),
-      double("story", as_fever_json: { id: 11 })
-    ]
-    expect(story_repository).to receive(:unread).twice.and_return(stories)
-    expect(subject.call("items" => nil)).to eq(
-      items: [
-        { id: 5 },
-        { id: 7 },
-        { id: 11 }
-      ],
-      total_items: 3
-    )
-  end
-
-  it "returns a list of unread items since id including total count" do
-    unread_since_stories = [
-      double("story", as_fever_json: { id: 5 }),
-      double("story", as_fever_json: { id: 7 })
-    ]
-    expect(story_repository).to receive(:unread_since_id).with(3).and_return(unread_since_stories)
-    unread_stories = [
-      double("story", as_fever_json: { id: 2 }),
-      double("story", as_fever_json: { id: 5 }),
-      double("story", as_fever_json: { id: 7 })
-    ]
-    expect(story_repository).to receive(:unread).and_return(unread_stories)
-    expect(subject.call("items" => nil, since_id: 3)).to eq(
-      items: [
-        { id: 5 },
-        { id: 7 }
-      ],
-      total_items: 3
-    )
-  end
-
-  it "returns a list of specified items including total count" do
-    stories = [
-      double("story", as_fever_json: { id: 5 }),
-      double("story", as_fever_json: { id: 11 })
-    ]
-    expect(story_repository).to receive(:fetch_by_ids).with(%w(5 11)).twice.and_return(stories)
-
-    expect(subject.call("items" => nil, with_ids: "5,11")).to eq(
-      items: [
-        { id: 5 },
-        { id: 11 }
-      ],
-      total_items: 2
-    )
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/read_links_spec.rb b/spec/fever_api/read_links_spec.rb
deleted file mode 100644
index 895123de0..000000000
--- a/spec/fever_api/read_links_spec.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/read_links"
-
-describe FeverAPI::ReadLinks do
-  subject { FeverAPI::ReadLinks.new }
-
-  it "returns a fixed link list if requested" do
-    expect(subject.call("links" => nil)).to eq(links: [])
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/sync_saved_item_ids_spec.rb b/spec/fever_api/sync_saved_item_ids_spec.rb
deleted file mode 100644
index e9efefb64..000000000
--- a/spec/fever_api/sync_saved_item_ids_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/sync_saved_item_ids"
-
-describe FeverAPI::SyncSavedItemIds do
-  let(:story_ids) { [5, 7, 11] }
-  let(:stories) { story_ids.map { |id| double("story", id: id) } }
-  let(:story_repository) { double("repo") }
-
-  subject do
-    FeverAPI::SyncSavedItemIds.new(story_repository: story_repository)
-  end
-
-  it "returns a list of starred items if requested" do
-    expect(story_repository).to receive(:all_starred).and_return(stories)
-    expect(subject.call("saved_item_ids" => nil)).to eq(saved_item_ids: story_ids.join(","))
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/sync_unread_item_ids_spec.rb b/spec/fever_api/sync_unread_item_ids_spec.rb
deleted file mode 100644
index b7f9925f4..000000000
--- a/spec/fever_api/sync_unread_item_ids_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/sync_unread_item_ids"
-
-describe FeverAPI::SyncUnreadItemIds do
-  let(:story_ids) { [5, 7, 11] }
-  let(:stories) { story_ids.map { |id| double("story", id: id) } }
-  let(:story_repository) { double("repo") }
-
-  subject do
-    FeverAPI::SyncUnreadItemIds.new(story_repository: story_repository)
-  end
-
-  it "returns a list of unread items if requested" do
-    expect(story_repository).to receive(:unread).and_return(stories)
-    expect(subject.call("unread_item_ids" => nil)).to eq(unread_item_ids: story_ids.join(","))
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/write_mark_feed_spec.rb b/spec/fever_api/write_mark_feed_spec.rb
deleted file mode 100644
index 793112034..000000000
--- a/spec/fever_api/write_mark_feed_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/write_mark_feed"
-
-describe FeverAPI::WriteMarkFeed do
-  let(:feed_marker) { double("feed marker") }
-  let(:marker_class) { double("marker class") }
-
-  subject do
-    FeverAPI::WriteMarkFeed.new(marker_class: marker_class)
-  end
-
-  it "instantiates a feed marker and calls mark_feed_as_read if requested" do
-    expect(marker_class).to receive(:new).with(5, 1234567890).and_return(feed_marker)
-    expect(feed_marker).to receive(:mark_feed_as_read)
-    expect(subject.call(mark: "feed", id: 5, before: 1234567890)).to eq({})
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/write_mark_group_spec.rb b/spec/fever_api/write_mark_group_spec.rb
deleted file mode 100644
index 1262f048c..000000000
--- a/spec/fever_api/write_mark_group_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/write_mark_group"
-
-describe FeverAPI::WriteMarkGroup do
-  let(:group_marker) { double("group marker") }
-  let(:marker_class) { double("marker class") }
-
-  subject do
-    FeverAPI::WriteMarkGroup.new(marker_class: marker_class)
-  end
-
-  it "instantiates a group marker and calls mark_group_as_read if requested" do
-    expect(marker_class).to receive(:new).with(5, 1234567890).and_return(group_marker)
-    expect(group_marker).to receive(:mark_group_as_read)
-    expect(subject.call(mark: "group", id: 5, before: 1234567890)).to eq({})
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api/write_mark_item_spec.rb b/spec/fever_api/write_mark_item_spec.rb
deleted file mode 100644
index 2a2929409..000000000
--- a/spec/fever_api/write_mark_item_spec.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-require "spec_helper"
-
-app_require "fever_api/write_mark_item"
-
-describe FeverAPI::WriteMarkItem do
-  let(:item_marker) { double("item marker") }
-  let(:marker_class) { double("marker class") }
-
-  describe "as read" do
-    subject do
-      FeverAPI::WriteMarkItem.new(read_marker_class: marker_class)
-    end
-
-    it "instantiates an item marker and calls mark_item_as_read if requested" do
-      expect(marker_class).to receive(:new).with(5).and_return(item_marker)
-      expect(item_marker).to receive(:mark_as_read)
-      expect(subject.call(mark: "item", as: "read", id: 5)).to eq({})
-    end
-  end
-
-  describe "as unread" do
-    subject do
-      FeverAPI::WriteMarkItem.new(unread_marker_class: marker_class)
-    end
-
-    it "instantiates an item marker and calls mark_item_as_unread if requested" do
-      expect(marker_class).to receive(:new).with(5).and_return(item_marker)
-      expect(item_marker).to receive(:mark_as_unread)
-      expect(subject.call(mark: "item", as: "unread", id: 5)).to eq({})
-    end
-  end
-
-  describe "as starred" do
-    subject do
-      FeverAPI::WriteMarkItem.new(starred_marker_class: marker_class)
-    end
-
-    it "instantiates an item marker and calls mark_item_as_starred if requested" do
-      expect(marker_class).to receive(:new).with(5).and_return(item_marker)
-      expect(item_marker).to receive(:mark_as_starred)
-      expect(subject.call(mark: "item", as: "saved", id: 5)).to eq({})
-    end
-  end
-
-  describe "as unstarred" do
-    subject do
-      FeverAPI::WriteMarkItem.new(unstarred_marker_class: marker_class)
-    end
-
-    it "instantiates an item marker and calls mark_item_as_unstarred if requested" do
-      expect(marker_class).to receive(:new).with(5).and_return(item_marker)
-      expect(item_marker).to receive(:mark_as_unstarred)
-      expect(subject.call(mark: "item", as: "unsaved", id: 5)).to eq({})
-    end
-  end
-
-  it "returns an empty hash otherwise" do
-    expect(subject.call).to eq({})
-  end
-end
diff --git a/spec/fever_api_spec.rb b/spec/fever_api_spec.rb
deleted file mode 100644
index 011d0eb43..000000000
--- a/spec/fever_api_spec.rb
+++ /dev/null
@@ -1,243 +0,0 @@
-require "spec_helper"
-require "./fever_api"
-
-describe FeverAPI do
-  include Rack::Test::Methods
-
-  def app
-    FeverAPI::Endpoint
-  end
-
-  let(:api_key) { "apisecretkey" }
-  let(:story_one) { StoryFactory.build }
-  let(:story_two) { StoryFactory.build }
-  let(:group) { GroupFactory.build }
-  let(:feed) { FeedFactory.build(group_id: group.id) }
-  let(:stories) { [story_one, story_two] }
-  let(:standard_answer) do
-    { api_version: 3, auth: 1, last_refreshed_on_time: 123456789 }
-  end
-  let(:cannot_auth) do
-    { api_version: 3, auth: 0 }
-  end
-  let(:headers) { { api_key: api_key } }
-
-  before do
-    user = double(api_key: api_key)
-    allow(User).to receive(:first) { user }
-
-    allow(Time).to receive(:now) { Time.at(123456789) }
-  end
-
-  def last_response_as_object
-    JSON.parse(last_response.body, symbolize_names: true)
-  end
-
-  describe "authentication" do
-    it "authenticates request with correct api_key" do
-      get "/", headers
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-    end
-
-    it "does not authenticate request with incorrect api_key" do
-      get "/", api_key: "foo"
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(cannot_auth)
-    end
-
-    it "does not authenticate request when api_key is not provided" do
-      get "/"
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(cannot_auth)
-    end
-  end
-
-  describe "#get" do
-    def make_request(extra_headers = {})
-      get "/", headers.merge(extra_headers)
-    end
-
-    it "returns standard answer" do
-      make_request
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-    end
-
-    it "returns groups and feeds by groups when 'groups' header is provided" do
-      allow(GroupRepository).to receive(:list).and_return([group])
-      allow(FeedRepository).to receive_message_chain(:in_group, :order).and_return([feed])
-
-      make_request(groups: nil)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(
-        groups: [group.as_fever_json],
-        feeds_groups: [{ group_id: group.id, feed_ids: feed.id.to_s }]
-      )
-    end
-
-    it "returns feeds and feeds by groups when 'feeds' header is provided" do
-      allow(FeedRepository).to receive(:list).and_return([feed])
-      allow(FeedRepository).to receive_message_chain(:in_group, :order).and_return([feed])
-
-      make_request(feeds: nil)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(
-        feeds: [feed.as_fever_json],
-        feeds_groups: [{ group_id: group.id, feed_ids: feed.id.to_s }]
-      )
-    end
-
-    it "returns favicons hash when 'favicons' header provided" do
-      make_request(favicons: nil)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(
-        favicons: [
-          {
-            id: 0,
-            data: "image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
-          }
-        ]
-      )
-    end
-
-    it "returns stories when 'items' header is provided along with 'since_id'" do
-      expect(StoryRepository).to receive(:unread_since_id).with("5").and_return([story_one])
-      expect(StoryRepository).to receive(:unread).and_return([story_one, story_two])
-
-      make_request(items: nil, since_id: 5)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(
-        items: [story_one.as_fever_json],
-        total_items: 2
-      )
-    end
-
-    it "returns stories when 'items' header is provided without 'since_id'" do
-      expect(StoryRepository).to receive(:unread).twice.and_return([story_one, story_two])
-
-      make_request(items: nil)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(
-        items: [story_one.as_fever_json, story_two.as_fever_json],
-        total_items: 2
-      )
-    end
-
-    it "returns stories ids when 'items' header is provided along with 'with_ids'" do
-      expect(StoryRepository).to receive(:fetch_by_ids).twice.with(["5"]).and_return([story_one])
-
-      make_request(items: nil, with_ids: 5)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(
-        items: [story_one.as_fever_json],
-        total_items: 1
-      )
-    end
-
-    it "returns links as empty array when 'links' header is provided" do
-      make_request(links: nil)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(links: [])
-    end
-
-    it "returns unread items ids when 'unread_item_ids' header is provided" do
-      expect(StoryRepository).to receive(:unread).and_return([story_one, story_two])
-
-      make_request(unread_item_ids: nil)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(
-        unread_item_ids: [story_one.id, story_two.id].join(",")
-      )
-    end
-
-    it "returns starred items when 'saved_item_ids' header is provided" do
-      expect(Story).to receive(:where).with(is_starred: true).and_return([story_one, story_two])
-
-      make_request(saved_item_ids: nil)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-      expect(last_response_as_object).to include(
-        saved_item_ids: [story_one.id, story_two.id].join(",")
-      )
-    end
-  end
-
-  describe "#post" do
-    def make_request(extra_headers = {})
-      post "/", headers.merge(extra_headers)
-    end
-
-    it "commands to mark story as read" do
-      expect(MarkAsRead).to receive(:new).with("10").and_return(double(mark_as_read: true))
-
-      make_request(mark: "item", as: "read", id: 10)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-    end
-
-    it "commands to mark story as unread" do
-      expect(MarkAsUnread).to receive(:new).with("10").and_return(double(mark_as_unread: true))
-
-      make_request(mark: "item", as: "unread", id: 10)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-    end
-
-    it "commands to save story" do
-      expect(MarkAsStarred).to receive(:new).with("10").and_return(double(mark_as_starred: true))
-
-      make_request(mark: "item", as: "saved", id: 10)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-    end
-
-    it "commands to unsave story" do
-      expect(MarkAsUnstarred).to receive(:new).with("10").and_return(double(mark_as_unstarred: true))
-
-      make_request(mark: "item", as: "unsaved", id: 10)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-    end
-
-    it "commands to mark group as read" do
-      expect(MarkGroupAsRead).to receive(:new).with("10", "1375080946").and_return(double(mark_group_as_read: true))
-
-      make_request(mark: "group", as: "read", id: 10, before: 1375080946)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-    end
-
-    it "commands to mark entire feed as read" do
-      expect(MarkFeedAsRead).to receive(:new).with("20", "1375080945").and_return(double(mark_feed_as_read: true))
-
-      make_request(mark: "feed", as: "read", id: 20, before: 1375080945)
-
-      expect(last_response).to be_ok
-      expect(last_response_as_object).to include(standard_answer)
-    end
-  end
-end
diff --git a/spec/fixtures/feeds.opml b/spec/fixtures/feeds.opml
new file mode 100755
index 000000000..832f59b48
--- /dev/null
+++ b/spec/fixtures/feeds.opml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<opml version="1.0">
+    <head>
+        <title>subscriptions title</title>
+    </head>
+    <body>
+        <outline text="Autoblog" title="Autoblog" type="rss"
+            xmlUrl="http://feeds.autoblog.com/weblogsinc/autoblog/" htmlUrl="http://www.autoblog.com"/>
+        <outline text="City Guide News" title="City Guide News"
+            type="rss"
+            xmlUrl="http://www.probki.net/news/RSS_news_feed.asp" htmlUrl="http://www.probki.net"/>
+        <outline title="Football News" text="Football News">
+            <outline text="TMW Football Transfer News"
+                title="TMW Football Transfer News" type="rss"
+                xmlUrl="http://www.transfermarketweb.com/rss" htmlUrl="http://www.transfermarketweb.com"/>
+        </outline>
+        <outline title="RoR" text="RoR">
+            <outline
+                text="GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS - Home"
+                title="GIANT ROBOTS SMASHING INTO OTHER GIANT ROBOTS - Home"
+                type="rss"
+                xmlUrl="http://feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots" htmlUrl="http://robots.thoughtbot.com/"/>
+            <outline text="igvita.com" title="igvita.com" type="rss"
+                xmlUrl="http://www.igvita.com/feed/" htmlUrl="http://www.igvita.com"/>
+        </outline>
+        <outline title="Empty Group" text="Empty Group">
+        </outline>
+    </body>
+</opml>
diff --git a/spec/helpers/authentications_helper_spec.rb b/spec/helpers/authentications_helper_spec.rb
deleted file mode 100644
index 325dce20b..000000000
--- a/spec/helpers/authentications_helper_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require "spec_helper"
-
-app_require "helpers/authentication_helpers"
-
-RSpec.describe Sinatra::AuthenticationHelpers do
-  class Helper
-    include Sinatra::AuthenticationHelpers
-  end
-
-  let(:helper) { Helper.new }
-
-  describe "#needs_authentication?" do
-    let(:authenticated_path) { "/news" }
-
-    before do
-      stub_const("ENV", "RACK_ENV" => "not-test")
-      allow(UserRepository).to receive(:setup_complete?).and_return(true)
-    end
-
-    context "when `RACK_ENV` is 'test'" do
-      it "returns false" do
-        stub_const("ENV", "RACK_ENV" => "test")
-
-        expect(helper.needs_authentication?(authenticated_path)).to eq(false)
-      end
-    end
-
-    context "when setup in not complete" do
-      it "returns false" do
-        allow(UserRepository).to receive(:setup_complete?).and_return(false)
-
-        expect(helper.needs_authentication?(authenticated_path)).to eq(false)
-      end
-    end
-
-    %w(/login /logout /heroku).each do |path|
-      context "when `path` is '#{path}'" do
-        it "returns false" do
-          expect(helper.needs_authentication?(path)).to eq(false)
-        end
-      end
-    end
-
-    %w(css js img).each do |path|
-      context "when `path` contains '#{path}'" do
-        it "returns false" do
-          expect(helper.needs_authentication?("/#{path}/file.ext")).to eq(false)
-        end
-      end
-    end
-
-    context "otherwise" do
-      it "returns true" do
-        expect(helper.needs_authentication?(authenticated_path)).to eq(true)
-      end
-    end
-  end
-end
diff --git a/spec/helpers/url_helers_spec.rb b/spec/helpers/url_helpers_spec.rb
similarity index 58%
rename from spec/helpers/url_helers_spec.rb
rename to spec/helpers/url_helpers_spec.rb
index 6ed0288bf..1ba7126d0 100644
--- a/spec/helpers/url_helers_spec.rb
+++ b/spec/helpers/url_helpers_spec.rb
@@ -1,66 +1,64 @@
-require "spec_helper"
-
-app_require "helpers/url_helpers"
+# frozen_string_literal: true
 
 RSpec.describe UrlHelpers do
-  class Helper
-    include UrlHelpers
+  def helper
+    helper_class = Class.new { include UrlHelpers }
+    helper_class.new
   end
 
-  let(:helper) { Helper.new }
-
   describe "#expand_absolute_urls" do
     it "preserves existing absolute urls" do
       content = '<a href="http://foo">bar</a>'
 
-      expect(helper.expand_absolute_urls(content, nil)).to eq content
+      expect(helper.expand_absolute_urls(content, nil)).to eq(content)
     end
 
     it "replaces relative urls in a, img and video tags" do
-      content = <<-EOS
-<div>
-<img src="https://foo">
-<a href="/bar/baz">tee</a><img src="bar/bar">
-<video src="/tee"></video>
-</div>
-      EOS
+      content = <<~HTML
+        <div>
+        <img src="https://foo">
+        <a href="/bar/baz">tee</a><img src="bar/bar">
+        <video src="/tee"></video>
+        </div>
+      HTML
 
       result = helper.expand_absolute_urls(content, "http://oodl.io/d/")
-      expect(result.delete("\n")).to eq <<-EOS.delete("\n")
-<div>
-<img src="https://foo">
-<a href="http://oodl.io/bar/baz">tee</a>
-<img src="http://oodl.io/d/bar/bar">
-<video src="http://oodl.io/tee"></video>
-</div>
-      EOS
+      expect(result.delete("\n")).to eq(<<~HTML.delete("\n"))
+        <div>
+        <img src="https://foo">
+        <a href="http://oodl.io/bar/baz">tee</a>
+        <img src="http://oodl.io/d/bar/bar">
+        <video src="http://oodl.io/tee"></video>
+        </div>
+      HTML
     end
 
     it "handles empty body" do
-      expect(helper.expand_absolute_urls("", nil)).to eq ""
+      expect(helper.expand_absolute_urls("", nil)).to eq("")
     end
 
     it "doesn't modify tags that do not have url attributes" do
-      content = <<-EOS
-<div>
-<img foo="bar">
-<a name="something"/></a>
-<video foo="bar"></video>
-</div>
-      EOS
+      content = <<~HTML
+        <div>
+        <img foo="bar">
+        <a name="something"></a>
+        <video foo="bar"></video>
+        </div>
+      HTML
 
       result = helper.expand_absolute_urls(content, "http://oodl.io/d/")
-      expect(result.delete("\n")).to eq <<-EOS.delete("\n")
-<div>
-<img foo="bar">
-<a name="something"></a>
-<video foo="bar"></video>
-</div>
-      EOS
+      expect(result.delete("\n")).to eq(<<~HTML.delete("\n"))
+        <div>
+        <img foo="bar">
+        <a name="something"></a>
+        <video foo="bar"></video>
+        </div>
+      HTML
     end
 
     it "leaves the url as-is if it cannot be parsed" do
-      weird_url = "https://github.com/aphyr/jepsen/blob/" \
+      weird_url =
+        "https://github.com/aphyr/jepsen/blob/" \
         "1403f2d6e61c595bafede0d404fd4a893371c036/" \
         "elasticsearch/src/jepsen/system/elasticsearch.clj#" \
         "L161-L226.%20Then%20we'll%20write%20a%20%5Bregister%20test%5D(" \
@@ -77,12 +75,12 @@ class Helper
 
   describe "#normalize_url" do
     it "resolves scheme-less urls" do
-      %w(http https).each do |scheme|
+      ["http", "https"].each do |scheme|
         feed_url = "#{scheme}://blog.golang.org/feed.atom"
 
         url = helper.normalize_url("//blog.golang.org/context", feed_url)
 
-        expect(url).to eq "#{scheme}://blog.golang.org/context"
+        expect(url).to eq("#{scheme}://blog.golang.org/context")
       end
     end
 
@@ -98,7 +96,7 @@ class Helper
       url = helper.normalize_url(
         "//blog.golang.org/context", "//blog.golang.org/feed.atom"
       )
-      expect(url).to eq "http://blog.golang.org/context"
+      expect(url).to eq("http://blog.golang.org/context")
     end
 
     it "resolves relative urls" do
@@ -106,7 +104,7 @@ class Helper
         "/progrium/dokku/releases/tag/v0.4.4",
         "https://github.com/progrium/dokku/releases.atom"
       )
-      expect(url).to eq "https://github.com/progrium/dokku/releases/tag/v0.4.4"
+      expect(url).to eq("https://github.com/progrium/dokku/releases/tag/v0.4.4")
     end
   end
 end
diff --git a/spec/integration/feed_importing_spec.rb b/spec/integration/feed_importing_spec.rb
index 699a9e696..4755f8af3 100644
--- a/spec/integration/feed_importing_spec.rb
+++ b/spec/integration/feed_importing_spec.rb
@@ -1,94 +1,88 @@
-require "spec_helper"
-require "time"
-require "support/active_record"
-require "support/feed_server"
-require "capybara"
-require "capybara/server"
-require "timecop"
+# frozen_string_literal: true
 
-app_require "tasks/fetch_feed"
+require "support/feed_server"
 
-describe "Feed importing" do
-  before(:all) do
-    @server = FeedServer.new
+RSpec.describe "Feed importing" do
+  def create_server(url: "feed01_valid_feed/feed.xml")
+    server = FeedServer.new
+    server.response = sample_data(url)
+    server
   end
 
-  let(:feed) do
-    Feed.create(
+  def create_feed(**)
+    create(
+      :feed,
       name: "Example feed",
-      last_fetched: Time.new(2014, 1, 1),
-      url: @server.url
+      last_fetched: Time.zone.local(2014, 1, 1),
+      **
     )
   end
 
   describe "Valid feed" do
-    before(:all) do
-      # articles older than 3 days are ignored, so freeze time within
-      # applicable range of the stories in the sample feed
-      Timecop.freeze Time.parse("2014-08-15T17:30:00Z")
-    end
+    # articles older than 3 days are ignored, so freeze time within
+    # applicable range of the stories in the sample feed
+    # travel_to(Time.parse("2014-08-15T17:30:00Z"))
 
     describe "Importing for the first time" do
       it "imports all entries" do
-        @server.response = sample_data("feeds/feed01_valid_feed/feed.xml")
-        expect { fetch_feed(feed) }.to change { feed.stories.count }.to(5)
+        travel_to(Time.parse("2014-08-15T17:30:00Z"))
+        feed = create_feed(url: create_server.url)
+        expect { Feed::FetchOne.call(feed) }
+          .to change(feed.stories, :count).to(5)
       end
     end
 
     describe "Importing for the second time" do
-      before(:each) do
-        @server.response = sample_data("feeds/feed01_valid_feed/feed.xml")
-        fetch_feed(feed)
-      end
-
       context "no new entries" do
         it "does not create new stories" do
-          @server.response = sample_data("feeds/feed01_valid_feed/feed.xml")
-          expect { fetch_feed(feed) }.to_not change { feed.stories.count }
+          travel_to(Time.parse("2014-08-15T17:30:00Z"))
+          feed = create_feed(url: create_server.url)
+          Feed::FetchOne.call(feed)
+          expect { Feed::FetchOne.call(feed) }
+            .not_to change(feed.stories, :count)
         end
       end
 
       context "new entries" do
         it "creates new stories" do
-          @server.response = sample_data("feeds/feed01_valid_feed/feed_updated.xml")
-          expect { fetch_feed(feed) }.to change { feed.stories.count }.by(1)
+          travel_to(Time.parse("2014-08-15T17:30:00Z"))
+          server = create_server
+          feed = create_feed(url: server.url)
+          Feed::FetchOne.call(feed)
+          server.response = sample_data("feed01_valid_feed/feed_updated.xml")
+          expect { Feed::FetchOne.call(feed) }
+            .to change(feed.stories, :count).by(1)
         end
       end
     end
   end
 
   describe "Feed with incorrect pubdates" do
-    before(:all) do
-      Timecop.freeze Time.parse("2014-08-12T17:30:00Z")
-    end
-
     context "has been fetched before" do
+      url = "feed02_invalid_published_dates/feed.xml"
+      last_fetched = Time.parse("2014-08-12T00:01:00Z")
+
       it "imports all new stories" do
         # This spec describes a scenario where the feed is reporting incorrect
-        # published dates for stories.
-        # The feed in question is feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots.
-        # When an article is published the published date is always set to 00:00 of
-        # the day the article was published.
-        # This specs shows that with the current behaviour (08-15-2014) Stringer
-        # will not detect this article, if the last time this feed was fetched is
-        # after 00:00 the day the article was published.
+        # published dates for stories. The feed in question is
+        # feeds.feedburner.com/GiantRobotsSmashingIntoOtherGiantRobots. When an
+        # article is published the published date is always set to 00:00 of the
+        # day the article was published. This specs shows that with the current
+        # behaviour (08-15-2014) Stringer will not detect this article, if the
+        # last time this feed was fetched is after 00:00 the day the article
+        # was published.
 
-        feed.last_fetched = Time.parse("2014-08-12T00:01:00Z")
-        @server.response = sample_data("feeds/feed02_invalid_published_dates/feed.xml")
+        travel_to(Time.parse("2014-08-12T17:30:00Z"))
+        server = create_server(url:)
+        feed = create_feed(url: server.url, last_fetched:)
 
-        expect { fetch_feed(feed) }.to change { feed.stories.count }.by(1)
+        expect { Feed::FetchOne.call(feed) }
+          .to change(feed.stories, :count).by(1)
       end
     end
   end
 end
 
 def sample_data(path)
-  File.new(File.join("spec", "sample_data", path)).read
-end
-
-def fetch_feed(feed)
-  logger = Logger.new(STDOUT)
-  logger.level = Logger::DEBUG
-
-  FetchFeed.new(feed, logger: logger).fetch
+  File.new(File.join("spec", "sample_data", "feeds", path)).read
 end
diff --git a/spec/javascript/spec/spec_helper.js b/spec/javascript/spec/spec_helper.js
index a35e11d48..e18c11a27 100644
--- a/spec/javascript/spec/spec_helper.js
+++ b/spec/javascript/spec/spec_helper.js
@@ -1,3 +1,7 @@
+if (typeof initMochaPhantomJS === 'function') {
+  initMochaPhantomJS()
+}
+
 mocha.ui('bdd');
 
 chai.should();
diff --git a/spec/javascript/spec/views/story_view_spec.js b/spec/javascript/spec/views/story_view_spec.js
index c7a556c97..737ac7f5e 100644
--- a/spec/javascript/spec/views/story_view_spec.js
+++ b/spec/javascript/spec/views/story_view_spec.js
@@ -9,6 +9,7 @@ describe("Storyiew", function(){
     before(function() {
       this.story = new Story({
         source: "TechKrunch",
+        enclosure_url: null,
         headline: "Every startups acquired by Yahoo!",
         lead: "This is the lead.",
         title: "Every startups acquired by Yahoo! NOT!!",
@@ -34,6 +35,10 @@ describe("Storyiew", function(){
       el.find(tagName).should.have.length(count);
     };
 
+    var assertNoTagExists = function(el, tagName) {
+      el.find(tagName).should.have.length(0);
+    };
+
     var assertPropertyRendered = function(el, model, propName) {
       el.html().should.have.string(model.get(propName));
     };
@@ -104,6 +109,18 @@ describe("Storyiew", function(){
       assertTagExists(this.view.$el, ".story-starred .icon-star", 2);
     });
 
+    it("should not render enclosure link when not present", function(){
+      assertNoTagExists(this.view.$el, ".story-enclosure");
+    });
+
+    it("should render enclosure link when present", function(){
+      this.story.set("enclosure_url", "http://example.com/enclosure");
+      this.view.render();
+
+      assertTagExists(this.view.$el, ".story-enclosure");
+      assertPropertyRendered(this.view.$el, this.story, "enclosure_url");
+    });
+
     describe("Handling click on story", function(){
       beforeEach(function() {
         this.toggle_stub = sinon.stub(this.story, "toggle");
diff --git a/spec/javascript/support/views/index.erb b/spec/javascript/support/views/test/index.html.erb
similarity index 83%
rename from spec/javascript/support/views/index.erb
rename to spec/javascript/support/views/test/index.html.erb
index 52ec0d503..bf129cce1 100644
--- a/spec/javascript/support/views/index.erb
+++ b/spec/javascript/support/views/test/index.html.erb
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html>
+<html lang="en">
   <head>
     <title>Stringer JavaScript Test Suite</title>
     <meta http-equiv="Content-Type" content="text/html; charset=utf-8" >
@@ -13,10 +13,7 @@
     <% end %>
 
     <!-- Javascript templates -->
-    <% js_templates.each do |template| %>
-      <%= template %>
-      <%= render_js_template template.to_sym %>
-    <% end %>
+    <%= render("stories/templates") %>
   </head>
   <body>
     <div id="mocha"></div>
diff --git a/spec/javascript/test_controller.rb b/spec/javascript/test_controller.rb
index c79b0993e..57f461f86 100644
--- a/spec/javascript/test_controller.rb
+++ b/spec/javascript/test_controller.rb
@@ -1,55 +1,53 @@
-class Stringer < Sinatra::Base
-  def self.test_path(*chunks)
-    File.expand_path(File.join("..", *chunks), __FILE__)
-  end
+# frozen_string_literal: true
+
+class TestController < ApplicationController
+  skip_before_action :verify_authenticity_token
 
-  get "/test" do
-    erb File.read(self.class.test_path("support", "views", "index.erb")), layout: false, locals: {
-      js_files: js_files,
-      js_templates: js_templates
-    }
+  def index
+    authorization.skip
+    prepend_view_path(test_path("support", "views"))
+    render(layout: false, locals: { js_files: })
   end
 
-  get "/spec/*" do
-    send_file self.class.test_path("spec", *params[:splat])
+  def spec
+    authorization.skip
+    send_file(test_path("spec", "#{params[:splat]}.js"))
   end
 
-  get "/vendor/*" do
-    send_file self.class.test_path("support", "vendor", *params[:splat])
+  def vendor
+    authorization.skip
+
+    filename = "#{params[:splat]}.#{params[:format]}"
+    send_file(test_path("support", "vendor", params[:format], filename))
   end
 
   private
 
+  def test_path(*)
+    File.expand_path(File.join(__dir__, *))
+  end
+
   def vendor_js_files
-    %w(mocha.js sinon.js chai.js chai-changes.js chai-backbone.js sinon-chai.js).map do |name|
-      File.join "vendor", "js", name
-    end
+    [
+      "vendor/js/mocha.js",
+      "vendor/js/sinon.js",
+      "vendor/js/chai.js",
+      "vendor/js/chai-changes.js",
+      "vendor/js/chai-backbone.js",
+      "vendor/js/sinon-chai.js"
+    ]
   end
 
   def vendor_css_files
-    %w(mocha.css).map do |name|
-      File.join "vendor", "css", name
-    end
+    ["vendor/css/mocha.css"]
   end
 
   def js_helper_files
-    %w(spec_helper.js).map do |name|
-      File.join "spec", name
-    end
-  end
-
-  def js_lib_files
-    base = self.class.test_path("..", "..", "app", "public")
-    Dir[File.join(base, "js", "**", "*.js")].map do |lib_file|
-      lib_file.sub!(base, "")
-    end
+    ["spec/spec_helper.js"]
   end
 
   def js_test_files
-    base = self.class.test_path
-    Dir[File.join(base, "**", "*_spec.js")].map do |spec_file|
-      spec_file.sub!(base, "")
-    end
+    ["/spec/models/story_spec.js", "/spec/views/story_view_spec.js"]
   end
 
   def js_files
@@ -59,8 +57,5 @@ def js_files
   def css_files
     vendor_css_files
   end
-
-  def js_templates
-    [:story]
-  end
+  helper_method :css_files
 end
diff --git a/spec/jobs/callable_job_spec.rb b/spec/jobs/callable_job_spec.rb
new file mode 100644
index 000000000..2ca4b33e7
--- /dev/null
+++ b/spec/jobs/callable_job_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+RSpec.describe CallableJob do
+  it "calls the callable" do
+    callable = ->(*, **) {}
+
+    expect { described_class.perform_now(callable, "foo", bar: "baz") }
+      .to invoke(:call).on(callable).with("foo", bar: "baz")
+  end
+end
diff --git a/spec/lib/admin_constraint_spec.rb b/spec/lib/admin_constraint_spec.rb
new file mode 100644
index 000000000..09efd1cc6
--- /dev/null
+++ b/spec/lib/admin_constraint_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+RSpec.describe AdminConstraint do
+  def make_request(session: {})
+    request = ActionDispatch::Request.new({})
+    request.session = session
+    request
+  end
+
+  it "matches when the session user is an admin" do
+    user = create(:user, admin: true)
+    request = make_request(session: { user_id: user.id })
+
+    expect(described_class.new.matches?(request)).to be(true)
+  end
+
+  it "does not match when the session user is not an admin" do
+    user = create(:user)
+    request = make_request(session: { user_id: user.id })
+
+    expect(described_class.new.matches?(request)).to be(false)
+  end
+
+  it "does not match when there is no session user" do
+    request = make_request
+
+    expect(described_class.new.matches?(request)).to be(false)
+  end
+end
diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb
new file mode 100644
index 000000000..c02d0c3e9
--- /dev/null
+++ b/spec/models/application_record_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+RSpec.describe ApplicationRecord do
+  describe ".boolean_accessor" do
+    with_model :Cheese, superclass: described_class do
+      table { |t| t.jsonb(:options, default: {}) }
+    end
+
+    describe "#<key>" do
+      it "returns the value when present" do
+        Cheese.boolean_accessor(:options, :stinky)
+        cheese = Cheese.new(options: { stinky: true })
+
+        expect(cheese.stinky).to be(true)
+      end
+
+      it "returns false by default" do
+        Cheese.boolean_accessor(:options, :stinky)
+        cheese = Cheese.new
+
+        expect(cheese.stinky).to be(false)
+      end
+
+      it "returns the default when value is nil" do
+        Cheese.boolean_accessor(:options, :stinky, default: true)
+        cheese = Cheese.new
+
+        expect(cheese.stinky).to be(true)
+      end
+
+      it "casts the value to a boolean" do
+        Cheese.boolean_accessor(:options, :stinky)
+        cheese = Cheese.new(options: { stinky: "true" })
+
+        expect(cheese.stinky).to be(true)
+      end
+    end
+
+    describe "#<key>=" do
+      it "sets the value" do
+        Cheese.boolean_accessor(:options, :stinky)
+        cheese = Cheese.new
+
+        cheese.stinky = true
+
+        expect(cheese.options).to eq({ "stinky" => true })
+      end
+
+      it "casts the value to a boolean" do
+        Cheese.boolean_accessor(:options, :stinky)
+        cheese = Cheese.new
+
+        cheese.stinky = "true"
+
+        expect(cheese.options).to eq({ "stinky" => true })
+      end
+
+      it "uses the default when value is nil" do
+        Cheese.boolean_accessor(:options, :stinky, default: true)
+        cheese = Cheese.new
+
+        cheese.stinky = nil
+
+        expect(cheese.options).to eq({ "stinky" => true })
+      end
+    end
+  end
+end
diff --git a/spec/models/feed_spec.rb b/spec/models/feed_spec.rb
new file mode 100644
index 000000000..a78f05c25
--- /dev/null
+++ b/spec/models/feed_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+RSpec.describe "Feed" do
+  describe ".with_unread_stories_counts" do
+    it "returns feeds with unread stories counts" do
+      create(:story)
+
+      feed = Feed.with_unread_stories_counts.first
+
+      expect(feed.unread_stories_count).to eq(1)
+    end
+
+    it "includes feeds with no unread stories" do
+      create(:story, :read)
+
+      feed = Feed.with_unread_stories_counts.first
+
+      expect(feed.unread_stories_count).to eq(0)
+    end
+  end
+
+  describe "#unread_stories" do
+    it "returns stories where is_read is false" do
+      feed = create(:feed)
+      story = create(:story, feed:)
+
+      expect(feed.unread_stories).to eq([story])
+    end
+
+    it "does not return stories where is_read is true" do
+      feed = create(:feed)
+      create(:story, :read, feed:)
+
+      expect(feed.unread_stories).to be_empty
+    end
+  end
+
+  describe "#status_bubble" do
+    it "returns 'yellow' when status == 'red' and there are stories" do
+      feed = Feed.new(status: :red, stories: [Story.new])
+
+      expect(feed.status_bubble).to eq("yellow")
+    end
+
+    it "returns 'red' if status is 'red' and there are no stories" do
+      feed = Feed.new(status: :red)
+
+      expect(feed.status_bubble).to eq("red")
+    end
+
+    it "returns nil when no status is set" do
+      feed = Feed.new
+
+      expect(feed.status_bubble).to be_nil
+    end
+
+    it "returns :green when status is :green" do
+      feed = Feed.new(status: :green)
+
+      expect(feed.status_bubble).to eq("green")
+    end
+  end
+
+  describe "#as_fever_json" do
+    it "returns a hash of the feed in fever format" do
+      last_fetched = 1.day.ago
+      feed = Feed.new(
+        id: 52,
+        name: "chicken feed",
+        url: "wat url",
+        last_fetched:
+      )
+
+      expect(feed.as_fever_json).to eq(
+        id: 52,
+        favicon_id: 0,
+        title: "chicken feed",
+        url: "wat url",
+        site_url: "wat url",
+        is_spark: 0,
+        last_updated_on_time: last_fetched.to_i
+      )
+    end
+
+    it "replaces a null title with an empty string" do
+      last_fetched = 1.day.ago
+      feed = Feed.new(id: 52, name: nil, url: "wat url", last_fetched:)
+
+      expect(feed.as_fever_json).to eq(
+        id: 52,
+        favicon_id: 0,
+        title: "",
+        url: "wat url",
+        site_url: "wat url",
+        is_spark: 0,
+        last_updated_on_time: last_fetched.to_i
+      )
+    end
+  end
+end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
new file mode 100644
index 000000000..6ee186679
--- /dev/null
+++ b/spec/models/group_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+RSpec.describe Group do
+  describe "#as_fever_json" do
+    it "returns a hash of the group in fever format" do
+      group = described_class.new(id: 5, name: "wat group")
+
+      expect(group.as_fever_json).to eq(id: 5, title: "wat group")
+    end
+  end
+end
diff --git a/spec/models/migration_status_spec.rb b/spec/models/migration_status_spec.rb
index e05961abc..d509990b2 100644
--- a/spec/models/migration_status_spec.rb
+++ b/spec/models/migration_status_spec.rb
@@ -1,21 +1,17 @@
-require "spec_helper"
-require "support/active_record"
+# frozen_string_literal: true
 
-app_require "models/migration_status"
+RSpec.describe "MigrationStatus" do
+  it "returns array of strings representing pending migrations" do
+    migrator = ActiveRecord::Base.connection.pool.migration_context.open
 
-describe "MigrationStatus" do
-  describe "pending_migrations" do
-    it "returns array of strings representing pending migrations" do
-      migrator = double "Migrator"
-      allow(migrator).to receive(:migrations).and_return [
-        double("First Migration", name: "Migration A", version: 1),
-        double("Second Migration", name: "Migration B", version: 2),
-        double("Third Migration", name: "Migration C", version: 3)
+    allow(migrator).to receive(:pending_migrations).and_return(
+      [
+        ActiveRecord::Migration.new("Migration B", 2),
+        ActiveRecord::Migration.new("Migration C", 3)
       ]
-      allow(migrator).to receive(:migrations_path)
-      allow(migrator).to receive(:current_version).and_return 1
+    )
+    allow(ActiveRecord::Migrator).to receive(:new).and_return(migrator)
 
-      expect(MigrationStatus.new(migrator).pending_migrations).to eq ["Migration B - 2", "Migration C - 3"]
-    end
+    expect(MigrationStatus.call).to eq(["Migration B - 2", "Migration C - 3"])
   end
 end
diff --git a/spec/models/setting/user_signup_spec.rb b/spec/models/setting/user_signup_spec.rb
new file mode 100644
index 000000000..847443043
--- /dev/null
+++ b/spec/models/setting/user_signup_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+RSpec.describe Setting::UserSignup do
+  describe ".first" do
+    it "returns the first record" do
+      setting = described_class.create!
+
+      expect(described_class.first).to eq(setting)
+    end
+
+    it "creates a record if one does not already exist" do
+      expect { described_class.first }.to change(described_class, :count).by(1)
+    end
+  end
+
+  describe ".enabled?" do
+    it "returns true when enabled" do
+      create(:user)
+      described_class.create!(enabled: true)
+
+      expect(described_class.enabled?).to be(true)
+    end
+
+    it "returns false when disabled" do
+      create(:user)
+      described_class.create!(enabled: false)
+
+      expect(described_class.enabled?).to be(false)
+    end
+
+    it "returns true when no users exist" do
+      described_class.create!(enabled: false)
+
+      expect(described_class.enabled?).to be(true)
+    end
+  end
+end
diff --git a/spec/models/story_spec.rb b/spec/models/story_spec.rb
index 3ca12d381..247792ff7 100644
--- a/spec/models/story_spec.rb
+++ b/spec/models/story_spec.rb
@@ -1,49 +1,159 @@
-require "spec_helper"
-require "support/active_record"
+# frozen_string_literal: true
 
-app_require "models/story"
+RSpec.describe "Story" do
+  describe ".unread" do
+    it "returns stories where is_read is false" do
+      story = create(:story)
 
-describe "Story" do
-  let(:story) do
-    Story.new(
-      title: Faker::Lorem.sentence(50),
-      body: Faker::Lorem.sentence(50)
-    )
+      expect(Story.unread).to eq([story])
+    end
+
+    it "does not return stories where is_read is true" do
+      create(:story, :read)
+
+      expect(Story.unread).to be_empty
+    end
   end
 
   describe "#headline" do
     it "truncates to 50 chars" do
+      story = Story.new(title: "a" * 100)
+
       expect(story.headline.size).to eq(50)
     end
 
     it "uses a fallback string if story has no title" do
+      story = build_stubbed(:story)
       story.title = nil
       expect(story.headline).to eq(Story::UNTITLED)
     end
 
     it "strips html out" do
+      story = build_stubbed(:story)
       story.title = "<b>Super cool</b> stuff"
-      expect(story.headline).to eq "Super cool stuff"
+      expect(story.headline).to eq("Super cool stuff")
     end
   end
 
   describe "#lead" do
     it "truncates to 100 chars" do
+      story = Story.new(body: "a" * 1000)
+
       expect(story.lead.size).to eq(100)
     end
 
     it "strips html out" do
+      story = build_stubbed(:story)
       story.body = "<a href='http://github.com'>Yo</a> dawg"
-      expect(story.lead).to eq "Yo dawg"
+      expect(story.lead).to eq("Yo dawg")
     end
   end
 
   describe "#source" do
-    let(:feed) { Feed.new(name: "Superfeed") }
-    before { story.feed = feed }
-
     it "returns the feeds name" do
+      story = build_stubbed(:story)
+      feed = Feed.new(name: "Superfeed")
+      story.feed = feed
+
       expect(story.source).to eq(feed.name)
     end
   end
+
+  describe "#pretty_date" do
+    it "returns a formatted published date" do
+      published_at = Time.zone.now
+      story = Story.new(published: published_at)
+
+      expect(story.pretty_date).to eq(I18n.l(published_at))
+    end
+
+    it "raises an error when published is nil" do
+      story = Story.new
+
+      expect { story.pretty_date }.to raise_error(ArgumentError)
+    end
+  end
+
+  describe "#as_json" do
+    it "returns a hash of the story" do
+      feed = create(:feed, name: "my feed")
+      published_at = 1.day.ago
+      created_at = 1.hour.ago
+      updated_at = 1.minute.ago
+      story = create(
+        :story,
+        body: "story body",
+        created_at:,
+        entry_id: 5,
+        feed:,
+        is_read: true,
+        is_starred: false,
+        keep_unread: true,
+        permalink: "www.exampoo.com/perma",
+        published: published_at,
+        title: "the story title",
+        updated_at:
+      )
+
+      expect(story.as_json).to eq(
+        {
+          body: "story body",
+          created_at: created_at.utc.as_json,
+          enclosure_url: nil,
+          entry_id: "5",
+          feed_id: feed.id,
+          headline: "the story title",
+          id: story.id,
+          is_read: true,
+          is_starred: false,
+          keep_unread: true,
+          lead: "story body",
+          permalink: "www.exampoo.com/perma",
+          pretty_date: I18n.l(published_at.utc),
+          published: published_at.utc.as_json,
+          source: "my feed",
+          title: "the story title",
+          updated_at: updated_at.utc.as_json
+        }.stringify_keys
+      )
+    end
+  end
+
+  describe "#as_fever_json" do
+    it "returns a hash of the story in fever format" do
+      feed = create(:feed, name: "my feed")
+      published_at = 1.day.ago
+      story = create(
+        :story,
+        feed:,
+        title: "the story title",
+        body: "story body",
+        permalink: "www.exampoo.com/perma",
+        published: published_at,
+        is_read: true
+      )
+
+      expect(story.as_fever_json).to eq(
+        id: story.id,
+        feed_id: feed.id,
+        title: "the story title",
+        author: "my feed",
+        html: "story body",
+        url: "www.exampoo.com/perma",
+        is_saved: 0,
+        is_read: 1,
+        created_on_time: published_at.to_i
+      )
+    end
+
+    it "returns is_read as 0 if story is unread" do
+      story = create(:story, is_read: false)
+      expect(story.as_fever_json[:is_read]).to eq(0)
+    end
+
+    it "returns is_saved as 1 if story is starred" do
+      story = create(:story, is_starred: true)
+      expect(story.as_fever_json[:is_saved]).to eq(1)
+    end
+  end
 end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
new file mode 100644
index 000000000..a194f80b1
--- /dev/null
+++ b/spec/models/user_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+RSpec.describe User do
+  describe "#update_api_key" do
+    it "updates the api key when the username changed" do
+      user = create(:user, username: "stringer", password: "super-secret")
+      user.update!(username: "booger")
+
+      expect(user.api_key).to eq(Digest::MD5.hexdigest("booger:super-secret"))
+    end
+
+    it "updates the api key when the password changed" do
+      user = create(:user, username: "stringer", password: "super-secret")
+      user.update!(password: "new-password")
+
+      expect(user.api_key).to eq(Digest::MD5.hexdigest("stringer:new-password"))
+    end
+
+    it "does nothing when the username and password have not changed" do
+      user = create(:user, username: "stringer", password: "super-secret")
+      user = described_class.find(user.id)
+
+      expect { user.save! }.to not_change(user, :api_key).and not_raise_error
+    end
+
+    it "raises an error when password and password challenge are blank" do
+      user = create(:user, username: "stringer", password: "super-secret")
+      user = described_class.find(user.id)
+      expected_message = "Cannot change username without providing a password"
+
+      expect { user.update!(username: "booger") }
+        .to raise_error(ActiveRecord::ActiveRecordError, expected_message)
+    end
+  end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
new file mode 100644
index 000000000..ced21387b
--- /dev/null
+++ b/spec/rails_helper.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+ENV["RAILS_ENV"] ||= "test"
+
+require_relative "support/coverage"
+require_relative "../config/environment"
+# Prevent database truncation if the environment is production
+if Rails.env.production?
+  abort("The Rails environment is running in production mode!")
+end
+require "rspec/rails"
+
+Rails.root.glob("spec/support/*.rb").each { |path| require path }
+
+# Checks for pending migrations and applies them before tests are run.
+begin
+  ActiveRecord::Migration.maintain_test_schema!
+rescue ActiveRecord::PendingMigrationError => e
+  abort(e.to_s.strip)
+end
+
+RSpec.configure do |config|
+  config.include(RequestHelpers, type: :request)
+  config.include(SystemHelpers, type: :system)
+  config.include(ActiveSupport::Testing::TimeHelpers)
+
+  config.use_transactional_fixtures = true
+
+  config.infer_spec_type_from_file_location!
+
+  # Filter lines from Rails gems in backtraces.
+  config.filter_rails_from_backtrace!
+  # arbitrary gems may also be filtered via:
+  # config.filter_gems_from_backtrace("gem name")
+end
diff --git a/spec/repositories/feed_repository_spec.rb b/spec/repositories/feed_repository_spec.rb
index 1951019f5..864474988 100644
--- a/spec/repositories/feed_repository_spec.rb
+++ b/spec/repositories/feed_repository_spec.rb
@@ -1,73 +1,119 @@
-require "spec_helper"
-require "support/active_record"
+# frozen_string_literal: true
 
-app_require "repositories/feed_repository"
+RSpec.describe FeedRepository do
+  describe ".fetch" do
+    it "finds and returns found feed" do
+      feed = create(:feed)
 
-describe FeedRepository do
-  describe ".update_last_fetched" do
-    let(:timestamp) { Time.now }
+      result = described_class.fetch(feed.id)
 
-    it "saves the last_fetched timestamp" do
-      feed = Feed.new
+      expect(result).to eq(feed)
+    end
+  end
+
+  describe ".fetch_by_ids" do
+    it "finds all feeds by id" do
+      feeds = create_pair(:feed)
+
+      expect(described_class.fetch_by_ids(feeds.map(&:id)))
+        .to match_array(feeds)
+    end
 
-      FeedRepository.update_last_fetched(feed, timestamp)
+    it "does not find other feeds" do
+      feed1, = create_pair(:feed)
 
-      expect(feed.last_fetched).to eq timestamp
+      expect(described_class.fetch_by_ids(feed1.id)).to eq([feed1])
     end
+  end
+
+  describe ".update_feed" do
+    it "saves the name and url" do
+      feed = build(:feed)
 
-    let(:weird_timestamp) { Time.parse("Mon, 01 Jan 0001 00:00:00 +0100") }
+      described_class.update_feed(feed, "Test Feed", "example.com/feed")
+
+      expect(feed.name).to eq("Test Feed")
+      expect(feed.url).to eq("example.com/feed")
+    end
+  end
+
+  describe ".update_last_fetched" do
+    it "saves the last_fetched timestamp" do
+      timestamp = Time.zone.now.round
+      feed = build(:feed)
+
+      described_class.update_last_fetched(feed, timestamp)
+
+      expect(feed.last_fetched).to eq(timestamp)
+    end
 
     it "rejects weird timestamps" do
+      timestamp = Time.zone.now.round
+      weird_timestamp = Time.parse("Mon, 01 Jan 0001 00:00:00 +0100")
       feed = Feed.new(last_fetched: timestamp)
 
-      FeedRepository.update_last_fetched(feed, weird_timestamp)
+      described_class.update_last_fetched(feed, weird_timestamp)
 
-      expect(feed.last_fetched).to eq timestamp
+      expect(feed.last_fetched).to eq(timestamp)
     end
 
-    it "doesn't update if timestamp is nil (feed does not report last modified)" do
+    it "doesn't update if timestamp is nil" do
+      timestamp = Time.zone.now.round
       feed = Feed.new(last_fetched: timestamp)
 
-      FeedRepository.update_last_fetched(feed, nil)
+      described_class.update_last_fetched(feed, nil)
 
-      expect(feed.last_fetched).to eq timestamp
+      expect(feed.last_fetched).to eq(timestamp)
     end
 
     it "doesn't update if timestamp is older than the current value" do
+      timestamp = Time.zone.now.round
       feed = Feed.new(last_fetched: timestamp)
       one_week_ago = timestamp - 1.week
 
-      FeedRepository.update_last_fetched(feed, one_week_ago)
+      described_class.update_last_fetched(feed, one_week_ago)
 
-      expect(feed.last_fetched).to eq timestamp
+      expect(feed.last_fetched).to eq(timestamp)
     end
   end
 
-  describe ".update_feed" do
-    it "saves the name and url" do
-      feed = Feed.new
+  describe ".delete" do
+    it "deletes the feed by id" do
+      feed = create(:feed)
+
+      expect { described_class.delete(feed.id) }.to delete_record(feed)
+    end
 
-      FeedRepository.update_feed(feed, "Test Feed", "example.com/feed")
+    it "does not delete other feeds" do
+      feed1, feed2 = create_pair(:feed)
 
-      expect(feed.name).to eq "Test Feed"
-      expect(feed.url).to eq "example.com/feed"
+      expect { described_class.delete(feed1.id) }.not_to delete_record(feed2)
     end
   end
 
-  describe "fetch" do
-    let(:feed) { Feed.new(id: 1) }
+  describe ".list" do
+    it "returns all feeds ordered by name, case insensitive" do
+      feed1 = create(:feed, name: "foo")
+      feed2 = create(:feed, name: "Fabulous")
+      feed3 = create(:feed, name: "Zooby")
+      feed4 = create(:feed, name: "zabby")
 
-    it "finds by id" do
-      expect(Feed).to receive(:find).with(feed.id).and_return(feed)
-      FeedRepository.fetch(feed.id)
+      expect(described_class.list).to eq([feed2, feed1, feed4, feed3])
     end
+  end
+
+  describe ".in_group" do
+    it "returns feeds that are in a group" do
+      feed1 = create(:feed, group_id: 5)
+      feed2 = create(:feed, group_id: 6)
 
-    it "returns found feed" do
-      allow(Feed).to receive(:find).with(feed.id).and_return(feed)
+      expect(described_class.in_group).to contain_exactly(feed1, feed2)
+    end
 
-      result = FeedRepository.fetch(feed.id)
+    it "does not return feeds that are not in a group" do
+      create_pair(:feed)
 
-      expect(result).to eq feed
+      expect(described_class.in_group).to be_empty
     end
   end
 end
diff --git a/spec/repositories/group_repository_spec.rb b/spec/repositories/group_repository_spec.rb
new file mode 100644
index 000000000..82c86817b
--- /dev/null
+++ b/spec/repositories/group_repository_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+RSpec.describe GroupRepository do
+  describe ".list" do
+    it "lists groups ordered by lower name" do
+      group1 = create(:group, name: "Zabba")
+      group2 = create(:group, name: "zlabba")
+      group3 = create(:group, name: "blabba")
+      group4 = create(:group, name: "Babba")
+      expected_groups = [group4, group3, group1, group2]
+
+      expect(described_class.list).to eq(expected_groups)
+    end
+  end
+end
diff --git a/spec/repositories/story_repository_spec.rb b/spec/repositories/story_repository_spec.rb
index b7eed53bf..c444da0af 100644
--- a/spec/repositories/story_repository_spec.rb
+++ b/spec/repositories/story_repository_spec.rb
@@ -1,106 +1,490 @@
-require "spec_helper"
+# frozen_string_literal: true
 
-app_require "repositories/story_repository"
-
-describe StoryRepository do
+RSpec.describe StoryRepository do
   describe ".add" do
-    let(:feed) { double(url: "http://blog.golang.org/feed.atom") }
-    before do
-      allow(Story).to receive(:create)
+    def create_feed(url: "http://blog.golang.org/feed.atom")
+      double(url:)
     end
 
     it "normalizes story urls" do
-      entry = double(url: "//blog.golang.org/context", title: "", content: "").as_null_object
-      expect(StoryRepository).to receive(:normalize_url).with(entry.url, feed.url)
+      feed = create_feed
+      entry = double(url: "//blog.golang.org/context", title: "", content: "")
+              .as_null_object
+
+      allow(Story).to receive(:create)
+
+      expect(described_class)
+        .to receive(:normalize_url).with(entry.url, feed.url)
 
-      StoryRepository.add(entry, feed)
+      described_class.add(entry, feed)
     end
 
-    it "sanitizes titles" do
+    it "deletes line and paragraph separator characters from titles" do
+      feed = create_feed
       entry = double(title: "n\u2028\u2029", content: "").as_null_object
-      allow(StoryRepository).to receive(:normalize_url)
+      allow(described_class).to receive(:normalize_url)
 
       expect(Story).to receive(:create).with(hash_including(title: "n"))
 
-      StoryRepository.add(entry, feed)
+      described_class.add(entry, feed)
+    end
+
+    it "deletes script tags from titles" do
+      feed = create_feed
+      entry = double(title: "n<script>alert('xss');</script>", content: "")
+              .as_null_object
+      allow(described_class).to receive(:normalize_url)
+
+      expect(Story).to receive(:create).with(hash_including(title: "n"))
+
+      described_class.add(entry, feed)
+    end
+
+    it "sets the enclosure url when present" do
+      feed = create_feed
+      entry = instance_double(
+        Feedjira::Parser::ITunesRSSItem,
+        enclosure_url: "http://example.com/audio.mp3",
+        title: "",
+        summary: "",
+        content: ""
+      ).as_null_object
+      allow(described_class).to receive(:normalize_url)
+
+      expect(Story).to receive(:create).with(hash_including(enclosure_url: "http://example.com/audio.mp3"))
+
+      described_class.add(entry, feed)
+    end
+
+    it "does not set the enclosure url when not present" do
+      feed = create_feed
+      entry = instance_double(
+        Feedjira::Parser::RSSEntry,
+        title: "",
+        summary: "",
+        content: ""
+      ).as_null_object
+      allow(described_class).to receive(:normalize_url)
+
+      expect(Story).to receive(:create).with(hash_including(enclosure_url: nil))
+
+      described_class.add(entry, feed)
+    end
+  end
+
+  describe ".fetch" do
+    it "finds the story by id" do
+      story = create(:story)
+
+      expect(described_class.fetch(story.id)).to eq(story)
+    end
+  end
+
+  describe ".fetch_by_ids" do
+    it "finds all stories by id" do
+      story1 = create(:story)
+      story2 = create(:story)
+      expected_stories = [story1, story2]
+
+      actual_stories = described_class.fetch_by_ids(expected_stories.map(&:id))
+
+      expect(actual_stories).to match_array(expected_stories)
+    end
+  end
+
+  describe ".fetch_unread_by_timestamp" do
+    it "returns unread stories from before the timestamp" do
+      story = create(:story, created_at: 1.week.ago, is_read: false)
+
+      actual_stories = described_class.fetch_unread_by_timestamp(4.days.ago)
+
+      expect(actual_stories).to eq([story])
+    end
+
+    it "does not return unread stories from after the timestamp" do
+      create(:story, created_at: 3.days.ago, is_read: false)
+
+      actual_stories = described_class.fetch_unread_by_timestamp(4.days.ago)
+
+      expect(actual_stories).to be_empty
+    end
+
+    it "does not return read stories from before the timestamp" do
+      create(:story, created_at: 1.week.ago, is_read: true)
+
+      actual_stories = described_class.fetch_unread_by_timestamp(4.days.ago)
+
+      expect(actual_stories).to be_empty
+    end
+  end
+
+  describe ".fetch_unread_by_timestamp_and_group" do
+    it "returns unread stories before timestamp for group_id" do
+      feed = create(:feed, group_id: 52)
+      story = create(:story, feed:, created_at: 5.minutes.ago)
+      time = Time.zone.now
+
+      stories = described_class.fetch_unread_by_timestamp_and_group(time, 52)
+
+      expect(stories).to eq([story])
+    end
+
+    it "does not return read stories before timestamp for group_id" do
+      feed = create(:feed, group_id: 52)
+      create(:story, :read, feed:, created_at: 5.minutes.ago)
+      time = Time.zone.now
+
+      stories = described_class.fetch_unread_by_timestamp_and_group(time, 52)
+
+      expect(stories).to be_empty
+    end
+
+    it "does not return unread stories after timestamp for group_id" do
+      feed = create(:feed, group_id: 52)
+      create(:story, feed:, created_at: 5.minutes.ago)
+      time = 6.minutes.ago
+
+      stories = described_class.fetch_unread_by_timestamp_and_group(time, 52)
+
+      expect(stories).to be_empty
+    end
+
+    it "does not return stories before timestamp for other group_id" do
+      feed = create(:feed, group_id: 52)
+      create(:story, feed:, created_at: 5.minutes.ago)
+      time = Time.zone.now
+
+      stories = described_class.fetch_unread_by_timestamp_and_group(time, 55)
+
+      expect(stories).to be_empty
+    end
+
+    it "does not return stories with no group_id before timestamp" do
+      feed = create(:feed)
+      create(:story, feed:, created_at: 5.minutes.ago)
+      time = Time.zone.now
+
+      stories = described_class.fetch_unread_by_timestamp_and_group(time, 52)
+
+      expect(stories).to be_empty
+    end
+
+    it "returns unread stories before timestamp for nil group_id" do
+      feed = create(:feed)
+      story = create(:story, feed:, created_at: 5.minutes.ago)
+      time = Time.zone.now
+
+      stories = described_class.fetch_unread_by_timestamp_and_group(time, nil)
+
+      expect(stories).to eq([story])
+    end
+  end
+
+  describe ".fetch_unread_for_feed_by_timestamp" do
+    it "returns unread stories for the feed before timestamp" do
+      feed = create(:feed)
+      story = create(:story, feed:, created_at: 5.minutes.ago)
+      time = 4.minutes.ago
+
+      stories =
+        described_class.fetch_unread_for_feed_by_timestamp(feed.id, time)
+
+      expect(stories).to eq([story])
+    end
+
+    it "returns unread stories for the feed before string timestamp" do
+      feed = create(:feed)
+      story = create(:story, feed:, created_at: 5.minutes.ago)
+      timestamp = Integer(4.minutes.ago).to_s
+
+      stories =
+        described_class.fetch_unread_for_feed_by_timestamp(feed.id, timestamp)
+
+      expect(stories).to eq([story])
+    end
+
+    it "does not return read stories for the feed before timestamp" do
+      feed = create(:feed)
+      create(:story, :read, feed:, created_at: 5.minutes.ago)
+      time = 4.minutes.ago
+
+      stories =
+        described_class.fetch_unread_for_feed_by_timestamp(feed.id, time)
+
+      expect(stories).to be_empty
+    end
+
+    it "does not return unread stories for the feed after timestamp" do
+      feed = create(:feed)
+      create(:story, feed:, created_at: 5.minutes.ago)
+      time = 6.minutes.ago
+
+      stories =
+        described_class.fetch_unread_for_feed_by_timestamp(feed.id, time)
+
+      expect(stories).to be_empty
+    end
+
+    it "does not return unread stories for another feed before timestamp" do
+      feed = create(:feed)
+      create(:story, created_at: 5.minutes.ago)
+      time = 4.minutes.ago
+
+      stories =
+        described_class.fetch_unread_for_feed_by_timestamp(feed.id, time)
+
+      expect(stories).to be_empty
+    end
+  end
+
+  describe ".unread" do
+    it "returns unread stories ordered by published date descending" do
+      story1 = create(:story, published: 5.minutes.ago)
+      story2 = create(:story, published: 4.minutes.ago)
+
+      expect(described_class.unread).to eq([story2, story1])
+    end
+
+    it "does not return read stories" do
+      create(:story, :read, published: 5.minutes.ago)
+      create(:story, :read, published: 4.minutes.ago)
+
+      expect(described_class.unread).to be_empty
+    end
+
+    it "allows to override the order" do
+      story1 = create(:story, published: 5.minutes.ago)
+      story2 = create(:story, published: 4.minutes.ago)
+
+      expect(described_class.unread(order: :asc)).to eq([story1, story2])
+    end
+  end
+
+  describe ".unread_since_id" do
+    it "returns unread stories with id greater than given id" do
+      story1 = create(:story)
+      story2 = create(:story)
+
+      expect(described_class.unread_since_id(story1.id)).to eq([story2])
+    end
+
+    it "does not return read stories with id greater than given id" do
+      story1 = create(:story)
+      create(:story, :read)
+
+      expect(described_class.unread_since_id(story1.id)).to be_empty
+    end
+
+    it "does not return unread stories with id less than given id" do
+      create(:story)
+      story2 = create(:story)
+
+      expect(described_class.unread_since_id(story2.id)).to be_empty
+    end
+  end
+
+  describe ".feed" do
+    it "returns stories for the given feed id" do
+      feed = create(:feed)
+      story = create(:story, feed:)
+
+      expect(described_class.feed(feed.id)).to eq([story])
+    end
+
+    it "sorts stories by published" do
+      feed = create(:feed)
+      story1 = create(:story, feed:, published: 1.day.ago)
+      story2 = create(:story, feed:, published: 1.hour.ago)
+
+      expect(described_class.feed(feed.id)).to eq([story2, story1])
+    end
+
+    it "does not return stories for other feeds" do
+      feed = create(:feed)
+      create(:story)
+
+      expect(described_class.feed(feed.id)).to be_empty
+    end
+  end
+
+  describe ".read" do
+    it "returns read stories" do
+      story = create(:story, :read)
+
+      expect(described_class.read).to eq([story])
+    end
+
+    it "sorts stories by published" do
+      story1 = create(:story, :read, published: 1.day.ago)
+      story2 = create(:story, :read, published: 1.hour.ago)
+
+      expect(described_class.read).to eq([story2, story1])
+    end
+
+    it "does not return unread stories" do
+      create(:story)
+
+      expect(described_class.read).to be_empty
+    end
+
+    it "paginates results" do
+      stories =
+        21.times.map { |num| create(:story, :read, published: num.days.ago) }
+
+      expect(described_class.read).to eq(stories[0...20])
+      expect(described_class.read(2)).to eq([stories.last])
+    end
+  end
+
+  describe ".starred" do
+    it "returns starred stories" do
+      story = create(:story, :starred)
+
+      expect(described_class.starred).to eq([story])
+    end
+
+    it "sorts stories by published" do
+      story1 = create(:story, :starred, published: 1.day.ago)
+      story2 = create(:story, :starred, published: 1.hour.ago)
+
+      expect(described_class.starred).to eq([story2, story1])
+    end
+
+    it "does not return unstarred stories" do
+      create(:story)
+
+      expect(described_class.starred).to be_empty
+    end
+
+    it "paginates results" do
+      stories =
+        21.times.map { |num| create(:story, :starred, published: num.days.ago) }
+
+      expect(described_class.starred).to eq(stories[0...20])
+      expect(described_class.starred(2)).to eq([stories.last])
+    end
+  end
+
+  describe ".unstarred_read_stories_older_than" do
+    it "returns unstarred read stories older than given number of days" do
+      story = create(:story, :read, published: 6.days.ago)
+
+      expect(described_class.unstarred_read_stories_older_than(5))
+        .to eq([story])
+    end
+
+    it "does not return starred stories older than the given number of days" do
+      create(:story, :read, :starred, published: 6.days.ago)
+
+      expect(described_class.unstarred_read_stories_older_than(5)).to be_empty
+    end
+
+    it "does not return unread stories older than the given number of days" do
+      create(:story, published: 6.days.ago)
+
+      expect(described_class.unstarred_read_stories_older_than(5)).to be_empty
+    end
+
+    it "does not return stories newer than given number of days" do
+      create(:story, :read, published: 4.days.ago)
+
+      expect(described_class.unstarred_read_stories_older_than(5)).to be_empty
+    end
+  end
+
+  describe ".read_count" do
+    it "returns the count of read stories" do
+      create(:story, :read)
+      create(:story, :read)
+      create(:story, :read)
+
+      expect(described_class.read_count).to eq(3)
+    end
+
+    it "does not count unread stories" do
+      create_list(:story, 3)
+
+      expect(described_class.read_count).to eq(0)
     end
   end
 
   describe ".extract_url" do
     it "returns the url" do
       feed = double(url: "http://github.com")
-      entry = double(url: "https://github.com/swanson/stringer")
+      entry = double(url: "https://github.com/stringer-rss/stringer")
 
-      expect(StoryRepository.extract_url(entry, feed)).to eq "https://github.com/swanson/stringer"
+      expect(described_class.extract_url(entry, feed)).to eq("https://github.com/stringer-rss/stringer")
     end
 
     it "returns the enclosure_url when the url is nil" do
       feed = double(url: "http://github.com")
-      entry = double(url: nil, enclosure_url: "https://github.com/swanson/stringer")
+      entry = double(url: nil, enclosure_url: "https://github.com/stringer-rss/stringer")
 
-      expect(StoryRepository.extract_url(entry, feed)).to eq "https://github.com/swanson/stringer"
+      expect(described_class.extract_url(entry, feed)).to eq("https://github.com/stringer-rss/stringer")
     end
-  end
 
-  describe ".extract_title" do
-    let(:entry) do
+    it "does not crash if url is nil but enclosure_url does not exist" do
+      feed = double(url: "http://github.com")
+      entry = double(url: nil)
+
+      expect(described_class.extract_url(entry, feed)).to be_nil
     end
+  end
 
+  describe ".extract_title" do
     it "returns the title if there is a title" do
       entry = double(title: "title", summary: "summary")
 
-      expect(StoryRepository.extract_title(entry)).to eq "title"
+      expect(described_class.extract_title(entry)).to eq("title")
     end
 
     it "returns the summary if there isn't a title" do
       entry = double(title: "", summary: "summary")
 
-      expect(StoryRepository.extract_title(entry)).to eq "summary"
+      expect(described_class.extract_title(entry)).to eq("summary")
     end
   end
 
   describe ".extract_content" do
-    let(:entry) do
-      double(url: "http://mdswanson.com",
-             content: "Some test content<script></script>")
+    it "sanitizes content" do
+      entry = double(
+        url: "http://mdswanson.com",
+        content: "Some test content<script></script>"
+      )
+      expect(described_class.extract_content(entry)).to eq("Some test content")
     end
 
-    let(:summary_only) do
-      double(url: "http://mdswanson.com",
-             content: nil,
-             summary: "Dumb publisher")
-    end
+    it "falls back to summary if there is no content" do
+      url = "http://mdswanson.com"
+      summary_only = double(url:, content: nil, summary: "Dumb publisher")
 
-    it "sanitizes content" do
-      expect(StoryRepository.extract_content(entry)).to eq "Some test content"
+      expect(described_class.extract_content(summary_only))
+        .to eq("Dumb publisher")
     end
 
-    it "falls back to summary if there is no content" do
-      expect(StoryRepository.extract_content(summary_only)).to eq "Dumb publisher"
+    it "returns empty string if there is no content or summary" do
+      entry = double(url: "http://mdswanson.com", content: nil, summary: nil)
+
+      expect(described_class.extract_content(entry)).to eq("")
     end
-  end
 
-  describe ".sanitize" do
-    context "regressions" do
-      it "handles <wbr> tag properly" do
-        result = StoryRepository.sanitize("<code>WM_<wbr\t\n >ERROR</code> asdf")
-        expect(result).to eq "<code>WM_ERROR</code> asdf"
-      end
+    it "expands urls" do
+      entry = double(
+        url: "http://mdswanson.com",
+        content: nil,
+        summary: "<a href=\"page\">Page</a>"
+      )
 
-      it "handles <figure> tag properly" do
-        result = StoryRepository.sanitize("<figure>some code</figure>")
-        expect(result).to eq "<figure>some code</figure>"
-      end
+      expect(described_class.extract_content(entry))
+        .to eq("<a href=\"http://mdswanson.com/page\">Page</a>")
+    end
 
-      it "handles unprintable characters" do
-        result = StoryRepository.sanitize("n\u2028\u2029")
-        expect(result).to eq "n"
-      end
+    it "ignores URL expansion if entry url is nil" do
+      entry =
+        double(url: nil, content: nil, summary: "<a href=\"page\">Page</a>")
 
-      it "preserves line endings" do
-        result = StoryRepository.sanitize("test\r\ncase")
-        expect(result).to eq "test\r\ncase"
-      end
+      expect(described_class.extract_content(entry))
+        .to eq("<a href=\"page\">Page</a>")
     end
   end
 end
diff --git a/spec/repositories/user_repository_spec.rb b/spec/repositories/user_repository_spec.rb
new file mode 100644
index 000000000..c919efcfd
--- /dev/null
+++ b/spec/repositories/user_repository_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+RSpec.describe UserRepository do
+  describe ".fetch" do
+    it "returns nil when given id is nil" do
+      expect(described_class.fetch(nil)).to be_nil
+    end
+
+    it "returns the user for the given id" do
+      user = default_user
+
+      expect(described_class.fetch(user.id)).to eq(user)
+    end
+  end
+
+  describe ".setup_complete?" do
+    it "returns false when there are no users" do
+      expect(described_class.setup_complete?).to be(false)
+    end
+
+    it "returns true when there is at least one user" do
+      create(:user)
+
+      expect(described_class.setup_complete?).to be(true)
+    end
+  end
+
+  describe ".save" do
+    it "saves the given user" do
+      user = build(:user)
+
+      expect { described_class.save(user) }
+        .to change(user, :persisted?).from(false).to(true)
+    end
+
+    it "returns the given user" do
+      user = User.new
+
+      expect(described_class.save(user)).to eq(user)
+    end
+  end
+
+  describe ".first" do
+    it "returns the first user" do
+      user = default_user
+      create(:user)
+
+      expect(described_class.first).to eq(user)
+    end
+  end
+end
diff --git a/spec/requests/debug_controller_spec.rb b/spec/requests/debug_controller_spec.rb
new file mode 100644
index 000000000..196373dc1
--- /dev/null
+++ b/spec/requests/debug_controller_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+RSpec.describe DebugController do
+  describe "#debug" do
+    def setup
+      login_as(create(:user, admin: true))
+
+      expect(MigrationStatus)
+        .to receive(:call)
+        .and_return(["Migration B - 2", "Migration C - 3"])
+    end
+
+    it "displays an admin settings link" do
+      setup
+
+      get("/admin/debug")
+
+      expect(rendered).to have_link("Admin Settings", href: settings_path)
+    end
+
+    it "displays the current Ruby version" do
+      setup
+
+      get "/admin/debug"
+
+      expect(rendered).to have_css("dd", text: /#{RUBY_VERSION}/)
+    end
+
+    it "displays the user agent" do
+      setup
+
+      get("/admin/debug", headers: { "HTTP_USER_AGENT" => "testy" })
+
+      expect(rendered).to have_css("dd", text: /testy/)
+    end
+
+    it "displays the jobs count" do
+      setup
+      12.times { GoodJob::Job.create!(scheduled_at: Time.zone.now) }
+
+      get "/admin/debug"
+
+      expect(rendered).to have_css("dd", text: /12/)
+    end
+
+    it "displays pending migrations" do
+      setup
+
+      get "/admin/debug"
+
+      expect(rendered).to have_css("li", text: /Migration B - 2/)
+        .and have_css("li", text: /Migration C - 3/)
+    end
+  end
+
+  describe "#heroku" do
+    it "displays Heroku instructions" do
+      get("/heroku")
+
+      expect(rendered).to have_text("add an hourly task")
+    end
+  end
+end
diff --git a/spec/requests/exports_controller_spec.rb b/spec/requests/exports_controller_spec.rb
new file mode 100644
index 000000000..e83937798
--- /dev/null
+++ b/spec/requests/exports_controller_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+RSpec.describe ExportsController do
+  describe "GET /feeds/export" do
+    def expected_xml
+      <<~XML
+        <?xml version="1.0"?>
+        <opml version="1.0">
+          <head>
+            <title>Feeds from Stringer</title>
+          </head>
+          <body/>
+        </opml>
+      XML
+    end
+
+    it "returns an OPML file" do
+      login_as(default_user)
+
+      get "/feeds/export"
+
+      expect(response.body).to eq(expected_xml)
+    end
+
+    it "responds with xml content type" do
+      login_as(default_user)
+
+      get "/feeds/export"
+
+      expect(response.header["Content-Type"]).to include("application/xml")
+    end
+
+    it "responds with disposition attachment" do
+      login_as(default_user)
+
+      get "/feeds/export"
+
+      expected =
+        "attachment; filename=\"stringer.opml\"; filename*=UTF-8''stringer.opml"
+      expect(response.header["Content-Disposition"]).to eq(expected)
+    end
+  end
+end
diff --git a/spec/requests/feeds_controller_spec.rb b/spec/requests/feeds_controller_spec.rb
new file mode 100644
index 000000000..2975a23d3
--- /dev/null
+++ b/spec/requests/feeds_controller_spec.rb
@@ -0,0 +1,163 @@
+# frozen_string_literal: true
+
+RSpec.describe FeedsController do
+  include ActiveJob::TestHelper
+
+  describe "#index" do
+    it "renders a list of feeds" do
+      login_as(default_user)
+      create_pair(:feed)
+
+      get "/feeds"
+
+      expect(rendered).to have_css("li.feed", count: 2)
+    end
+
+    it "displays message to add feeds if there are none" do
+      login_as(default_user)
+
+      get "/feeds"
+
+      expect(rendered).to have_css("#add-some-feeds")
+    end
+  end
+
+  describe "#show" do
+    it "displays a list of stories" do
+      login_as(default_user)
+      story = create(:story)
+
+      get "/feed/#{story.feed_id}"
+
+      expect(rendered).to have_css("#stories")
+    end
+
+    it "raises an error if the feed belongs to another user" do
+      login_as(create(:user))
+      feed = create(:feed)
+
+      expect { get("/feed/#{feed.id}") }
+        .to raise_error(Authorization::NotAuthorizedError)
+    end
+  end
+
+  describe "#edit" do
+    it "displays the feed edit form" do
+      login_as(default_user)
+      feed = create(:feed, name: "Rainbows/unicorns", url: "example.com/feed")
+
+      get "/feeds/#{feed.id}/edit"
+
+      expect(rendered).to have_field("feed_name", with: "Rainbows/unicorns")
+    end
+  end
+
+  describe "#update" do
+    def params(feed, **overrides)
+      {
+        feed_id: feed.id,
+        feed_name: feed.name,
+        feed_url: feed.url,
+        group_id: feed.group_id,
+        **overrides
+      }
+    end
+
+    it "updates a feed given the id" do
+      login_as(default_user)
+      feed = create(:feed, url: "example.com/atom", id: "12", group_id: nil)
+
+      feed_url = "example.com/feed"
+
+      expect { put("/feeds/#{feed.id}", params: params(feed, feed_url:)) }
+        .to change_record(feed, :url).to(feed_url)
+    end
+
+    it "updates a feed group given the id" do
+      login_as(default_user)
+      feed = create(:feed, url: "example.com/atom")
+
+      expect { put("/feeds/#{feed.id}", params: params(feed, group_id: 321)) }
+        .to change_record(feed, :group_id).to(321)
+    end
+  end
+
+  describe "#destroy" do
+    it "deletes a feed given the id" do
+      login_as(default_user)
+      feed = create(:feed)
+
+      expect { delete("/feeds/#{feed.id}") }.to delete_record(feed)
+    end
+  end
+
+  describe "#new" do
+    it "displays a new feed form" do
+      login_as(default_user)
+
+      get "/feeds/new"
+
+      expect(rendered).to have_css("form#add-feed-setup")
+    end
+  end
+
+  def with_test_adapter
+    adapter = ActiveJob::Base.queue_adapter
+    ActiveJob::Base.queue_adapter = :test
+
+    yield
+
+    ActiveJob::Base.queue_adapter = adapter
+  end
+
+  describe "#create" do
+    context "when the feed url is valid" do
+      feed_url = "http://example.com/"
+
+      around { |example| with_test_adapter(&example) }
+
+      it "adds the feed and queues it to be fetched" do
+        login_as(default_user)
+        stub_request(:get, feed_url).to_return(status: 200, body: "<rss></rss>")
+
+        expect { post("/feeds", params: { feed_url: }) }
+          .to change(Feed, :count).by(1)
+      end
+
+      it "queues the feed to be fetched" do
+        login_as(default_user)
+        stub_request(:get, feed_url).to_return(status: 200, body: "<rss></rss>")
+
+        expect { post("/feeds", params: { feed_url: }) }
+          .to enqueue_job(CallableJob).with(Feed::FetchOne, instance_of(Feed))
+      end
+    end
+
+    context "when the feed url is invalid" do
+      feed_url = "http://not-a-valid-feed.com/"
+
+      it "does not add the feed" do
+        login_as(default_user)
+        stub_request(:get, feed_url).to_return(status: 404)
+        expect($stderr).to receive(:puts).with(/Error occurred/)
+        post("/feeds", params: { feed_url: })
+
+        expect(rendered).to have_css(".error")
+      end
+    end
+
+    context "when the feed url is one we already subscribe to" do
+      feed_url = "http://example.com/"
+
+      it "does not add the feed" do
+        login_as(default_user)
+        create(:feed, url: feed_url)
+        stub_request(:get, feed_url).to_return(status: 200, body: "<rss></rss>")
+
+        post("/feeds", params: { feed_url: })
+
+        expect(rendered).to have_css(".error")
+      end
+    end
+  end
+end
diff --git a/spec/requests/fever_controller_spec.rb b/spec/requests/fever_controller_spec.rb
new file mode 100644
index 000000000..2ad3ad2d7
--- /dev/null
+++ b/spec/requests/fever_controller_spec.rb
@@ -0,0 +1,236 @@
+# frozen_string_literal: true
+
+RSpec.describe FeverController do
+  def standard_answer
+    { api_version: 3, auth: 1, last_refreshed_on_time: 0 }
+  end
+
+  def cannot_auth
+    { api_version: 3, auth: 0 }
+  end
+
+  def response_as_object
+    JSON.parse(response.body, symbolize_names: true)
+  end
+
+  def params(user: default_user, **overrides)
+    { api_key: user.api_key, **overrides }
+  end
+
+  describe "authentication" do
+    it "authenticates request with correct api_key" do
+      get("/fever", params:)
+
+      expect(response_as_object).to include(standard_answer)
+    end
+
+    it "does not authenticate request with incorrect api_key" do
+      get "/fever", params: params(api_key: "foo")
+
+      expect(response_as_object).to include(cannot_auth)
+    end
+
+    it "does not authenticate request when api_key is not provided" do
+      create(:user)
+
+      get "/fever", params: params(api_key: nil)
+
+      expect(response_as_object).to include(cannot_auth)
+    end
+  end
+
+  describe "#get" do
+    it "returns standard answer" do
+      get("/fever", params:)
+
+      expect(response_as_object).to include(standard_answer)
+    end
+
+    context "when 'groups' header is provided" do
+      it "returns only ungrouped feedsin the ungrouped group" do
+        feed = create(:feed)
+
+        get("/fever", params: params(groups: nil))
+
+        expect_groups_response([Group::UNGROUPED], [feed])
+      end
+
+      it "returns only grouped feeds in their respective groups" do
+        grouped_feed = create(:feed, :with_group)
+
+        get("/fever", params: params(groups: nil))
+
+        groups = [Group::UNGROUPED, grouped_feed.group]
+        expect_groups_response(groups, [grouped_feed])
+      end
+
+      it "returns grouped and ungrouped feeds by groups" do
+        feed = create(:feed)
+        grouped_feed = create(:feed, :with_group)
+
+        get("/fever", params: params(groups: nil))
+
+        groups = [Group::UNGROUPED, grouped_feed.group]
+        expect_groups_response(groups, [feed, grouped_feed])
+      end
+
+      def expect_groups_response(groups, feeds_groups)
+        feeds_groups =
+          feeds_groups.map do |feed|
+            { group_id: feed.group_id || 0, feed_ids: feed.id.to_s }
+          end
+        expect(response_as_object).to include(standard_answer)
+          .and include(
+            groups: match_array(groups.map(&:as_fever_json)),
+            feeds_groups: match_array(feeds_groups)
+          )
+      end
+    end
+
+    it "returns feeds and feeds by groups when 'feeds' header is provided" do
+      feed = create(:feed, :with_group)
+
+      get("/fever", params: params(feeds: nil))
+
+      groups = [{ group_id: feed.group_id, feed_ids: feed.id.to_s }]
+      expect(response_as_object).to include(standard_answer)
+        .and include(feeds: [feed.as_fever_json], feeds_groups: groups)
+    end
+
+    it "returns favicons hash when 'favicons' header provided" do
+      get("/fever", params: params(favicons: nil))
+
+      favicon = { id: 0, data: a_string_including("image/gif;base64") }
+      expect(response_as_object).to include(standard_answer)
+        .and include(favicons: [favicon])
+    end
+
+    it "returns stories when 'items' and 'since_id'" do
+      create(:story, id: 5)
+      story_two = create(:story, id: 6)
+
+      get("/fever", params: params(items: nil, since_id: 5))
+
+      expect(response_as_object).to include(standard_answer)
+        .and include(items: [story_two.as_fever_json], total_items: 2)
+    end
+
+    it "returns stories when 'items' header is provided without 'since_id'" do
+      stories = create_pair(:story)
+
+      get("/fever", params: params(items: nil))
+
+      expect(response_as_object).to include(standard_answer)
+        .and include(items: stories.map(&:as_fever_json), total_items: 2)
+    end
+
+    it "returns stories ids when 'items' and 'with_ids'" do
+      story = create(:story)
+
+      get("/fever", params: params(items: nil, with_ids: story.id))
+
+      expect(response_as_object).to include(standard_answer)
+        .and include(items: [story.as_fever_json], total_items: 1)
+    end
+
+    it "returns links as empty array when 'links' header is provided" do
+      get("/fever", params: params(links: nil))
+
+      expect(response_as_object).to include(standard_answer)
+        .and include(links: [])
+    end
+
+    it "returns unread items ids when 'unread_item_ids' header is provided" do
+      stories = create_pair(:story)
+
+      get("/fever", params: params(unread_item_ids: nil))
+
+      expect(response_as_object).to include(standard_answer)
+        .and include(unread_item_ids: stories.map(&:id).join(","))
+    end
+
+    it "returns starred items when 'saved_item_ids' header is provided" do
+      stories = create_pair(:story, :starred)
+
+      get("/fever", params: params(saved_item_ids: nil))
+
+      expect(response_as_object).to include(standard_answer)
+        .and include(saved_item_ids: stories.map(&:id).join(","))
+    end
+  end
+
+  describe "#post" do
+    it "commands to mark story as read" do
+      story = create(:story)
+
+      post("/fever", params: params(mark: "item", as: "read", id: story.id))
+
+      expect(response_as_object).to include(standard_answer)
+    end
+
+    it "commands to mark story as unread" do
+      story = create(:story, :read)
+
+      post("/fever", params: params(mark: "item", as: "unread", id: story.id))
+
+      expect(response_as_object).to include(standard_answer)
+    end
+
+    it "commands to save story" do
+      story = create(:story)
+
+      post("/fever", params: params(mark: "item", as: "saved", id: story.id))
+
+      expect(response_as_object).to include(standard_answer)
+    end
+
+    it "commands to unsave story" do
+      story = create(:story, :starred)
+
+      post("/fever", params: params(mark: "item", as: "unsaved", id: story.id))
+
+      expect(response_as_object).to include(standard_answer)
+    end
+
+    it "commands to mark group as read" do
+      story = create(:story, :with_group, created_at: 1.week.ago)
+      before = Time.zone.now.to_i
+      id = story.feed.group_id
+
+      post("/fever", params: params(mark: "group", as: "read", id:, before:))
+
+      expect(response_as_object).to include(standard_answer)
+    end
+
+    it "commands to mark entire feed as read" do
+      story = create(:story, created_at: 1.week.ago)
+      before = Time.zone.now.to_i
+      params = params(mark: "feed", as: "read", id: story.feed_id, before:)
+
+      expect { post("/fever", params:) }
+        .to change_record(story, :is_read).from(false).to(true)
+    end
+
+    describe "#index" do
+      it "works with a trailing /" do
+        story = create(:story, created_at: 1.week.ago)
+        before = Time.zone.now.to_i
+        params = params(mark: "feed", as: "read", id: story.feed_id, before:)
+
+        expect { get("/fever/", params:) }
+          .to change_record(story, :is_read).from(false).to(true)
+      end
+    end
+
+    describe "#update" do
+      it "works with a trailing /" do
+        story = create(:story, created_at: 1.week.ago)
+        before = Time.zone.now.to_i
+        params = params(mark: "feed", as: "read", id: story.feed_id, before:)
+
+        expect { post("/fever/", params:) }
+          .to change_record(story, :is_read).from(false).to(true)
+      end
+    end
+  end
+end
diff --git a/spec/requests/imports_controller_spec.rb b/spec/requests/imports_controller_spec.rb
new file mode 100644
index 000000000..5fc561b59
--- /dev/null
+++ b/spec/requests/imports_controller_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+RSpec.describe ImportsController do
+  describe "GET /feeds/import" do
+    it "displays the import options" do
+      login_as(default_user)
+
+      get "/feeds/import"
+
+      expect(rendered).to have_field("opml_file")
+    end
+  end
+
+  describe "POST /feeds/import" do
+    opml_file = Rack::Test::UploadedFile.new(
+      "spec/sample_data/subscriptions.xml",
+      "application/xml"
+    )
+
+    it "parses OPML and starts fetching" do
+      expect(Feed::ImportFromOpml).to receive(:call).once
+      login_as(default_user)
+
+      post "/feeds/import", params: { opml_file: }
+    end
+  end
+end
diff --git a/spec/requests/passwords_controller_spec.rb b/spec/requests/passwords_controller_spec.rb
new file mode 100644
index 000000000..d6ab2f2c2
--- /dev/null
+++ b/spec/requests/passwords_controller_spec.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+RSpec.describe PasswordsController do
+  describe "#new" do
+    it "displays a form to enter your password" do
+      get "/setup/password"
+
+      expect(rendered).to have_css("form#password_setup")
+    end
+
+    it "redirects to the login page when signups are not enabled" do
+      create(:user)
+      Setting::UserSignup.update!(enabled: false)
+
+      get "/setup/password"
+
+      expect(response).to redirect_to(login_path)
+    end
+  end
+
+  describe "#create" do
+    it "rejects empty passwords" do
+      post "/setup/password",
+           params: { user: { password: "", password_confirmation: "" } }
+
+      expect(rendered).to have_text("Password can't be blank")
+    end
+
+    it "rejects when password isn't confirmed" do
+      post "/setup/password",
+           params: { user: { password: "foo", password_confirmation: "bar" } }
+
+      expect(rendered).to have_text("confirmation doesn't match")
+    end
+
+    it "accepts confirmed passwords and redirects to next step" do
+      user_params =
+        { username: "foo", password: "foo", password_confirmation: "foo" }
+
+      post "/setup/password", params: { user: user_params }
+
+      expect(response).to redirect_to("/feeds/import")
+    end
+
+    it "redirects to the login page when signups are not enabled" do
+      create(:user)
+      Setting::UserSignup.update!(enabled: false)
+
+      post "/setup/password"
+
+      expect(response).to redirect_to(login_path)
+    end
+  end
+
+  describe "#update" do
+    def params(old_password, new_password)
+      {
+        user: {
+          password_challenge: old_password,
+          password: new_password,
+          password_confirmation: new_password
+        }
+      }
+    end
+
+    context "when the password info is correct" do
+      it "updates the password" do
+        user = create(:user, password: "old_password")
+        login_as(user)
+
+        put("/password", params: params("old_password", "new_password"))
+
+        expect(user.reload.authenticate("new_password")).to eq(user)
+      end
+
+      it "redirects to the news page" do
+        user = create(:user, password: "old_password")
+        login_as(user)
+
+        put("/password", params: params("old_password", "new_password"))
+
+        expect(response).to redirect_to("/news")
+      end
+    end
+
+    context "when the password info is incorrect" do
+      it "displays an error message" do
+        user = create(:user, password: "old_password")
+        login_as(user)
+
+        put("/password", params: params("wrong_password", "new_password"))
+
+        expect(rendered).to have_text("Unable to update password")
+      end
+
+      it "renders the edit profile page" do
+        user = create(:user, password: "old_password")
+        login_as(user)
+
+        put("/password", params: params("wrong_password", "new_password"))
+
+        expect(rendered).to have_text("Edit profile")
+      end
+
+      it "doesn't update the password" do
+        user = create(:user, password: "old_password")
+        login_as(user)
+
+        put("/password", params: params("wrong_password", "new_password"))
+
+        expect(user.reload.authenticate("new_password")).to be_falsey
+      end
+    end
+  end
+end
diff --git a/spec/requests/profiles_controller_spec.rb b/spec/requests/profiles_controller_spec.rb
new file mode 100644
index 000000000..182412f86
--- /dev/null
+++ b/spec/requests/profiles_controller_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+RSpec.describe ProfilesController do
+  describe "#edit" do
+    it "displays the edit view" do
+      login_as(default_user)
+
+      get(edit_profile_path)
+
+      expect(rendered).to have_text("Edit profile")
+        .and have_field("Username", with: default_user.username)
+    end
+  end
+
+  describe "#update" do
+    context "when the user is valid" do
+      it "updates the user's username" do
+        login_as(default_user)
+        password_challenge = default_user.password
+        params = { user: { username: "new_username", password_challenge: } }
+
+        expect { patch(profile_path, params:) }
+          .to change_record(default_user, :username).to("new_username")
+      end
+
+      it "redirects to the news path" do
+        login_as(default_user)
+        password_challenge = default_user.password
+        params = { user: { username: "new_username", password_challenge: } }
+
+        patch(profile_path, params:)
+
+        expect(response).to redirect_to(news_path)
+      end
+    end
+
+    context "when the username is invalid" do
+      it "displays an error message" do
+        login_as(default_user)
+
+        patch(profile_path, params: { user: { username: "" } })
+
+        expect(rendered).to have_text("Username can't be blank")
+      end
+
+      it "displays the edit view" do
+        login_as(default_user)
+
+        patch(profile_path, params: { user: { username: "" } })
+
+        expect(rendered).to have_text("Edit profile")
+      end
+
+      it "does not update the user's username" do
+        login_as(default_user)
+
+        params = { user: { username: "" } }
+
+        expect { patch(profile_path, params:) }
+          .not_to change_record(default_user, :username)
+      end
+    end
+  end
+end
diff --git a/spec/requests/sessions_controller_spec.rb b/spec/requests/sessions_controller_spec.rb
new file mode 100644
index 000000000..876a1c8e0
--- /dev/null
+++ b/spec/requests/sessions_controller_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+RSpec.describe SessionsController do
+  describe "#new" do
+    it "has a password input and login button" do
+      create(:user)
+
+      get "/login"
+
+      expect(rendered).to have_field("password")
+    end
+  end
+
+  describe "#create" do
+    it "denies access when password is incorrect" do
+      user = create(:user)
+      params = { username: user.username, password: "not-the-password" }
+
+      post("/login", params:)
+
+      expect(rendered).to have_css(".error")
+    end
+
+    it "allows access when password is correct" do
+      user = default_user
+      params = { username: user.username, password: user.password }
+
+      post("/login", params:)
+
+      expect(session[:user_id]).to eq(user.id)
+    end
+
+    it "redirects to the root page" do
+      user = default_user
+      params = { username: user.username, password: user.password }
+
+      post("/login", params:)
+
+      expect(URI.parse(response.location).path).to eq("/")
+    end
+
+    it "redirects to the previous path when present" do
+      user = default_user
+      params = { username: user.username, password: user.password }
+      get("/archive")
+
+      post("/login", params:)
+
+      expect(URI.parse(response.location).path).to eq("/archive")
+    end
+  end
+
+  describe "#destroy" do
+    it "clears the session" do
+      login_as(default_user)
+
+      get "/logout"
+
+      expect(session[:user_id]).to be_nil
+    end
+
+    it "redirects to the root page" do
+      login_as(default_user)
+
+      get "/logout"
+
+      expect(URI.parse(response.location).path).to eq("/")
+    end
+  end
+end
diff --git a/spec/requests/settings_controller_spec.rb b/spec/requests/settings_controller_spec.rb
new file mode 100644
index 000000000..87a1c819a
--- /dev/null
+++ b/spec/requests/settings_controller_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+RSpec.describe SettingsController do
+  describe "#index" do
+    it "displays the settings page" do
+      login_as(create(:user, admin: true))
+
+      get(settings_path)
+
+      expect(rendered).to have_css("h1", text: "Settings")
+        .and have_text("User signups are disabled")
+    end
+  end
+
+  describe "#update" do
+    it "allows enabling account creation" do
+      login_as(create(:user, admin: true))
+
+      params = { setting: { enabled: "true" } }
+      put(setting_path(Setting::UserSignup.first), params:)
+
+      expect(Setting::UserSignup.enabled?).to be(true)
+    end
+  end
+end
diff --git a/spec/requests/stories_controller_spec.rb b/spec/requests/stories_controller_spec.rb
new file mode 100644
index 000000000..210385dc8
--- /dev/null
+++ b/spec/requests/stories_controller_spec.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+RSpec.describe StoriesController do
+  describe "GET /news" do
+    it "redirects to the setup page when no user exists" do
+      get "/news"
+
+      expect(URI.parse(response.location).path).to eq("/setup/password")
+    end
+
+    it "redirects to the login page if not logged in" do
+      create(:user)
+
+      get "/news"
+
+      expect(URI.parse(response.location).path).to eq("/login")
+    end
+
+    it "display list of unread stories" do
+      login_as(default_user)
+      create(:story)
+
+      get "/news"
+
+      expect(rendered).to have_css("#stories")
+    end
+
+    it "displays the blog title and article title" do
+      login_as(default_user)
+      story = create(:story)
+
+      get "/news"
+
+      expect(rendered).to have_text(story.headline)
+    end
+
+    it "displays all user actions" do
+      login_as(default_user)
+      create(:story)
+
+      get "/news"
+
+      expect(rendered).to have_css("#mark-all")
+    end
+
+    it "has correct footer links" do
+      login_as(default_user)
+      create(:story)
+
+      get "/news"
+
+      expect(rendered).to have_link("Export").and have_link("Logout")
+    end
+
+    it "displays a zen-like message when there are no unread stories" do
+      login_as(default_user)
+
+      get "/news"
+
+      expect(rendered).to have_css("#zen")
+    end
+  end
+
+  describe "#archived" do
+    it "displays the list of read stories with pagination" do
+      login_as(default_user)
+      create(:story, :read)
+
+      get "/archive"
+
+      expect(rendered).to have_css("#stories")
+    end
+  end
+
+  describe "#starred" do
+    it "displays the list of starred stories" do
+      login_as(default_user)
+      create(:story, :starred)
+
+      get "/starred"
+
+      expect(rendered).to have_css("#stories")
+    end
+  end
+
+  describe "#update" do
+    headers = { "CONTENT_TYPE" => "application/json" }
+
+    it "marks a story as read when it is_read not malformed" do
+      login_as(default_user)
+      story = create(:story)
+      params = { is_read: true }.to_json
+
+      expect { put("/stories/#{story.id}", params:, headers:) }
+        .to change_record(story, :is_read).from(false).to(true)
+    end
+
+    it "marks a story as read when is_read is malformed" do
+      login_as(default_user)
+      story = create(:story)
+      params = { is_read: "malformed" }.to_json
+
+      expect { put("/stories/#{story.id}", params:, headers:) }
+        .to change_record(story, :is_read).from(false).to(true)
+    end
+
+    it "marks a story as keep unread when it keep_unread not malformed" do
+      login_as(default_user)
+      story = create(:story)
+      params = { keep_unread: true }.to_json
+
+      expect { put("/stories/#{story.id}", params:, headers:) }
+        .to change_record(story, :keep_unread).from(false).to(true)
+    end
+
+    it "marks a story as keep unread when keep_unread is malformed" do
+      login_as(default_user)
+      story = create(:story)
+      params = { keep_unread: "malformed" }.to_json
+
+      expect { put("/stories/#{story.id}", params:, headers:) }
+        .to change_record(story, :keep_unread).from(false).to(true)
+    end
+
+    it "marks a story as starred when is_starred is not malformed" do
+      login_as(default_user)
+      story = create(:story)
+      params = { is_starred: true }.to_json
+
+      expect { put("/stories/#{story.id}", params:, headers:) }
+        .to change_record(story, :is_starred).from(false).to(true)
+    end
+
+    it "marks a story as starred when is_starred is malformed" do
+      login_as(default_user)
+      story = create(:story)
+      params = { is_starred: "malformed" }.to_json
+
+      expect { put("/stories/#{story.id}", params:, headers:) }
+        .to change_record(story, :is_starred).from(false).to(true)
+    end
+  end
+
+  describe "#mark_all_as_read" do
+    it "marks all unread stories as read and reload the page" do
+      login_as(default_user)
+      stories = create_pair(:story)
+      params = { story_ids: stories.map(&:id) }
+
+      expect { post("/stories/mark_all_as_read", params:) }
+        .to change_all_records(stories, :is_read).from(false).to(true)
+    end
+  end
+end
diff --git a/spec/requests/tutorials_controller_spec.rb b/spec/requests/tutorials_controller_spec.rb
new file mode 100644
index 000000000..f5cc80b1a
--- /dev/null
+++ b/spec/requests/tutorials_controller_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+RSpec.describe TutorialsController do
+  describe "#index" do
+    context "when a user has not been setup" do
+      it "displays the tutorial and completes setup" do
+        login_as(default_user)
+
+        get "/setup/tutorial"
+
+        expect(rendered).to have_css("#mark-all-instruction")
+      end
+    end
+  end
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index f2799081b..2783a10a9 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,36 +1,74 @@
-ENV["RACK_ENV"] = "test"
+# frozen_string_literal: true
 
-require "rspec"
-require "rspec-html-matchers"
-require "rack/test"
-require "pry"
-require "faker"
-require "ostruct"
-require "date"
+RSpec.configure do |config|
+  config.expect_with(:rspec) do |expectations|
+    # This option will default to `true` in RSpec 4. It makes the `description`
+    # and `failure_message` of custom matchers include text for helper methods
+    # defined using `chain`, e.g.:
+    #     be_bigger_than(2).and_smaller_than(4).description
+    #     # => "be bigger than 2 and smaller than 4"
+    # ...rather than:
+    #     # => "be bigger than 2"
+    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
+  end
 
-require "coveralls"
-Coveralls.wear!
+  config.mock_with(:rspec) do |mocks|
+    # Prevents you from mocking or stubbing a method that does not exist on
+    # a real object. This is generally recommended, and will default to
+    # `true` in RSpec 4.
+    mocks.verify_partial_doubles = true
+  end
 
-require "factories/feed_factory"
-require "factories/story_factory"
-require "factories/user_factory"
-require "factories/group_factory"
+  # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
+  # have no way to turn it off -- the option exists only for backwards
+  # compatibility in RSpec 3). It causes shared context metadata to be
+  # inherited by the metadata hash of host groups and examples, rather than
+  # triggering implicit auto-inclusion in groups with matching metadata.
+  config.shared_context_metadata_behavior = :apply_to_host_groups
 
-require "./app"
+  # The settings below are suggested to provide a good initial experience
+  # with RSpec, but feel free to customize to your heart's content.
+  # This allows you to limit a spec run to individual examples or groups
+  # you care about by tagging them with `:focus` metadata. When nothing
+  # is tagged with `:focus`, all examples get run. RSpec also provides
+  # aliases for `it`, `describe`, and `context` that include `:focus`
+  # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
+  config.filter_run_when_matching(:focus)
 
-RSpec.configure do |config|
-  config.include Rack::Test::Methods
-  config.include RSpecHtmlMatchers
-end
+  # Allows RSpec to persist some state between runs in order to support
+  # the `--only-failures` and `--next-failure` CLI options. We recommend
+  # you configure your source control system to ignore this file.
+  config.example_status_persistence_file_path = "spec/examples.txt"
 
-def app_require(file)
-  require File.expand_path(File.join("app", file))
-end
+  # Limits the available syntax to the non-monkey patched syntax that is
+  # recommended. For more details, see:
+  # https://relishapp.com/rspec/rspec-core/docs/configuration/zero-monkey-patching-mode
+  config.disable_monkey_patching!
 
-def app
-  Stringer
-end
+  # Many RSpec users commonly either run the entire suite or an individual
+  # file, and it's useful to allow more verbose output when running an
+  # individual spec file.
+  if config.files_to_run.one?
+    # Use the documentation formatter for detailed output,
+    # unless a formatter has already been configured
+    # (e.g. via a command-line flag).
+    config.default_formatter = "doc"
+  end
+
+  # Print the 10 slowest examples and example groups at the
+  # end of the spec run, to help surface which specs are running
+  # particularly slow.
+  config.profile_examples = 10
+
+  # Run specs in random order to surface order dependencies. If you find an
+  # order dependency and want to debug it, you can fix the order by providing
+  # the seed, which is printed after each run.
+  #     --seed 1234
+  config.order = :random
 
-def session
-  last_request.env["rack.session"]
+  # Seed global randomization in this process using the `--seed` CLI option.
+  # Setting this allows you to use `--seed` to deterministically reproduce
+  # test failures related to randomization by passing the same `--seed` value
+  # as the one that triggered the failure.
+  Kernel.srand(config.seed)
 end
diff --git a/spec/support/active_record.rb b/spec/support/active_record.rb
deleted file mode 100644
index 6e13deec9..000000000
--- a/spec/support/active_record.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-require "active_record"
-
-db_config = YAML.load(File.read("config/database.yml"))
-ActiveRecord::Base.establish_connection(db_config["test"])
-ActiveRecord::Base.logger = Logger.new("log/test.log")
-
-def need_to_migrate?
-  ActiveRecord::Migrator.new(:up, ActiveRecord::Migrator.migrations("db/migrate")).pending_migrations.any?
-end
-
-ActiveRecord::Migrator.up "db/migrate" if need_to_migrate?
-
-RSpec.configure do |config|
-  config.around do |example|
-    ActiveRecord::Base.transaction do
-      example.run
-      raise ActiveRecord::Rollback
-    end
-  end
-end
diff --git a/spec/support/axe_core.rb b/spec/support/axe_core.rb
new file mode 100644
index 000000000..42d48805d
--- /dev/null
+++ b/spec/support/axe_core.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require "axe-rspec"
+
+module AccessibilityOverrides
+  A11Y_SKIP = [
+    "aria-required-children",
+    "color-contrast",
+    "landmark-one-main",
+    "page-has-heading-one",
+    "region"
+  ].freeze
+
+  def visit(*, accessible: true, a11y_skip: A11Y_SKIP)
+    page.visit(*)
+
+    yield if block_given?
+
+    check_accessibility(a11y_skip:) if accessible
+  end
+
+  def click_on(*, a11y_skip: A11Y_SKIP, **)
+    page.click_on(*, **)
+
+    yield if block_given?
+
+    check_accessibility(a11y_skip:)
+  end
+
+  def check_accessibility(a11y_skip:)
+    within_window(current_window) do
+      expect(page).to have_css("div")
+      expect(page).to be_axe_clean.skipping(*a11y_skip)
+    end
+  end
+end
+
+RSpec.configure do |config|
+  config.include(AccessibilityOverrides, type: :system)
+end
diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb
new file mode 100644
index 000000000..39cbb2b22
--- /dev/null
+++ b/spec/support/capybara.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require "capybara/rails"
+
+Capybara.enable_aria_label = true
+
+Selenium::WebDriver.logger.output = Rails.root.join("log/selenium.log")
+
+RSpec.configure do |config|
+  config.before(:each, type: :system) do
+    driven_by(:selenium, using: :firefox) do |driver|
+      driver.add_preference("browser.download.folderList", 2)
+      driver.add_preference("browser.download.manager.showWhenStarting", false)
+      driver.add_preference("browser.download.dir", Downloads::PATH.to_s)
+      driver.add_preference(
+        "browser.helperApps.neverAsk.saveToDisk",
+        "application/xml"
+      )
+    end
+  end
+end
diff --git a/spec/support/coverage.rb b/spec/support/coverage.rb
new file mode 100644
index 000000000..9f585690c
--- /dev/null
+++ b/spec/support/coverage.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+return if ENV["COVERAGE"] == "false"
+
+require "simplecov"
+
+if ENV["CI"]
+  require "coveralls"
+  SimpleCov.formatter = Coveralls::SimpleCov::Formatter
+end
+
+SimpleCov.start(:rails) do
+  add_group("Commands", "app/commands")
+  add_group("Fever API", "app/fever_api")
+  add_group("Repositories", "app/repositories")
+  add_group("Tasks", "app/tasks")
+  add_group("Utils", "app/utils")
+  enable_coverage :branch
+end
+SimpleCov.minimum_coverage(line: 100, branch: 100)
diff --git a/spec/support/downloads.rb b/spec/support/downloads.rb
new file mode 100644
index 000000000..7dabe5b76
--- /dev/null
+++ b/spec/support/downloads.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Downloads
+  PATH = Rails.root.join("tmp/downloads")
+
+  class << self
+    def clear
+      FileUtils.rm_f(downloads)
+    end
+
+    def content_for(page, filename)
+      page.document.synchronize(errors: [Errno::ENOENT]) do
+        File.read(PATH.join(filename))
+      end
+    end
+
+    private
+
+    def downloads
+      Dir[PATH.join("*")]
+    end
+  end
+end
+
+RSpec.configure do |config|
+  config.after(:each, type: :system) { Downloads.clear }
+end
diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb
new file mode 100644
index 000000000..3d59d0734
--- /dev/null
+++ b/spec/support/factory_bot.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require "factory_bot"
+FactoryBot.find_definitions
+
+module FactoryCache
+  def self.user
+    @user ||= FactoryBot.create(:user)
+  end
+
+  def self.reset
+    @user = nil
+  end
+end
+
+RSpec.configure do |config|
+  config.include(FactoryBot::Syntax::Methods)
+
+  config.after do
+    FactoryBot.rewind_sequences
+    FactoryCache.reset
+  end
+end
+
+module FactoryBot::Syntax::Methods
+  def default_user
+    FactoryCache.user
+  end
+end
diff --git a/spec/support/feed_server.rb b/spec/support/feed_server.rb
index 502c9b338..0fd60f524 100644
--- a/spec/support/feed_server.rb
+++ b/spec/support/feed_server.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
 class FeedServer
   attr_writer :response
 
diff --git a/spec/support/files/subscriptions.xml b/spec/support/files/subscriptions.xml
index bb44ada99..740faf24f 100755
--- a/spec/support/files/subscriptions.xml
+++ b/spec/support/files/subscriptions.xml
@@ -23,5 +23,9 @@
             <outline text="igvita.com" title="igvita.com" type="rss"
                 xmlUrl="http://www.igvita.com/feed/" htmlUrl="http://www.igvita.com"/>
         </outline>
+        <outline title="Empty Group" text="Empty Group">
+        </outline>
+        <outline type="rss" xmlUrl="http://feeds.macrumors.com/MacRumors-Front"/>
+        <outline type="rss" xmlUrl="http://deadfeed.example.com/feed.rss"/>
     </body>
 </opml>
diff --git a/spec/support/generate_xml.rb b/spec/support/generate_xml.rb
new file mode 100644
index 000000000..659903869
--- /dev/null
+++ b/spec/support/generate_xml.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module GenerateXml
+  class << self
+    def call(feed, items)
+      build_feed(feed, items).to_xml
+    end
+
+    private
+
+    def build_feed(feed, items)
+      Nokogiri::XML::Builder.new do |xml|
+        xml.rss(version: "2.0") do
+          xml.title(feed.name)
+          xml.link(feed.url)
+          items.each { |item| build_item(xml, item) }
+        end
+      end
+    end
+
+    def build_item(xml, item)
+      xml.item do
+        xml.title(item.title)
+        xml.link(item.url)
+        xml.pubDate(item.published)
+        xml.content(item.content)
+      end
+    end
+  end
+end
diff --git a/spec/support/matchers.rb b/spec/support/matchers.rb
new file mode 100644
index 000000000..27e32daa7
--- /dev/null
+++ b/spec/support/matchers.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Matchers
+  def change_all_records(records, attribute)
+    Matchers::ChangeAllRecords.new(records, attribute)
+  end
+
+  def change_record(record, attribute)
+    Matchers::ChangeRecord.new(record, attribute)
+  end
+
+  def delete_record(record)
+    Matchers::DeleteRecord.new(record)
+  end
+
+  def invoke(expected_method)
+    Matchers::Invoke.new(expected_method)
+  end
+end
+
+RSpec.configure { |config| config.include(Matchers) }
+
+RSpec::Matchers.define_negated_matcher(:not_change, :change)
+RSpec::Matchers.define_negated_matcher(:not_change_record, :change_record)
+RSpec::Matchers.define_negated_matcher(:not_delete_record, :delete_record)
+RSpec::Matchers.define_negated_matcher(:not_raise_error, :raise_error)
+
+Dir[File.join(__dir__, "./matchers/*.rb")].each { |path| require path }
diff --git a/spec/support/matchers/change_all_records.rb b/spec/support/matchers/change_all_records.rb
new file mode 100644
index 000000000..c53b6100a
--- /dev/null
+++ b/spec/support/matchers/change_all_records.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+class Matchers::ChangeAllRecords
+  include RSpec::Matchers::Composable
+
+  attr_reader :records,
+              :attribute,
+              :original_values,
+              :new_values,
+              :expected_new_value,
+              :expected_original_value
+
+  def initialize(records, attribute)
+    @records = records
+    @attribute = attribute
+    @expected_original_value = :__not_set__
+    @expected_new_value = :__not_set__
+  end
+
+  def supports_block_expectations?
+    true
+  end
+
+  def matches?(event_proc)
+    perform_change(event_proc)
+
+    original_values_match? && changed? && new_values_match?
+  end
+
+  def from(expected_original_value)
+    @expected_original_value = expected_original_value
+    self
+  end
+
+  def does_not_match?(event_proc)
+    if new_values_expected?
+      message = "`expect { }.not_to change_all_records().to()` not supported"
+
+      raise(NotImplementedError, message)
+    end
+
+    perform_change(event_proc)
+
+    original_values_match? && !changed?
+  end
+
+  def to(expected_new_value)
+    @expected_new_value = expected_new_value
+    self
+  end
+
+  def failure_message
+    if !original_values_match?
+      original_values_do_not_match_message
+    elsif !changed?
+      values_did_not_change_message
+    else
+      new_values_do_not_match_message
+    end
+  end
+
+  def failure_message_when_negated
+    if original_values_match? # records changed
+      values_changed_message
+    else
+      original_values_do_not_match_message
+    end
+  end
+
+  private
+
+  def perform_change(event_proc)
+    @original_values = records.map { |record| record.public_send(attribute) }
+
+    event_proc.call
+
+    @new_values = records.map { |record| record.reload.public_send(attribute) }
+  end
+
+  def original_values_do_not_match_message
+    <<~MESSAGE.squish
+      expected original values to be #{expected_original_value} but were
+      #{original_values}
+    MESSAGE
+  end
+
+  def values_did_not_change_message
+    if new_values_expected?
+      <<~MESSAGE.squish
+        expected values to change from #{original_values} to
+        #{expected_new_value} but did not change
+      MESSAGE
+    else
+      <<~MESSAGE.squish
+        expected values to change from #{original_values} but did not change
+      MESSAGE
+    end
+  end
+
+  def new_values_do_not_match_message
+    <<~MESSAGE.squish
+      expected values to change to #{expected_new_value} but changed to
+      #{new_values}
+    MESSAGE
+  end
+
+  def values_changed_message
+    <<~MESSAGE.squish
+      expected values not to change from #{original_values} but changed to
+      #{new_values}
+    MESSAGE
+  end
+
+  def original_values_match?
+    !original_value_expected? || original_values.all?(expected_original_value)
+  end
+
+  def original_value_expected?
+    expected_original_value != :__not_set__
+  end
+
+  def changed?
+    new_values != original_values
+  end
+
+  def new_values_match?
+    !new_values_expected? || new_values.all?(expected_new_value)
+  end
+
+  def new_values_expected?
+    expected_new_value != :__not_set__
+  end
+end
diff --git a/spec/support/matchers/change_record.rb b/spec/support/matchers/change_record.rb
new file mode 100644
index 000000000..cc775aa2a
--- /dev/null
+++ b/spec/support/matchers/change_record.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+class Matchers::ChangeRecord
+  include RSpec::Matchers::Composable
+
+  attr_reader :record,
+              :attribute,
+              :original_value,
+              :new_value,
+              :expected_new_value,
+              :expected_original_value
+
+  def initialize(record, attribute)
+    @record = record
+    @attribute = attribute
+    @expected_original_value = :__not_set__
+    @expected_new_value = :__not_set__
+  end
+
+  def supports_block_expectations?
+    true
+  end
+
+  def matches?(event_proc)
+    perform_change(event_proc)
+
+    original_value_matches? && changed? && new_value_matches?
+  end
+
+  def from(expected_original_value)
+    @expected_original_value = expected_original_value
+    self
+  end
+
+  def does_not_match?(event_proc)
+    if new_value_expected?
+      message = "`expect { }.not_to change_record().to()` is not supported"
+
+      raise(NotImplementedError, message)
+    end
+
+    perform_change(event_proc)
+
+    original_value_matches? && !changed?
+  end
+
+  def to(expected_new_value)
+    @expected_new_value = expected_new_value
+    self
+  end
+
+  def failure_message
+    if !original_value_matches?
+      original_value_does_not_match_message
+    elsif !changed?
+      value_did_not_change_message
+    else # new value doesn't match
+      new_value_does_not_match_message
+    end
+  end
+
+  def failure_message_when_negated
+    if original_value_matches? # record changed
+      value_changed_message
+    else
+      original_value_does_not_match_message
+    end
+  end
+
+  private
+
+  def perform_change(event_proc)
+    @original_value = record[attribute]
+
+    event_proc.call
+
+    @new_value = record.reload.public_send(attribute)
+  end
+
+  def original_value_does_not_match_message
+    <<~MESSAGE.squish
+      expected original value to be #{expected_original_value} but was
+      #{original_value}
+    MESSAGE
+  end
+
+  def value_did_not_change_message
+    if new_value_expected?
+      <<~MESSAGE.squish
+        expected value to change from #{original_value} to
+        #{expected_new_value} but did not change
+      MESSAGE
+    else
+      <<~MESSAGE.squish
+        expected value to change from #{original_value} but did not change
+      MESSAGE
+    end
+  end
+
+  def new_value_does_not_match_message
+    <<~MESSAGE.squish
+      expected value to change to #{expected_new_value} but changed to
+      #{new_value}
+    MESSAGE
+  end
+
+  def value_changed_message
+    <<~MESSAGE.squish
+      expected value not to change from #{original_value} but changed to
+      #{new_value}
+    MESSAGE
+  end
+
+  def original_value_matches?
+    !original_value_expected? || original_value == expected_original_value
+  end
+
+  def original_value_expected?
+    expected_original_value != :__not_set__
+  end
+
+  def changed?
+    new_value != original_value
+  end
+
+  def new_value_matches?
+    !new_value_expected? || new_value == expected_new_value
+  end
+
+  def new_value_expected?
+    expected_new_value != :__not_set__
+  end
+end
diff --git a/spec/support/matchers/delete_record.rb b/spec/support/matchers/delete_record.rb
new file mode 100644
index 000000000..4147ebc49
--- /dev/null
+++ b/spec/support/matchers/delete_record.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+class Matchers::DeleteRecord
+  include RSpec::Matchers::Composable
+
+  attr_reader :record
+
+  def initialize(record)
+    @record = record
+  end
+
+  def supports_block_expectations?
+    true
+  end
+
+  def matches?(event_proc)
+    perform_change(event_proc)
+
+    exists_before? && !exists_after?
+  end
+
+  def does_not_match?(event_proc)
+    perform_change(event_proc)
+
+    exists_before? && exists_after?
+  end
+
+  def failure_message
+    if exists_before? # was not deleted
+      existed_after_message
+    else
+      did_not_exist_before_message
+    end
+  end
+
+  def failure_message_when_negated
+    if exists_before? # was deleted
+      did_not_exist_after_message
+    else
+      did_not_exist_before_message
+    end
+  end
+
+  private
+
+  def perform_change(event_proc)
+    @exists_before = record.class.exists?(record.id)
+
+    event_proc.call
+
+    @exists_after = record.class.exists?(record.id)
+  end
+
+  def did_not_exist_before_message
+    <<~MESSAGE.squish
+      expected #{record.class.name} with id #{record.id} to originally exist,
+      but did not
+    MESSAGE
+  end
+
+  def existed_after_message
+    <<~MESSAGE.squish
+      expected #{record.class.name} with id #{record.id} to be deleted,
+      but was not deleted
+    MESSAGE
+  end
+
+  def did_not_exist_after_message
+    <<~MESSAGE.squish
+      expected #{record.class.name} with id #{record.id} not to be deleted,
+      but was deleted
+    MESSAGE
+  end
+
+  def exists_before?
+    @exists_before
+  end
+
+  def exists_after?
+    @exists_after
+  end
+end
diff --git a/spec/support/matchers/invoke.rb b/spec/support/matchers/invoke.rb
new file mode 100644
index 000000000..2dbe8e4a8
--- /dev/null
+++ b/spec/support/matchers/invoke.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require "active_support/core_ext/module/delegation"
+
+class Matchers::Invoke
+  include RSpec::Matchers::Composable
+
+  delegate :failure_message,
+           :failure_message_when_negated,
+           to: :received_matcher
+
+  def initialize(expected_method)
+    @expected_method = expected_method
+  end
+
+  def matches?(event_proc)
+    raise(ArgumentError, "missing '.on'") unless defined?(@expected_recipient)
+
+    allow(@expected_recipient).to receive(@expected_method)
+    allow(@expected_recipient).to receive_expected
+
+    event_proc.call
+    received_matcher.matches?(@expected_recipient)
+  end
+
+  def on(expected_recipient)
+    @expected_recipient = expected_recipient
+    self
+  end
+
+  def with(*expected_arguments)
+    @expected_arguments = expected_arguments
+    self
+  end
+
+  def and_return(*return_arguments)
+    @return_arguments = return_arguments
+    self
+  end
+
+  def and_call_original
+    @and_call_original = true
+    self
+  end
+
+  def twice
+    @times = 2
+    self
+  end
+
+  def once
+    @times = 1
+    self
+  end
+
+  def supports_block_expectations?
+    true
+  end
+
+  private
+
+  def receive_expected
+    receive_expected = receive(@expected_method)
+
+    if defined?(@return_arguments)
+      receive_expected = receive_expected.and_return(*@return_arguments)
+    end
+
+    if defined?(@and_call_original)
+      receive_expected = receive_expected.and_call_original
+    end
+
+    receive_expected
+  end
+
+  def allow(target)
+    RSpec::Mocks::AllowanceTarget.new(target)
+  end
+
+  def receive(method_name)
+    RSpec::Mocks::Matchers::Receive.new(method_name, nil)
+  end
+
+  def received_matcher
+    @received_matcher ||=
+      begin
+        matcher = RSpec::Mocks::Matchers::HaveReceived.new(@expected_method)
+
+        if defined?(@expected_arguments)
+          matcher = matcher.with(*@expected_arguments)
+        end
+
+        matcher = matcher.exactly(@times).times if defined?(@times)
+
+        matcher
+      end
+  end
+end
diff --git a/spec/support/request_helpers.rb b/spec/support/request_helpers.rb
new file mode 100644
index 000000000..fd25621cb
--- /dev/null
+++ b/spec/support/request_helpers.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module RequestHelpers
+  def login_as(user)
+    post("/login", params: { username: user.username, password: user.password })
+  end
+
+  def rendered
+    Capybara.string(response.body)
+  end
+end
diff --git a/spec/support/system_helpers.rb b/spec/support/system_helpers.rb
new file mode 100644
index 000000000..a0ce6542d
--- /dev/null
+++ b/spec/support/system_helpers.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module SystemHelpers
+  def login_as(user)
+    visit(login_path)
+    fill_in("Username", with: user.username)
+    fill_in("Password", with: user.password)
+    click_on("Login")
+  end
+end
diff --git a/spec/support/webmock.rb b/spec/support/webmock.rb
new file mode 100644
index 000000000..4e0581096
--- /dev/null
+++ b/spec/support/webmock.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+require "webmock/rspec"
+
+WebMock.disable_net_connect!(
+  allow_localhost: true,
+  allow: [/geckodriver/, /chromedriver/]
+)
diff --git a/spec/support/with_model.rb b/spec/support/with_model.rb
new file mode 100644
index 000000000..f7a5f9ba7
--- /dev/null
+++ b/spec/support/with_model.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class WithModel::Model
+  # Workaround for https://github.com/Casecommons/with_model/issues/35
+  def cleanup_descendants_tracking
+    cache_classes = Rails.application.config.cache_classes
+    if defined?(ActiveSupport::DescendantsTracker) && !cache_classes
+      ActiveSupport::DescendantsTracker.clear([@model])
+    elsif @model.superclass.respond_to?(:direct_descendants)
+      @model.superclass.subclasses.delete(@model)
+    end
+  end
+end
+
+RSpec.configure { |config| config.extend(WithModel) }
diff --git a/spec/system/account_setup_spec.rb b/spec/system/account_setup_spec.rb
new file mode 100644
index 000000000..798874dc5
--- /dev/null
+++ b/spec/system/account_setup_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+RSpec.describe "account setup" do
+  def fill_in_fields(username:)
+    fill_in("Username", with: username)
+    fill_in("Password", with: "my-password")
+    fill_in("Confirm", with: "my-password")
+    click_on("Next")
+  end
+
+  it "allows a user to sign up" do
+    visit "/"
+
+    fill_in_fields(username: "my-username")
+
+    expect(page).to have_text("Logged in as my-username")
+  end
+
+  it "allows a second user to sign up" do
+    Setting::UserSignup.create!(enabled: true)
+    create(:user)
+
+    visit "/"
+
+    expect(page).to have_link("sign up")
+  end
+
+  it "does not allow a second user to signup when not enabled" do
+    create(:user)
+
+    visit "/"
+
+    expect(page).to have_no_link("sign up")
+  end
+end
diff --git a/spec/system/application_settings_spec.rb b/spec/system/application_settings_spec.rb
new file mode 100644
index 000000000..03019028c
--- /dev/null
+++ b/spec/system/application_settings_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+RSpec.describe "application settings" do
+  it "allows enabling account creation" do
+    login_as(create(:user, admin: true))
+    visit(settings_path)
+
+    within("form", text: "User signups are disabled") { click_on("Enable") }
+
+    expect(page).to have_content("User signups are enabled")
+  end
+end
diff --git a/spec/system/export_spec.rb b/spec/system/export_spec.rb
new file mode 100644
index 000000000..4e1f4109a
--- /dev/null
+++ b/spec/system/export_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+RSpec.describe "exporting feeds" do
+  it "allows exporting feeds" do
+    login_as(default_user)
+    feed = create(:feed, :with_group)
+
+    click_on "Export"
+
+    xml = Capybara.string(Downloads.content_for(page, "stringer.opml"))
+    expect(xml).to have_css("outline[title='#{feed.name}']")
+  end
+end
diff --git a/spec/system/feeds_index_spec.rb b/spec/system/feeds_index_spec.rb
new file mode 100644
index 000000000..be664bd03
--- /dev/null
+++ b/spec/system/feeds_index_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+RSpec.describe "feeds/index" do
+  it "displays a list of feeds" do
+    login_as(default_user)
+    create_pair(:feed)
+
+    visit "/feeds"
+
+    expect(page).to have_css("li.feed", count: 2)
+  end
+
+  it "displays message to add feeds if there are none" do
+    login_as(default_user)
+
+    visit "/feeds"
+
+    expect(page).to have_content("Hey, you should add some feeds")
+  end
+
+  it "allows the user to delete a feed" do
+    login_as(default_user)
+    create(:feed)
+
+    visit("/feeds")
+    click_on "Delete"
+
+    expect(page).to have_content("Feed deleted")
+  end
+
+  it "allows the user to edit a feed" do
+    login_as(default_user)
+    feed = create(:feed)
+
+    visit "/feeds"
+    click_on "Edit"
+
+    expect(page).to have_field("Feed Name", with: feed.name)
+  end
+
+  it "links to the feed" do
+    login_as(default_user)
+    feed = create(:feed)
+
+    visit "/feeds"
+
+    expect(page).to have_link(href: feed.url)
+  end
+end
diff --git a/spec/system/good_job_spec.rb b/spec/system/good_job_spec.rb
new file mode 100644
index 000000000..31a4efba3
--- /dev/null
+++ b/spec/system/good_job_spec.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+RSpec.describe "admin/good_job" do
+  it "displays the GoodJob dashboard" do
+    login_as(create(:user, admin: true))
+    a11y_skip = [
+      "aria-required-children",
+      "color-contrast",
+      "landmark-unique",
+      "landmark-one-main",
+      "page-has-heading-one",
+      "region"
+    ]
+    visit(good_job_path, a11y_skip:)
+
+    expect(page).to have_link("Scheduled").and have_link("Queued")
+  end
+end
diff --git a/spec/system/import_spec.rb b/spec/system/import_spec.rb
new file mode 100644
index 000000000..ccd85e2a0
--- /dev/null
+++ b/spec/system/import_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+RSpec.describe "importing feeds" do
+  it "allows importing feeds" do
+    login_as(default_user)
+    visit(feeds_import_path)
+    file_path = Rails.root.join("spec/fixtures/feeds.opml")
+
+    attach_file("opml_file", file_path, visible: false)
+
+    expect(page).to have_content("We're getting you some stories to read")
+  end
+end
diff --git a/spec/system/js_tests_spec.rb b/spec/system/js_tests_spec.rb
new file mode 100644
index 000000000..1ff8910a1
--- /dev/null
+++ b/spec/system/js_tests_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+RSpec.describe "JS tests" do
+  it "passes the mocha tests" do
+    login_as(default_user)
+
+    visit "/test"
+
+    expect(page).to have_content("failures: 0")
+  end
+end
diff --git a/spec/system/profile_spec.rb b/spec/system/profile_spec.rb
new file mode 100644
index 000000000..07e74c1ca
--- /dev/null
+++ b/spec/system/profile_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+RSpec.describe "profile page" do
+  before do
+    login_as(default_user)
+    visit(edit_profile_path)
+  end
+
+  it "allows the user to edit their username" do
+    fill_in_username_fields(default_user.password)
+    click_on("Update username")
+
+    expect(page).to have_text("Logged in as new_username")
+  end
+
+  def fill_in_username_fields(existing_password)
+    within_fieldset("Change Username") do
+      fill_in("Username", with: "new_username")
+      fill_in("Existing password", with: existing_password)
+    end
+  end
+
+  it "allows the user to edit their password" do
+    fill_in_password_fields(default_user.password, "new_password")
+    click_on("Update password")
+
+    expect(page).to have_text("Password updated")
+  end
+
+  def fill_in_password_fields(existing_password, new_password)
+    within_fieldset("Change Password") do
+      fill_in("Existing password", with: existing_password)
+      fill_in("New password", with: new_password)
+      fill_in("Password confirmation", with: new_password)
+    end
+  end
+
+  it "allows the user to edit their feed order" do
+    select("Oldest first", from: "Stories feed order")
+    click_on("Update")
+
+    expect(default_user.reload).to be_stories_order_asc
+  end
+end
diff --git a/spec/system/stories_index_spec.rb b/spec/system/stories_index_spec.rb
new file mode 100644
index 000000000..6156df6ce
--- /dev/null
+++ b/spec/system/stories_index_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+RSpec.describe "stories/index" do
+  it "displays the stories" do
+    create(:story, title: "My Story")
+    login_as(default_user)
+
+    visit news_path
+
+    expect(page).to have_content("My Story")
+  end
+
+  it "does not display read stories" do
+    create(:story, :read, title: "My Story")
+    login_as(default_user)
+
+    visit news_path
+
+    expect(page).to have_no_content("My Story")
+  end
+
+  it "marks all stories as read" do
+    create(:story, title: "My Story")
+    login_as(default_user)
+    visit news_path
+
+    click_on "Mark all as read"
+
+    expect(page).to have_content("You've reached RSS Zero")
+  end
+
+  it "refreshes the page" do
+    login_as(default_user)
+    visit news_path
+    create(:story, title: "My Story")
+
+    click_on "Refresh"
+
+    expect(page).to have_content("My Story")
+  end
+
+  it "displays the story body when the row is clicked" do
+    create(:story, title: "My Story", body: "My Body")
+    login_as(default_user)
+    visit news_path
+
+    find(".story-preview", text: "My Story").click
+
+    expect(page).to have_content("My Body")
+  end
+
+  def star_story(story_title)
+    visit(news_path)
+
+    find(".story-preview", text: story_title).click
+    find(".story-actions .story-starred").click
+  end
+
+  it "allows marking a story as starred" do
+    create(:story, title: "My Story")
+    login_as(default_user)
+
+    star_story("My Story")
+
+    visit(starred_path)
+    expect(page).to have_content("My Story")
+  end
+
+  it "allows marking a story as unstarred" do
+    create(:story, :starred, title: "My Story")
+    login_as(default_user)
+
+    star_story("My Story")
+
+    visit(starred_path)
+    expect(page).to have_no_content("My Story")
+  end
+
+  def mark_story_unread(story_title)
+    visit(news_path)
+
+    find(".story-preview", text: story_title).click
+    find(".story-actions .story-keep-unread").click
+  end
+
+  it "allows marking a story as unread" do
+    create(:story, :starred, title: "My Story")
+    login_as(default_user)
+    mark_story_unread("My Story")
+
+    visit(news_path)
+
+    expect(page).to have_content("My Story")
+  end
+
+  it "allows viewing a story with hot keys" do
+    create(:story, title: "My Story", body: "My Body")
+    login_as(default_user)
+    visit news_path
+
+    send_keys("j")
+
+    expect(page).to have_content("My Body")
+  end
+end
diff --git a/spec/tasks/change_password_spec.rb b/spec/tasks/change_password_spec.rb
deleted file mode 100644
index f39dfcc11..000000000
--- a/spec/tasks/change_password_spec.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-require "spec_helper"
-
-app_require "tasks/change_password"
-
-describe ChangePassword do
-  let(:command) { double("command") }
-  let(:new_password) { "new-pw" }
-
-  let(:task) { ChangePassword.new(command) }
-
-  describe "#change_password" do
-    it "invokes command with confirmed password" do
-      expect(task).to receive(:ask_hidden).twice
-        .and_return(new_password, new_password)
-
-      expect(command)
-        .to receive(:change_user_password)
-        .with(new_password)
-
-      task.change_password
-    end
-
-    it "repeats until a matching confirmation" do
-      expect(task).to receive(:ask_hidden).exactly(2).times
-        .and_return(new_password, "", new_password, new_password)
-
-      expect(command)
-        .to receive(:change_user_password)
-        .with(new_password)
-
-      task.change_password
-    end
-  end
-end
diff --git a/spec/tasks/fetch_feed_spec.rb b/spec/tasks/fetch_feed_spec.rb
deleted file mode 100644
index 7408e854e..000000000
--- a/spec/tasks/fetch_feed_spec.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-require "spec_helper"
-app_require "tasks/fetch_feed"
-
-describe FetchFeed do
-  describe "#fetch" do
-    let(:daring_fireball) do
-      double(id: 1,
-             url: "http://daringfireball.com/feed",
-             last_fetched: Time.new(2013, 1, 1),
-             stories: [])
-    end
-
-    before do
-      allow(StoryRepository).to receive(:add)
-      allow(FeedRepository).to receive(:update_last_fetched)
-      allow(FeedRepository).to receive(:set_status)
-    end
-
-    context "when feed has not been modified" do
-      it "should not try to fetch posts" do
-        parser = double(fetch_and_parse: 304)
-
-        expect(StoryRepository).not_to receive(:add)
-
-        FetchFeed.new(daring_fireball, parser: parser)
-      end
-    end
-
-    context "when no new posts have been added" do
-      it "should not add any new posts" do
-        fake_feed = double(last_modified: Time.new(2012, 12, 31))
-        parser = double(fetch_and_parse: fake_feed)
-
-        allow_any_instance_of(FindNewStories).to receive(:new_stories).and_return([])
-
-        expect(StoryRepository).not_to receive(:add)
-
-        FetchFeed.new(daring_fireball, parser: parser).fetch
-      end
-    end
-
-    context "when new posts have been added" do
-      let(:now) { Time.now }
-      let(:new_story) { double }
-      let(:old_story) { double }
-
-      let(:fake_feed) { double(last_modified: now, entries: [new_story, old_story]) }
-      let(:fake_parser) { double(fetch_and_parse: fake_feed) }
-
-      before { allow_any_instance_of(FindNewStories).to receive(:new_stories).and_return([new_story]) }
-
-      it "should only add posts that are new" do
-        expect(StoryRepository).to receive(:add).with(new_story, daring_fireball)
-        expect(StoryRepository).not_to receive(:add).with(old_story, daring_fireball)
-
-        FetchFeed.new(daring_fireball, parser: fake_parser).fetch
-      end
-
-      it "should update the last fetched time for the feed" do
-        expect(FeedRepository).to receive(:update_last_fetched)
-          .with(daring_fireball, now)
-
-        FetchFeed.new(daring_fireball, parser: fake_parser).fetch
-      end
-    end
-
-    context "feed status" do
-      it "sets the status to green if things are all good" do
-        fake_feed = double(last_modified: Time.new(2012, 12, 31), entries: [])
-        parser = double(fetch_and_parse: fake_feed)
-
-        expect(FeedRepository).to receive(:set_status)
-          .with(:green, daring_fireball)
-
-        FetchFeed.new(daring_fireball, parser: parser).fetch
-      end
-
-      it "sets the status to red if things go wrong" do
-        parser = double(fetch_and_parse: 404)
-
-        expect(FeedRepository).to receive(:set_status)
-          .with(:red, daring_fireball)
-
-        FetchFeed.new(daring_fireball, parser: parser).fetch
-      end
-    end
-  end
-end
diff --git a/spec/tasks/fetch_feeds_spec.rb b/spec/tasks/fetch_feeds_spec.rb
deleted file mode 100644
index 80ad429ac..000000000
--- a/spec/tasks/fetch_feeds_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-require "spec_helper"
-
-describe FetchFeeds do
-  describe "#fetch_all" do
-    let(:feeds) { [FeedFactory.build, FeedFactory.build] }
-    let(:fetcher_one) { double }
-    let(:fetcher_two) { double }
-    let(:pool) { double }
-
-    it "calls FetchFeed#fetch for every feed" do
-      allow(pool).to receive(:process).and_yield
-      allow(FetchFeed).to receive(:new).and_return(fetcher_one, fetcher_two)
-
-      expect(fetcher_one).to receive(:fetch).once
-      expect(fetcher_two).to receive(:fetch).once
-
-      expect(pool).to receive(:shutdown)
-
-      FetchFeeds.new(feeds, pool).fetch_all
-    end
-  end
-end
diff --git a/spec/tasks/remove_old_stories_spec.rb b/spec/tasks/remove_old_stories_spec.rb
index 23271155e..f73bb8394 100644
--- a/spec/tasks/remove_old_stories_spec.rb
+++ b/spec/tasks/remove_old_stories_spec.rb
@@ -1,52 +1,50 @@
-require "spec_helper"
-app_require "tasks/remove_old_stories"
+# frozen_string_literal: true
 
-describe RemoveOldStories do
-  describe ".remove!" do
-    let(:stories_mock) do
-      stories = double("stories")
-      allow(stories).to receive(:delete_all)
-      stories
-    end
+RSpec.describe RemoveOldStories do
+  def create_stories
+    stories = double("stories")
+    allow(stories).to receive(:delete_all)
+    stories
+  end
 
-    it "should pass along the number of days to the story repository query" do
-      allow(RemoveOldStories).to receive(:pruned_feeds) { [] }
+  it "passes along the number of days to the story repository query" do
+    allow(described_class).to receive(:pruned_feeds).and_return([])
 
-      expect(StoryRepository).to receive(:unstarred_read_stories_older_than).with(7).and_return(stories_mock)
+    expect(StoryRepository).to receive(:unstarred_read_stories_older_than)
+      .with(7).and_return(create_stories)
 
-      RemoveOldStories.remove!(7)
-    end
+    described_class.call(7)
+  end
 
-    it "should request deletion of all old stories" do
-      allow(RemoveOldStories).to receive(:pruned_feeds) { [] }
-      allow(StoryRepository).to receive(:unstarred_read_stories_older_than) { stories_mock }
+  it "requests deletion of all old stories" do
+    allow(described_class).to receive(:pruned_feeds).and_return([])
+    mocked_stories = create_stories
+    allow(StoryRepository)
+      .to receive(:unstarred_read_stories_older_than) { mocked_stories }
 
-      expect(stories_mock).to receive(:delete_all)
+    expect(mocked_stories).to receive(:delete_all)
 
-      RemoveOldStories.remove!(11)
-    end
+    described_class.call(11)
+  end
 
-    it "should fetch affected feeds by id" do
-      allow(RemoveOldStories).to receive(:old_stories) do
-        stories = [double("story", feed_id: 3), double("story", feed_id: 5)]
-        allow(stories).to receive(:delete_all)
-        stories
-      end
+  it "fetches affected feeds by id" do
+    stories = create_list(:story, 3, :read, created_at: 1.month.ago)
 
-      expect(FeedRepository).to receive(:fetch_by_ids).with([3, 5]).and_return([])
+    described_class.call(13)
 
-      RemoveOldStories.remove!(13)
-    end
+    expect(Story.where(id: stories.map(&:id))).to be_empty
+  end
 
-    it "should update last_fetched on affected feeds" do
-      feeds = [double("feed a"), double("feed b")]
-      allow(RemoveOldStories).to receive(:pruned_feeds) { feeds }
-      allow(RemoveOldStories).to receive(:old_stories) { stories_mock }
+  it "updates last_fetched on affected feeds" do
+    feeds = [double("feed a"), double("feed b")]
+    allow(described_class).to receive(:pruned_feeds) { feeds }
+    allow(described_class).to receive(:old_stories) { create_stories }
 
-      expect(FeedRepository).to receive(:update_last_fetched).with(feeds.first, anything)
-      expect(FeedRepository).to receive(:update_last_fetched).with(feeds.last, anything)
+    expect(FeedRepository)
+      .to receive(:update_last_fetched).with(feeds.first, anything)
+    expect(FeedRepository)
+      .to receive(:update_last_fetched).with(feeds.last, anything)
 
-      RemoveOldStories.remove!(13)
-    end
+    described_class.call(13)
   end
 end
diff --git a/spec/utils/authorization_spec.rb b/spec/utils/authorization_spec.rb
new file mode 100644
index 000000000..80c98aa9e
--- /dev/null
+++ b/spec/utils/authorization_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+RSpec.describe Authorization do
+  describe "#check" do
+    it "raises an error if the record belongs to another user" do
+      user = create(:user)
+      feed = create(:feed)
+
+      expect { described_class.new(user).check(feed) }
+        .to raise_error(Authorization::NotAuthorizedError)
+    end
+
+    describe "when the user owns the record" do
+      it "marks the request as authorized" do
+        feed = create(:feed)
+        authorization = described_class.new(feed.user)
+
+        authorization.check(feed)
+
+        expect(authorization).to be_authorized
+      end
+
+      it "returns the record" do
+        feed = create(:feed)
+        authorization = described_class.new(feed.user)
+
+        expect(authorization.check(feed)).to eq(feed)
+      end
+    end
+  end
+
+  describe "#scope" do
+    it "returns the records that belong to the user" do
+      feed = create(:feed)
+
+      expect(described_class.new(feed.user).scope(Feed)).to eq([feed])
+    end
+
+    it "does not return records that belong to another user" do
+      create(:feed, user: create(:user))
+
+      expect(described_class.new(default_user).scope(Feed)).to be_empty
+    end
+
+    it "marks the request as authorized" do
+      authorization = described_class.new(default_user)
+
+      authorization.scope(Feed)
+
+      expect(authorization).to be_authorized
+    end
+  end
+
+  describe "#skip" do
+    it "marks the request as authorized" do
+      authorization = described_class.new(default_user)
+
+      authorization.skip
+
+      expect(authorization).to be_authorized
+    end
+  end
+
+  describe "#verify" do
+    it "raises an error if the request was not authorized" do
+      authorization = described_class.new(default_user)
+
+      expect { authorization.verify }
+        .to raise_error(Authorization::NotAuthorizedError)
+    end
+
+    it "does not raise an error if the request was authorized" do
+      authorization = described_class.new(default_user)
+      authorization.skip
+
+      expect { authorization.verify }.not_to raise_error
+    end
+  end
+end
diff --git a/spec/utils/content_sanitizer_spec.rb b/spec/utils/content_sanitizer_spec.rb
new file mode 100644
index 000000000..e8ab99d32
--- /dev/null
+++ b/spec/utils/content_sanitizer_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+RSpec.describe ContentSanitizer do
+  it "handles <wbr> tag properly" do
+    result = described_class.call("<code>WM_<wbr\t\n >ERROR</code> asdf")
+
+    expect(result).to eq("<code>WM_ERROR</code> asdf")
+  end
+
+  it "handles <figure> tag properly" do
+    result = described_class.call("<figure>some code</figure>")
+
+    expect(result).to eq("<figure>some code</figure>")
+  end
+
+  it "handles unprintable characters" do
+    result = described_class.call("n\u2028\u2029")
+
+    expect(result).to eq("n")
+  end
+
+  it "preserves line endings" do
+    result = described_class.call("test\r\ncase")
+
+    expect(result).to eq("test\r\ncase")
+  end
+end
diff --git a/spec/utils/feed_discovery_spec.rb b/spec/utils/feed_discovery_spec.rb
index 1c10286d9..4f5ddcd4e 100644
--- a/spec/utils/feed_discovery_spec.rb
+++ b/spec/utils/feed_discovery_spec.rb
@@ -1,52 +1,56 @@
-require "spec_helper"
+# frozen_string_literal: true
 
-app_require "utils/feed_discovery"
+RSpec.describe FeedDiscovery do
+  url = "http://example.com"
+  invalid_discovered_url = "http://not-a-valid-feed.com"
+  valid_discovered_url = "http://a-valid-feed.com"
 
-describe FeedDiscovery do
-  let(:finder) { double }
-  let(:parser) { double }
-  let(:feed) { double(feed_url: url) }
-  let(:url) { "http://example.com" }
+  it "returns false if url is not a feed and feed url cannot be discovered" do
+    expect(HTTParty).to receive(:get).with(url)
+    expect(Feedjira).to receive(:parse).and_raise(StandardError)
+    expect(Feedbag).to receive(:find).and_return([])
 
-  let(:invalid_discovered_url) { "http://not-a-valid-feed.com" }
-  let(:valid_discovered_url) { "http://a-valid-feed.com" }
+    result = described_class.call(url)
 
-  describe "#discover" do
-    it "returns false if url is not a feed and feed url cannot be discovered" do
-      expect(parser).to receive(:fetch_and_parse).with(url).and_raise(StandardError)
-      expect(finder).to receive(:find).and_return([])
+    expect(result).to be(false)
+  end
+
+  it "returns a feed if the url provided is parsable" do
+    feed = double(feed_url: url)
+    expect(HTTParty).to receive(:get).with(url)
+    expect(Feedjira).to receive(:parse).and_return(feed)
 
-      result = FeedDiscovery.new.discover(url, finder, parser)
+    result = described_class.call(url)
 
-      expect(result).to eq(false)
-    end
+    expect(result).to eq(feed)
+  end
 
-    it "returns a feed if the url provided is parsable" do
-      expect(parser).to receive(:fetch_and_parse).with(url).and_return(feed)
+  it "returns false if the discovered feed is not parsable" do
+    expect(HTTParty).to receive(:get).with(url)
+    expect(Feedjira).to receive(:parse).and_raise(StandardError)
 
-      result = FeedDiscovery.new.discover(url, finder, parser)
+    expect(Feedbag).to receive(:find).and_return([invalid_discovered_url])
 
-      expect(result).to eq feed
-    end
+    expect(HTTParty).to receive(:get).with(invalid_discovered_url)
+    expect(Feedjira).to receive(:parse).and_raise(StandardError)
 
-    it "returns false if the discovered feed is not parsable" do
-      expect(parser).to receive(:fetch_and_parse).with(url).and_raise(StandardError)
-      expect(finder).to receive(:find).and_return([invalid_discovered_url])
-      expect(parser).to receive(:fetch_and_parse).with(invalid_discovered_url).and_raise(StandardError)
+    result = described_class.call(url)
+
+    expect(result).to be(false)
+  end
 
-      result = FeedDiscovery.new.discover(url, finder, parser)
+  it "returns the feed if the discovered feed is parsable" do
+    feed = double(feed_url: url)
+    expect(HTTParty).to receive(:get).with(url)
+    expect(Feedjira).to receive(:parse).and_raise(StandardError)
 
-      expect(result).to eq(false)
-    end
+    expect(Feedbag).to receive(:find).and_return([valid_discovered_url])
 
-    it "returns the feed if the discovered feed is parsable" do
-      expect(parser).to receive(:fetch_and_parse).with(url).and_raise(StandardError)
-      expect(finder).to receive(:find).and_return([valid_discovered_url])
-      expect(parser).to receive(:fetch_and_parse).with(valid_discovered_url).and_return(feed)
+    expect(HTTParty).to receive(:get).with(valid_discovered_url)
+    expect(Feedjira).to receive(:parse).and_return(feed)
 
-      result = FeedDiscovery.new.discover(url, finder, parser)
+    result = described_class.call(url)
 
-      expect(result).to eq feed
-    end
+    expect(result).to eq(feed)
   end
 end
diff --git a/spec/utils/i18n_support_spec.rb b/spec/utils/i18n_support_spec.rb
index 861f1f713..89d3ec70e 100644
--- a/spec/utils/i18n_support_spec.rb
+++ b/spec/utils/i18n_support_spec.rb
@@ -1,35 +1,21 @@
-require "spec_helper"
+# frozen_string_literal: true
 
-describe "i18n" do
-  before do
+RSpec.describe "i18n", type: :request do
+  it "loads default locale when no locale was set" do
     allow(UserRepository).to receive(:setup_complete?).and_return(false)
-    ENV["LOCALE"] = locale
+    ENV["LOCALE"] = nil
     get "/"
-  end
-
-  context "when no locale was set" do
-    let(:locale) { nil }
 
-    it "should load default locale" do
-      expect(I18n.locale.to_s).to eq "en"
-      expect(I18n.locale.to_s).not_to eq nil
-    end
+    expect(I18n.locale.to_s).to eq("en")
+    expect(I18n.locale.to_s).not_to be_nil
   end
 
-  context "when locale was set" do
-    let(:locale) { "en" }
-
-    it "should load default locale" do
-      expect(I18n.locale.to_s).to eq "en"
-      expect(I18n.t("layout.title")).to eq "stringer | your rss buddy"
-    end
-  end
-
-  context "when a missing locale was set" do
-    let(:locale) { "xx" }
+  it "loads default locale was locale was set" do
+    allow(UserRepository).to receive(:setup_complete?).and_return(false)
+    ENV["LOCALE"] = "en"
+    get "/"
 
-    it "should not find localization strings" do
-      expect(I18n.t("layout.title", locale: ENV["LOCALE"].to_sym)).not_to eq "stringer | your rss buddy"
-    end
+    expect(I18n.locale.to_s).to eq("en")
+    expect(I18n.t("layout.title")).to eq("stringer | your rss buddy")
   end
 end
diff --git a/spec/utils/opml_parser_spec.rb b/spec/utils/opml_parser_spec.rb
index e007dc195..ce274d522 100644
--- a/spec/utils/opml_parser_spec.rb
+++ b/spec/utils/opml_parser_spec.rb
@@ -1,92 +1,86 @@
-require "spec_helper"
+# frozen_string_literal: true
 
-app_require "utils/opml_parser"
+RSpec.describe OpmlParser do
+  it "returns a hash of feed details from an OPML file" do
+    result = described_class.call(<<-XML)
+      <?xml version="1.0" encoding="UTF-8"?>
+      <opml version="1.0">
+      <head>
+        <title>matt swanson subscriptions in Google Reader</title>
+      </head>
+      <body>
+        <outline text="a sample feed" title="a sample feed" type="rss"
+            xmlUrl="http://feeds.feedburner.com/foobar" htmlUrl="http://www.example.org/"/>
+        <outline text="lolol" title="Matt's Blog" type="rss"
+            xmlUrl="http://mdswanson.com/atom.xml" htmlUrl="http://mdswanson.com/"/>
+      </body>
+      </opml>
+    XML
 
-describe OpmlParser do
-  let(:parser) { OpmlParser.new }
+    resulted_values = result.values.flatten
+    expect(resulted_values.size).to eq(2)
+    expect(resulted_values.first[:name]).to eq("a sample feed")
+    expect(resulted_values.first[:url]).to eq("http://feeds.feedburner.com/foobar")
 
-  describe "#parse_feeds" do
-    it "it returns a hash of feed details from an OPML file" do
-      result = parser.parse_feeds(<<-eos)
-        <?xml version="1.0" encoding="UTF-8"?>
-        <opml version="1.0">
-        <head>
-          <title>matt swanson subscriptions in Google Reader</title>
-        </head>
-        <body>
+    expect(resulted_values.last[:name]).to eq("Matt's Blog")
+    expect(resulted_values.last[:url]).to eq("http://mdswanson.com/atom.xml")
+    expect(result.keys.first).to eq("Ungrouped")
+  end
+
+  it "handles nested groups of feeds" do
+    result = described_class.call(<<-XML)
+      <?xml version="1.0" encoding="UTF-8"?>
+      <opml version="1.0">
+      <head>
+        <title>matt swanson subscriptions in Google Reader</title>
+      </head>
+      <body>
+        <outline text="Technology News">
           <outline text="a sample feed" title="a sample feed" type="rss"
               xmlUrl="http://feeds.feedburner.com/foobar" htmlUrl="http://www.example.org/"/>
-          <outline text="lolol" title="Matt's Blog" type="rss"
-              xmlUrl="http://mdswanson.com/atom.xml" htmlUrl="http://mdswanson.com/"/>
-        </body>
-        </opml>
-      eos
-
-      resulted_values = result.values.flatten
-      expect(resulted_values.size).to eq 2
-      expect(resulted_values.first[:name]).to eq "a sample feed"
-      expect(resulted_values.first[:url]).to eq "http://feeds.feedburner.com/foobar"
-
-      expect(resulted_values.last[:name]).to eq "Matt's Blog"
-      expect(resulted_values.last[:url]).to eq "http://mdswanson.com/atom.xml"
-      expect(result.keys.first).to eq "Ungrouped"
-    end
+        </outline>
+      </body>
+      </opml>
+    XML
+    resulted_values = result.values.flatten
 
-    it "handles nested groups of feeds" do
-      result = parser.parse_feeds(<<-eos)
-        <?xml version="1.0" encoding="UTF-8"?>
-        <opml version="1.0">
-        <head>
-          <title>matt swanson subscriptions in Google Reader</title>
-        </head>
-        <body>
-          <outline text="Technology News">
-            <outline text="a sample feed" title="a sample feed" type="rss"
-                xmlUrl="http://feeds.feedburner.com/foobar" htmlUrl="http://www.example.org/"/>
-          </outline>
-        </body>
-        </opml>
-      eos
-      resulted_values = result.values.flatten
-
-      expect(resulted_values.count).to eq 1
-      expect(resulted_values.first[:name]).to eq "a sample feed"
-      expect(resulted_values.first[:url]).to eq "http://feeds.feedburner.com/foobar"
-      expect(result.keys.first).to eq "Technology News"
-    end
+    expect(resulted_values.count).to eq(1)
+    expect(resulted_values.first[:name]).to eq("a sample feed")
+    expect(resulted_values.first[:url]).to eq("http://feeds.feedburner.com/foobar")
+    expect(result.keys.first).to eq("Technology News")
+  end
 
-    it "doesn't explode when there are no feeds" do
-      result = parser.parse_feeds(<<-eos)
-        <?xml version="1.0" encoding="UTF-8"?>
-        <opml version="1.0">
-        <head>
-          <title>matt swanson subscriptions in Google Reader</title>
-        </head>
-        <body>
-        </body>
-        </opml>
-      eos
+  it "doesn't explode when there are no feeds" do
+    result = described_class.call(<<-XML)
+      <?xml version="1.0" encoding="UTF-8"?>
+      <opml version="1.0">
+      <head>
+        <title>matt swanson subscriptions in Google Reader</title>
+      </head>
+      <body>
+      </body>
+      </opml>
+    XML
 
-      expect(result).to be_empty
-    end
+    expect(result).to be_empty
+  end
 
-    it "handles Feedly's exported OPML (missing :title)" do
-      result = parser.parse_feeds(<<-eos)
-        <?xml version="1.0" encoding="UTF-8"?>
-        <opml version="1.0">
-        <head>
-          <title>My feeds (Feedly)</title>
-        </head>
-        <body>
-          <outline text="a sample feed" type="rss"
-              xmlUrl="http://feeds.feedburner.com/foobar" htmlUrl="http://www.example.org/"/>
-        </body>
-        </opml>
-      eos
-      resulted_values = result.values.flatten
+  it "handles Feedly's exported OPML (missing :title)" do
+    result = described_class.call(<<-XML)
+      <?xml version="1.0" encoding="UTF-8"?>
+      <opml version="1.0">
+      <head>
+        <title>My feeds (Feedly)</title>
+      </head>
+      <body>
+        <outline text="a sample feed" type="rss"
+            xmlUrl="http://feeds.feedburner.com/foobar" htmlUrl="http://www.example.org/"/>
+      </body>
+      </opml>
+    XML
+    resulted_values = result.values.flatten
 
-      expect(resulted_values.count).to eq 1
-      expect(resulted_values.first[:name]).to eq "a sample feed"
-    end
+    expect(resulted_values.count).to eq(1)
+    expect(resulted_values.first[:name]).to eq("a sample feed")
   end
 end
diff --git a/tmp/.keep b/tmp/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/tmp/pids/.keep b/tmp/pids/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/vendor/.keep b/vendor/.keep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/assets/javascripts/backbone-min.js b/vendor/assets/javascripts/backbone-min.js
similarity index 99%
rename from app/assets/javascripts/backbone-min.js
rename to vendor/assets/javascripts/backbone-min.js
index 3541019c5..bce4fbc1b 100644
--- a/app/assets/javascripts/backbone-min.js
+++ b/vendor/assets/javascripts/backbone-min.js
@@ -1,4 +1 @@
 (function(){var t=this;var e=t.Backbone;var i=[];var r=i.push;var s=i.slice;var n=i.splice;var a;if(typeof exports!=="undefined"){a=exports}else{a=t.Backbone={}}a.VERSION="1.0.0";var h=t._;if(!h&&typeof require!=="undefined")h=require("underscore");a.$=t.jQuery||t.Zepto||t.ender||t.$;a.noConflict=function(){t.Backbone=e;return this};a.emulateHTTP=false;a.emulateJSON=false;var o=a.Events={on:function(t,e,i){if(!l(this,"on",t,[e,i])||!e)return this;this._events||(this._events={});var r=this._events[t]||(this._events[t]=[]);r.push({callback:e,context:i,ctx:i||this});return this},once:function(t,e,i){if(!l(this,"once",t,[e,i])||!e)return this;var r=this;var s=h.once(function(){r.off(t,s);e.apply(this,arguments)});s._callback=e;return this.on(t,s,i)},off:function(t,e,i){var r,s,n,a,o,u,c,f;if(!this._events||!l(this,"off",t,[e,i]))return this;if(!t&&!e&&!i){this._events={};return this}a=t?[t]:h.keys(this._events);for(o=0,u=a.length;o<u;o++){t=a[o];if(n=this._events[t]){this._events[t]=r=[];if(e||i){for(c=0,f=n.length;c<f;c++){s=n[c];if(e&&e!==s.callback&&e!==s.callback._callback||i&&i!==s.context){r.push(s)}}}if(!r.length)delete this._events[t]}}return this},trigger:function(t){if(!this._events)return this;var e=s.call(arguments,1);if(!l(this,"trigger",t,e))return this;var i=this._events[t];var r=this._events.all;if(i)c(i,e);if(r)c(r,arguments);return this},stopListening:function(t,e,i){var r=this._listeners;if(!r)return this;var s=!e&&!i;if(typeof e==="object")i=this;if(t)(r={})[t._listenerId]=t;for(var n in r){r[n].off(e,i,this);if(s)delete this._listeners[n]}return this}};var u=/\s+/;var l=function(t,e,i,r){if(!i)return true;if(typeof i==="object"){for(var s in i){t[e].apply(t,[s,i[s]].concat(r))}return false}if(u.test(i)){var n=i.split(u);for(var a=0,h=n.length;a<h;a++){t[e].apply(t,[n[a]].concat(r))}return false}return true};var c=function(t,e){var i,r=-1,s=t.length,n=e[0],a=e[1],h=e[2];switch(e.length){case 0:while(++r<s)(i=t[r]).callback.call(i.ctx);return;case 1:while(++r<s)(i=t[r]).callback.call(i.ctx,n);return;case 2:while(++r<s)(i=t[r]).callback.call(i.ctx,n,a);return;case 3:while(++r<s)(i=t[r]).callback.call(i.ctx,n,a,h);return;default:while(++r<s)(i=t[r]).callback.apply(i.ctx,e)}};var f={listenTo:"on",listenToOnce:"once"};h.each(f,function(t,e){o[e]=function(e,i,r){var s=this._listeners||(this._listeners={});var n=e._listenerId||(e._listenerId=h.uniqueId("l"));s[n]=e;if(typeof i==="object")r=this;e[t](i,r,this);return this}});o.bind=o.on;o.unbind=o.off;h.extend(a,o);var d=a.Model=function(t,e){var i;var r=t||{};e||(e={});this.cid=h.uniqueId("c");this.attributes={};h.extend(this,h.pick(e,p));if(e.parse)r=this.parse(r,e)||{};if(i=h.result(this,"defaults")){r=h.defaults({},r,i)}this.set(r,e);this.changed={};this.initialize.apply(this,arguments)};var p=["url","urlRoot","collection"];h.extend(d.prototype,o,{changed:null,validationError:null,idAttribute:"id",initialize:function(){},toJSON:function(t){return h.clone(this.attributes)},sync:function(){return a.sync.apply(this,arguments)},get:function(t){return this.attributes[t]},escape:function(t){return h.escape(this.get(t))},has:function(t){return this.get(t)!=null},set:function(t,e,i){var r,s,n,a,o,u,l,c;if(t==null)return this;if(typeof t==="object"){s=t;i=e}else{(s={})[t]=e}i||(i={});if(!this._validate(s,i))return false;n=i.unset;o=i.silent;a=[];u=this._changing;this._changing=true;if(!u){this._previousAttributes=h.clone(this.attributes);this.changed={}}c=this.attributes,l=this._previousAttributes;if(this.idAttribute in s)this.id=s[this.idAttribute];for(r in s){e=s[r];if(!h.isEqual(c[r],e))a.push(r);if(!h.isEqual(l[r],e)){this.changed[r]=e}else{delete this.changed[r]}n?delete c[r]:c[r]=e}if(!o){if(a.length)this._pending=true;for(var f=0,d=a.length;f<d;f++){this.trigger("change:"+a[f],this,c[a[f]],i)}}if(u)return this;if(!o){while(this._pending){this._pending=false;this.trigger("change",this,i)}}this._pending=false;this._changing=false;return this},unset:function(t,e){return this.set(t,void 0,h.extend({},e,{unset:true}))},clear:function(t){var e={};for(var i in this.attributes)e[i]=void 0;return this.set(e,h.extend({},t,{unset:true}))},hasChanged:function(t){if(t==null)return!h.isEmpty(this.changed);return h.has(this.changed,t)},changedAttributes:function(t){if(!t)return this.hasChanged()?h.clone(this.changed):false;var e,i=false;var r=this._changing?this._previousAttributes:this.attributes;for(var s in t){if(h.isEqual(r[s],e=t[s]))continue;(i||(i={}))[s]=e}return i},previous:function(t){if(t==null||!this._previousAttributes)return null;return this._previousAttributes[t]},previousAttributes:function(){return h.clone(this._previousAttributes)},fetch:function(t){t=t?h.clone(t):{};if(t.parse===void 0)t.parse=true;var e=this;var i=t.success;t.success=function(r){if(!e.set(e.parse(r,t),t))return false;if(i)i(e,r,t);e.trigger("sync",e,r,t)};R(this,t);return this.sync("read",this,t)},save:function(t,e,i){var r,s,n,a=this.attributes;if(t==null||typeof t==="object"){r=t;i=e}else{(r={})[t]=e}if(r&&(!i||!i.wait)&&!this.set(r,i))return false;i=h.extend({validate:true},i);if(!this._validate(r,i))return false;if(r&&i.wait){this.attributes=h.extend({},a,r)}if(i.parse===void 0)i.parse=true;var o=this;var u=i.success;i.success=function(t){o.attributes=a;var e=o.parse(t,i);if(i.wait)e=h.extend(r||{},e);if(h.isObject(e)&&!o.set(e,i)){return false}if(u)u(o,t,i);o.trigger("sync",o,t,i)};R(this,i);s=this.isNew()?"create":i.patch?"patch":"update";if(s==="patch")i.attrs=r;n=this.sync(s,this,i);if(r&&i.wait)this.attributes=a;return n},destroy:function(t){t=t?h.clone(t):{};var e=this;var i=t.success;var r=function(){e.trigger("destroy",e,e.collection,t)};t.success=function(s){if(t.wait||e.isNew())r();if(i)i(e,s,t);if(!e.isNew())e.trigger("sync",e,s,t)};if(this.isNew()){t.success();return false}R(this,t);var s=this.sync("delete",this,t);if(!t.wait)r();return s},url:function(){var t=h.result(this,"urlRoot")||h.result(this.collection,"url")||U();if(this.isNew())return t;return t+(t.charAt(t.length-1)==="/"?"":"/")+encodeURIComponent(this.id)},parse:function(t,e){return t},clone:function(){return new this.constructor(this.attributes)},isNew:function(){return this.id==null},isValid:function(t){return this._validate({},h.extend(t||{},{validate:true}))},_validate:function(t,e){if(!e.validate||!this.validate)return true;t=h.extend({},this.attributes,t);var i=this.validationError=this.validate(t,e)||null;if(!i)return true;this.trigger("invalid",this,i,h.extend(e||{},{validationError:i}));return false}});var v=["keys","values","pairs","invert","pick","omit"];h.each(v,function(t){d.prototype[t]=function(){var e=s.call(arguments);e.unshift(this.attributes);return h[t].apply(h,e)}});var g=a.Collection=function(t,e){e||(e={});if(e.url)this.url=e.url;if(e.model)this.model=e.model;if(e.comparator!==void 0)this.comparator=e.comparator;this._reset();this.initialize.apply(this,arguments);if(t)this.reset(t,h.extend({silent:true},e))};var m={add:true,remove:true,merge:true};var y={add:true,merge:false,remove:false};h.extend(g.prototype,o,{model:d,initialize:function(){},toJSON:function(t){return this.map(function(e){return e.toJSON(t)})},sync:function(){return a.sync.apply(this,arguments)},add:function(t,e){return this.set(t,h.defaults(e||{},y))},remove:function(t,e){t=h.isArray(t)?t.slice():[t];e||(e={});var i,r,s,n;for(i=0,r=t.length;i<r;i++){n=this.get(t[i]);if(!n)continue;delete this._byId[n.id];delete this._byId[n.cid];s=this.indexOf(n);this.models.splice(s,1);this.length--;if(!e.silent){e.index=s;n.trigger("remove",n,this,e)}this._removeReference(n)}return this},set:function(t,e){e=h.defaults(e||{},m);if(e.parse)t=this.parse(t,e);if(!h.isArray(t))t=t?[t]:[];var i,s,a,o,u,l;var c=e.at;var f=this.comparator&&c==null&&e.sort!==false;var d=h.isString(this.comparator)?this.comparator:null;var p=[],v=[],g={};for(i=0,s=t.length;i<s;i++){if(!(a=this._prepareModel(t[i],e)))continue;if(u=this.get(a)){if(e.remove)g[u.cid]=true;if(e.merge){u.set(a.attributes,e);if(f&&!l&&u.hasChanged(d))l=true}}else if(e.add){p.push(a);a.on("all",this._onModelEvent,this);this._byId[a.cid]=a;if(a.id!=null)this._byId[a.id]=a}}if(e.remove){for(i=0,s=this.length;i<s;++i){if(!g[(a=this.models[i]).cid])v.push(a)}if(v.length)this.remove(v,e)}if(p.length){if(f)l=true;this.length+=p.length;if(c!=null){n.apply(this.models,[c,0].concat(p))}else{r.apply(this.models,p)}}if(l)this.sort({silent:true});if(e.silent)return this;for(i=0,s=p.length;i<s;i++){(a=p[i]).trigger("add",a,this,e)}if(l)this.trigger("sort",this,e);return this},reset:function(t,e){e||(e={});for(var i=0,r=this.models.length;i<r;i++){this._removeReference(this.models[i])}e.previousModels=this.models;this._reset();this.add(t,h.extend({silent:true},e));if(!e.silent)this.trigger("reset",this,e);return this},push:function(t,e){t=this._prepareModel(t,e);this.add(t,h.extend({at:this.length},e));return t},pop:function(t){var e=this.at(this.length-1);this.remove(e,t);return e},unshift:function(t,e){t=this._prepareModel(t,e);this.add(t,h.extend({at:0},e));return t},shift:function(t){var e=this.at(0);this.remove(e,t);return e},slice:function(t,e){return this.models.slice(t,e)},get:function(t){if(t==null)return void 0;return this._byId[t.id!=null?t.id:t.cid||t]},at:function(t){return this.models[t]},where:function(t,e){if(h.isEmpty(t))return e?void 0:[];return this[e?"find":"filter"](function(e){for(var i in t){if(t[i]!==e.get(i))return false}return true})},findWhere:function(t){return this.where(t,true)},sort:function(t){if(!this.comparator)throw new Error("Cannot sort a set without a comparator");t||(t={});if(h.isString(this.comparator)||this.comparator.length===1){this.models=this.sortBy(this.comparator,this)}else{this.models.sort(h.bind(this.comparator,this))}if(!t.silent)this.trigger("sort",this,t);return this},sortedIndex:function(t,e,i){e||(e=this.comparator);var r=h.isFunction(e)?e:function(t){return t.get(e)};return h.sortedIndex(this.models,t,r,i)},pluck:function(t){return h.invoke(this.models,"get",t)},fetch:function(t){t=t?h.clone(t):{};if(t.parse===void 0)t.parse=true;var e=t.success;var i=this;t.success=function(r){var s=t.reset?"reset":"set";i[s](r,t);if(e)e(i,r,t);i.trigger("sync",i,r,t)};R(this,t);return this.sync("read",this,t)},create:function(t,e){e=e?h.clone(e):{};if(!(t=this._prepareModel(t,e)))return false;if(!e.wait)this.add(t,e);var i=this;var r=e.success;e.success=function(s){if(e.wait)i.add(t,e);if(r)r(t,s,e)};t.save(null,e);return t},parse:function(t,e){return t},clone:function(){return new this.constructor(this.models)},_reset:function(){this.length=0;this.models=[];this._byId={}},_prepareModel:function(t,e){if(t instanceof d){if(!t.collection)t.collection=this;return t}e||(e={});e.collection=this;var i=new this.model(t,e);if(!i._validate(t,e)){this.trigger("invalid",this,t,e);return false}return i},_removeReference:function(t){if(this===t.collection)delete t.collection;t.off("all",this._onModelEvent,this)},_onModelEvent:function(t,e,i,r){if((t==="add"||t==="remove")&&i!==this)return;if(t==="destroy")this.remove(e,r);if(e&&t==="change:"+e.idAttribute){delete this._byId[e.previous(e.idAttribute)];if(e.id!=null)this._byId[e.id]=e}this.trigger.apply(this,arguments)}});var _=["forEach","each","map","collect","reduce","foldl","inject","reduceRight","foldr","find","detect","filter","select","reject","every","all","some","any","include","contains","invoke","max","min","toArray","size","first","head","take","initial","rest","tail","drop","last","without","indexOf","shuffle","lastIndexOf","isEmpty","chain"];h.each(_,function(t){g.prototype[t]=function(){var e=s.call(arguments);e.unshift(this.models);return h[t].apply(h,e)}});var w=["groupBy","countBy","sortBy"];h.each(w,function(t){g.prototype[t]=function(e,i){var r=h.isFunction(e)?e:function(t){return t.get(e)};return h[t](this.models,r,i)}});var b=a.View=function(t){this.cid=h.uniqueId("view");this._configure(t||{});this._ensureElement();this.initialize.apply(this,arguments);this.delegateEvents()};var x=/^(\S+)\s*(.*)$/;var E=["model","collection","el","id","attributes","className","tagName","events"];h.extend(b.prototype,o,{tagName:"div",$:function(t){return this.$el.find(t)},initialize:function(){},render:function(){return this},remove:function(){this.$el.remove();this.stopListening();return this},setElement:function(t,e){if(this.$el)this.undelegateEvents();this.$el=t instanceof a.$?t:a.$(t);this.el=this.$el[0];if(e!==false)this.delegateEvents();return this},delegateEvents:function(t){if(!(t||(t=h.result(this,"events"))))return this;this.undelegateEvents();for(var e in t){var i=t[e];if(!h.isFunction(i))i=this[t[e]];if(!i)continue;var r=e.match(x);var s=r[1],n=r[2];i=h.bind(i,this);s+=".delegateEvents"+this.cid;if(n===""){this.$el.on(s,i)}else{this.$el.on(s,n,i)}}return this},undelegateEvents:function(){this.$el.off(".delegateEvents"+this.cid);return this},_configure:function(t){if(this.options)t=h.extend({},h.result(this,"options"),t);h.extend(this,h.pick(t,E));this.options=t},_ensureElement:function(){if(!this.el){var t=h.extend({},h.result(this,"attributes"));if(this.id)t.id=h.result(this,"id");if(this.className)t["class"]=h.result(this,"className");var e=a.$("<"+h.result(this,"tagName")+">").attr(t);this.setElement(e,false)}else{this.setElement(h.result(this,"el"),false)}}});a.sync=function(t,e,i){var r=k[t];h.defaults(i||(i={}),{emulateHTTP:a.emulateHTTP,emulateJSON:a.emulateJSON});var s={type:r,dataType:"json"};if(!i.url){s.url=h.result(e,"url")||U()}if(i.data==null&&e&&(t==="create"||t==="update"||t==="patch")){s.contentType="application/json";s.data=JSON.stringify(i.attrs||e.toJSON(i))}if(i.emulateJSON){s.contentType="application/x-www-form-urlencoded";s.data=s.data?{model:s.data}:{}}if(i.emulateHTTP&&(r==="PUT"||r==="DELETE"||r==="PATCH")){s.type="POST";if(i.emulateJSON)s.data._method=r;var n=i.beforeSend;i.beforeSend=function(t){t.setRequestHeader("X-HTTP-Method-Override",r);if(n)return n.apply(this,arguments)}}if(s.type!=="GET"&&!i.emulateJSON){s.processData=false}if(s.type==="PATCH"&&window.ActiveXObject&&!(window.external&&window.external.msActiveXFilteringEnabled)){s.xhr=function(){return new ActiveXObject("Microsoft.XMLHTTP")}}var o=i.xhr=a.ajax(h.extend(s,i));e.trigger("request",e,o,i);return o};var k={create:"POST",update:"PUT",patch:"PATCH","delete":"DELETE",read:"GET"};a.ajax=function(){return a.$.ajax.apply(a.$,arguments)};var S=a.Router=function(t){t||(t={});if(t.routes)this.routes=t.routes;this._bindRoutes();this.initialize.apply(this,arguments)};var $=/\((.*?)\)/g;var T=/(\(\?)?:\w+/g;var H=/\*\w+/g;var A=/[\-{}\[\]+?.,\\\^$|#\s]/g;h.extend(S.prototype,o,{initialize:function(){},route:function(t,e,i){if(!h.isRegExp(t))t=this._routeToRegExp(t);if(h.isFunction(e)){i=e;e=""}if(!i)i=this[e];var r=this;a.history.route(t,function(s){var n=r._extractParameters(t,s);i&&i.apply(r,n);r.trigger.apply(r,["route:"+e].concat(n));r.trigger("route",e,n);a.history.trigger("route",r,e,n)});return this},navigate:function(t,e){a.history.navigate(t,e);return this},_bindRoutes:function(){if(!this.routes)return;this.routes=h.result(this,"routes");var t,e=h.keys(this.routes);while((t=e.pop())!=null){this.route(t,this.routes[t])}},_routeToRegExp:function(t){t=t.replace(A,"\\$&").replace($,"(?:$1)?").replace(T,function(t,e){return e?t:"([^/]+)"}).replace(H,"(.*?)");return new RegExp("^"+t+"$")},_extractParameters:function(t,e){var i=t.exec(e).slice(1);return h.map(i,function(t){return t?decodeURIComponent(t):null})}});var I=a.History=function(){this.handlers=[];h.bindAll(this,"checkUrl");if(typeof window!=="undefined"){this.location=window.location;this.history=window.history}};var N=/^[#\/]|\s+$/g;var P=/^\/+|\/+$/g;var O=/msie [\w.]+/;var C=/\/$/;I.started=false;h.extend(I.prototype,o,{interval:50,getHash:function(t){var e=(t||this).location.href.match(/#(.*)$/);return e?e[1]:""},getFragment:function(t,e){if(t==null){if(this._hasPushState||!this._wantsHashChange||e){t=this.location.pathname;var i=this.root.replace(C,"");if(!t.indexOf(i))t=t.substr(i.length)}else{t=this.getHash()}}return t.replace(N,"")},start:function(t){if(I.started)throw new Error("Backbone.history has already been started");I.started=true;this.options=h.extend({},{root:"/"},this.options,t);this.root=this.options.root;this._wantsHashChange=this.options.hashChange!==false;this._wantsPushState=!!this.options.pushState;this._hasPushState=!!(this.options.pushState&&this.history&&this.history.pushState);var e=this.getFragment();var i=document.documentMode;var r=O.exec(navigator.userAgent.toLowerCase())&&(!i||i<=7);this.root=("/"+this.root+"/").replace(P,"/");if(r&&this._wantsHashChange){this.iframe=a.$('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo("body")[0].contentWindow;this.navigate(e)}if(this._hasPushState){a.$(window).on("popstate",this.checkUrl)}else if(this._wantsHashChange&&"onhashchange"in window&&!r){a.$(window).on("hashchange",this.checkUrl)}else if(this._wantsHashChange){this._checkUrlInterval=setInterval(this.checkUrl,this.interval)}this.fragment=e;var s=this.location;var n=s.pathname.replace(/[^\/]$/,"$&/")===this.root;if(this._wantsHashChange&&this._wantsPushState&&!this._hasPushState&&!n){this.fragment=this.getFragment(null,true);this.location.replace(this.root+this.location.search+"#"+this.fragment);return true}else if(this._wantsPushState&&this._hasPushState&&n&&s.hash){this.fragment=this.getHash().replace(N,"");this.history.replaceState({},document.title,this.root+this.fragment+s.search)}if(!this.options.silent)return this.loadUrl()},stop:function(){a.$(window).off("popstate",this.checkUrl).off("hashchange",this.checkUrl);clearInterval(this._checkUrlInterval);I.started=false},route:function(t,e){this.handlers.unshift({route:t,callback:e})},checkUrl:function(t){var e=this.getFragment();if(e===this.fragment&&this.iframe){e=this.getFragment(this.getHash(this.iframe))}if(e===this.fragment)return false;if(this.iframe)this.navigate(e);this.loadUrl()||this.loadUrl(this.getHash())},loadUrl:function(t){var e=this.fragment=this.getFragment(t);var i=h.any(this.handlers,function(t){if(t.route.test(e)){t.callback(e);return true}});return i},navigate:function(t,e){if(!I.started)return false;if(!e||e===true)e={trigger:e};t=this.getFragment(t||"");if(this.fragment===t)return;this.fragment=t;var i=this.root+t;if(this._hasPushState){this.history[e.replace?"replaceState":"pushState"]({},document.title,i)}else if(this._wantsHashChange){this._updateHash(this.location,t,e.replace);if(this.iframe&&t!==this.getFragment(this.getHash(this.iframe))){if(!e.replace)this.iframe.document.open().close();this._updateHash(this.iframe.location,t,e.replace)}}else{return this.location.assign(i)}if(e.trigger)this.loadUrl(t)},_updateHash:function(t,e,i){if(i){var r=t.href.replace(/(javascript:|#).*$/,"");t.replace(r+"#"+e)}else{t.hash="#"+e}}});a.history=new I;var j=function(t,e){var i=this;var r;if(t&&h.has(t,"constructor")){r=t.constructor}else{r=function(){return i.apply(this,arguments)}}h.extend(r,i,e);var s=function(){this.constructor=r};s.prototype=i.prototype;r.prototype=new s;if(t)h.extend(r.prototype,t);r.__super__=i.prototype;return r};d.extend=g.extend=S.extend=b.extend=I.extend=j;var U=function(){throw new Error('A "url" property or function must be specified')};var R=function(t,e){var i=e.error;e.error=function(r){if(i)i(t,r,e);t.trigger("error",t,r,e)}}}).call(this);
-/*
-//@ sourceMappingURL=backbone-min.map
-*/
\ No newline at end of file
diff --git a/app/assets/javascripts/bootstrap-min.js b/vendor/assets/javascripts/bootstrap-min.js
similarity index 100%
rename from app/assets/javascripts/bootstrap-min.js
rename to vendor/assets/javascripts/bootstrap-min.js
diff --git a/app/assets/javascripts/bootstrap.file-input.js b/vendor/assets/javascripts/bootstrap.file-input.js
similarity index 100%
rename from app/assets/javascripts/bootstrap.file-input.js
rename to vendor/assets/javascripts/bootstrap.file-input.js
diff --git a/app/assets/javascripts/jquery-min.js b/vendor/assets/javascripts/jquery-min.js
similarity index 99%
rename from app/assets/javascripts/jquery-min.js
rename to vendor/assets/javascripts/jquery-min.js
index 006e95310..8306fd00a 100644
--- a/app/assets/javascripts/jquery-min.js
+++ b/vendor/assets/javascripts/jquery-min.js
@@ -1,5 +1,4 @@
 /*! jQuery v1.9.1 | (c) 2005, 2012 jQuery Foundation, Inc. | jquery.org/license
-//@ sourceMappingURL=jquery.min.map
 */(function(e,t){var n,r,i=typeof t,o=e.document,a=e.location,s=e.jQuery,u=e.$,l={},c=[],p="1.9.1",f=c.concat,d=c.push,h=c.slice,g=c.indexOf,m=l.toString,y=l.hasOwnProperty,v=p.trim,b=function(e,t){return new b.fn.init(e,t,r)},x=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,w=/\S+/g,T=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,N=/^(?:(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,k=/^[\],:{}\s]*$/,E=/(?:^|:|,)(?:\s*\[)+/g,S=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,A=/"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g,j=/^-ms-/,D=/-([\da-z])/gi,L=function(e,t){return t.toUpperCase()},H=function(e){(o.addEventListener||"load"===e.type||"complete"===o.readyState)&&(q(),b.ready())},q=function(){o.addEventListener?(o.removeEventListener("DOMContentLoaded",H,!1),e.removeEventListener("load",H,!1)):(o.detachEvent("onreadystatechange",H),e.detachEvent("onload",H))};b.fn=b.prototype={jquery:p,constructor:b,init:function(e,n,r){var i,a;if(!e)return this;if("string"==typeof e){if(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:N.exec(e),!i||!i[1]&&n)return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e);if(i[1]){if(n=n instanceof b?n[0]:n,b.merge(this,b.parseHTML(i[1],n&&n.nodeType?n.ownerDocument||n:o,!0)),C.test(i[1])&&b.isPlainObject(n))for(i in n)b.isFunction(this[i])?this[i](n[i]):this.attr(i,n[i]);return this}if(a=o.getElementById(i[2]),a&&a.parentNode){if(a.id!==i[2])return r.find(e);this.length=1,this[0]=a}return this.context=o,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):b.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),b.makeArray(e,this))},selector:"",length:0,size:function(){return this.length},toArray:function(){return h.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=b.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return b.each(this,e,t)},ready:function(e){return b.ready.promise().done(e),this},slice:function(){return this.pushStack(h.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(b.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:d,sort:[].sort,splice:[].splice},b.fn.init.prototype=b.fn,b.extend=b.fn.extend=function(){var e,n,r,i,o,a,s=arguments[0]||{},u=1,l=arguments.length,c=!1;for("boolean"==typeof s&&(c=s,s=arguments[1]||{},u=2),"object"==typeof s||b.isFunction(s)||(s={}),l===u&&(s=this,--u);l>u;u++)if(null!=(o=arguments[u]))for(i in o)e=s[i],r=o[i],s!==r&&(c&&r&&(b.isPlainObject(r)||(n=b.isArray(r)))?(n?(n=!1,a=e&&b.isArray(e)?e:[]):a=e&&b.isPlainObject(e)?e:{},s[i]=b.extend(c,a,r)):r!==t&&(s[i]=r));return s},b.extend({noConflict:function(t){return e.$===b&&(e.$=u),t&&e.jQuery===b&&(e.jQuery=s),b},isReady:!1,readyWait:1,holdReady:function(e){e?b.readyWait++:b.ready(!0)},ready:function(e){if(e===!0?!--b.readyWait:!b.isReady){if(!o.body)return setTimeout(b.ready);b.isReady=!0,e!==!0&&--b.readyWait>0||(n.resolveWith(o,[b]),b.fn.trigger&&b(o).trigger("ready").off("ready"))}},isFunction:function(e){return"function"===b.type(e)},isArray:Array.isArray||function(e){return"array"===b.type(e)},isWindow:function(e){return null!=e&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[m.call(e)]||"object":typeof e},isPlainObject:function(e){if(!e||"object"!==b.type(e)||e.nodeType||b.isWindow(e))return!1;try{if(e.constructor&&!y.call(e,"constructor")&&!y.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(n){return!1}var r;for(r in e);return r===t||y.call(e,r)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||o;var r=C.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=b.buildFragment([e],t,i),i&&b(i).remove(),b.merge([],r.childNodes))},parseJSON:function(n){return e.JSON&&e.JSON.parse?e.JSON.parse(n):null===n?n:"string"==typeof n&&(n=b.trim(n),n&&k.test(n.replace(S,"@").replace(A,"]").replace(E,"")))?Function("return "+n)():(b.error("Invalid JSON: "+n),t)},parseXML:function(n){var r,i;if(!n||"string"!=typeof n)return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(o){r=t}return r&&r.documentElement&&!r.getElementsByTagName("parsererror").length||b.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&b.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(j,"ms-").replace(D,L)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,n){var r,i=0,o=e.length,a=M(e);if(n){if(a){for(;o>i;i++)if(r=t.apply(e[i],n),r===!1)break}else for(i in e)if(r=t.apply(e[i],n),r===!1)break}else if(a){for(;o>i;i++)if(r=t.call(e[i],i,e[i]),r===!1)break}else for(i in e)if(r=t.call(e[i],i,e[i]),r===!1)break;return e},trim:v&&!v.call("\ufeff\u00a0")?function(e){return null==e?"":v.call(e)}:function(e){return null==e?"":(e+"").replace(T,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(M(Object(e))?b.merge(n,"string"==typeof e?[e]:e):d.call(n,e)),n},inArray:function(e,t,n){var r;if(t){if(g)return g.call(t,e,n);for(r=t.length,n=n?0>n?Math.max(0,r+n):n:0;r>n;n++)if(n in t&&t[n]===e)return n}return-1},merge:function(e,n){var r=n.length,i=e.length,o=0;if("number"==typeof r)for(;r>o;o++)e[i++]=n[o];else while(n[o]!==t)e[i++]=n[o++];return e.length=i,e},grep:function(e,t,n){var r,i=[],o=0,a=e.length;for(n=!!n;a>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,n){var r,i=0,o=e.length,a=M(e),s=[];if(a)for(;o>i;i++)r=t(e[i],i,n),null!=r&&(s[s.length]=r);else for(i in e)r=t(e[i],i,n),null!=r&&(s[s.length]=r);return f.apply([],s)},guid:1,proxy:function(e,n){var r,i,o;return"string"==typeof n&&(o=e[n],n=e,e=o),b.isFunction(e)?(r=h.call(arguments,2),i=function(){return e.apply(n||this,r.concat(h.call(arguments)))},i.guid=e.guid=e.guid||b.guid++,i):t},access:function(e,n,r,i,o,a,s){var u=0,l=e.length,c=null==r;if("object"===b.type(r)){o=!0;for(u in r)b.access(e,n,u,r[u],!0,a,s)}else if(i!==t&&(o=!0,b.isFunction(i)||(s=!0),c&&(s?(n.call(e,i),n=null):(c=n,n=function(e,t,n){return c.call(b(e),n)})),n))for(;l>u;u++)n(e[u],r,s?i:i.call(e[u],u,n(e[u],r)));return o?e:c?n.call(e):l?n(e[0],r):a},now:function(){return(new Date).getTime()}}),b.ready.promise=function(t){if(!n)if(n=b.Deferred(),"complete"===o.readyState)setTimeout(b.ready);else if(o.addEventListener)o.addEventListener("DOMContentLoaded",H,!1),e.addEventListener("load",H,!1);else{o.attachEvent("onreadystatechange",H),e.attachEvent("onload",H);var r=!1;try{r=null==e.frameElement&&o.documentElement}catch(i){}r&&r.doScroll&&function a(){if(!b.isReady){try{r.doScroll("left")}catch(e){return setTimeout(a,50)}q(),b.ready()}}()}return n.promise(t)},b.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){l["[object "+t+"]"]=t.toLowerCase()});function M(e){var t=e.length,n=b.type(e);return b.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}r=b(o);var _={};function F(e){var t=_[e]={};return b.each(e.match(w)||[],function(e,n){t[n]=!0}),t}b.Callbacks=function(e){e="string"==typeof e?_[e]||F(e):b.extend({},e);var n,r,i,o,a,s,u=[],l=!e.once&&[],c=function(t){for(r=e.memory&&t,i=!0,a=s||0,s=0,o=u.length,n=!0;u&&o>a;a++)if(u[a].apply(t[0],t[1])===!1&&e.stopOnFalse){r=!1;break}n=!1,u&&(l?l.length&&c(l.shift()):r?u=[]:p.disable())},p={add:function(){if(u){var t=u.length;(function i(t){b.each(t,function(t,n){var r=b.type(n);"function"===r?e.unique&&p.has(n)||u.push(n):n&&n.length&&"string"!==r&&i(n)})})(arguments),n?o=u.length:r&&(s=t,c(r))}return this},remove:function(){return u&&b.each(arguments,function(e,t){var r;while((r=b.inArray(t,u,r))>-1)u.splice(r,1),n&&(o>=r&&o--,a>=r&&a--)}),this},has:function(e){return e?b.inArray(e,u)>-1:!(!u||!u.length)},empty:function(){return u=[],this},disable:function(){return u=l=r=t,this},disabled:function(){return!u},lock:function(){return l=t,r||p.disable(),this},locked:function(){return!l},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],!u||i&&!l||(n?l.push(t):c(t)),this},fire:function(){return p.fireWith(this,arguments),this},fired:function(){return!!i}};return p},b.extend({Deferred:function(e){var t=[["resolve","done",b.Callbacks("once memory"),"resolved"],["reject","fail",b.Callbacks("once memory"),"rejected"],["notify","progress",b.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return b.Deferred(function(n){b.each(t,function(t,o){var a=o[0],s=b.isFunction(e[t])&&e[t];i[o[1]](function(){var e=s&&s.apply(this,arguments);e&&b.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[a+"With"](this===r?n.promise():this,s?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?b.extend(e,r):r}},i={};return r.pipe=r.then,b.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=h.call(arguments),r=n.length,i=1!==r||e&&b.isFunction(e.promise)?r:0,o=1===i?e:b.Deferred(),a=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?h.call(arguments):r,n===s?o.notifyWith(t,n):--i||o.resolveWith(t,n)}},s,u,l;if(r>1)for(s=Array(r),u=Array(r),l=Array(r);r>t;t++)n[t]&&b.isFunction(n[t].promise)?n[t].promise().done(a(t,l,n)).fail(o.reject).progress(a(t,u,s)):--i;return i||o.resolveWith(l,n),o.promise()}}),b.support=function(){var t,n,r,a,s,u,l,c,p,f,d=o.createElement("div");if(d.setAttribute("className","t"),d.innerHTML="  <link/><table></table><a href='/a'>a</a><input type='checkbox'/>",n=d.getElementsByTagName("*"),r=d.getElementsByTagName("a")[0],!n||!r||!n.length)return{};s=o.createElement("select"),l=s.appendChild(o.createElement("option")),a=d.getElementsByTagName("input")[0],r.style.cssText="top:1px;float:left;opacity:.5",t={getSetAttribute:"t"!==d.className,leadingWhitespace:3===d.firstChild.nodeType,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/top/.test(r.getAttribute("style")),hrefNormalized:"/a"===r.getAttribute("href"),opacity:/^0.5/.test(r.style.opacity),cssFloat:!!r.style.cssFloat,checkOn:!!a.value,optSelected:l.selected,enctype:!!o.createElement("form").enctype,html5Clone:"<:nav></:nav>"!==o.createElement("nav").cloneNode(!0).outerHTML,boxModel:"CSS1Compat"===o.compatMode,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0,boxSizingReliable:!0,pixelPosition:!1},a.checked=!0,t.noCloneChecked=a.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!l.disabled;try{delete d.test}catch(h){t.deleteExpando=!1}a=o.createElement("input"),a.setAttribute("value",""),t.input=""===a.getAttribute("value"),a.value="t",a.setAttribute("type","radio"),t.radioValue="t"===a.value,a.setAttribute("checked","t"),a.setAttribute("name","t"),u=o.createDocumentFragment(),u.appendChild(a),t.appendChecked=a.checked,t.checkClone=u.cloneNode(!0).cloneNode(!0).lastChild.checked,d.attachEvent&&(d.attachEvent("onclick",function(){t.noCloneEvent=!1}),d.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})d.setAttribute(c="on"+f,"t"),t[f+"Bubbles"]=c in e||d.attributes[c].expando===!1;return d.style.backgroundClip="content-box",d.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===d.style.backgroundClip,b(function(){var n,r,a,s="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",u=o.getElementsByTagName("body")[0];u&&(n=o.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",u.appendChild(n).appendChild(d),d.innerHTML="<table><tr><td></td><td>t</td></tr></table>",a=d.getElementsByTagName("td"),a[0].style.cssText="padding:0;margin:0;border:0;display:none",p=0===a[0].offsetHeight,a[0].style.display="",a[1].style.display="none",t.reliableHiddenOffsets=p&&0===a[0].offsetHeight,d.innerHTML="",d.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",t.boxSizing=4===d.offsetWidth,t.doesNotIncludeMarginInBodyOffset=1!==u.offsetTop,e.getComputedStyle&&(t.pixelPosition="1%"!==(e.getComputedStyle(d,null)||{}).top,t.boxSizingReliable="4px"===(e.getComputedStyle(d,null)||{width:"4px"}).width,r=d.appendChild(o.createElement("div")),r.style.cssText=d.style.cssText=s,r.style.marginRight=r.style.width="0",d.style.width="1px",t.reliableMarginRight=!parseFloat((e.getComputedStyle(r,null)||{}).marginRight)),typeof d.style.zoom!==i&&(d.innerHTML="",d.style.cssText=s+"width:1px;padding:1px;display:inline;zoom:1",t.inlineBlockNeedsLayout=3===d.offsetWidth,d.style.display="block",d.innerHTML="<div></div>",d.firstChild.style.width="5px",t.shrinkWrapBlocks=3!==d.offsetWidth,t.inlineBlockNeedsLayout&&(u.style.zoom=1)),u.removeChild(n),n=d=a=r=null)}),n=s=u=l=r=a=null,t}();var O=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,B=/([A-Z])/g;function P(e,n,r,i){if(b.acceptData(e)){var o,a,s=b.expando,u="string"==typeof n,l=e.nodeType,p=l?b.cache:e,f=l?e[s]:e[s]&&s;if(f&&p[f]&&(i||p[f].data)||!u||r!==t)return f||(l?e[s]=f=c.pop()||b.guid++:f=s),p[f]||(p[f]={},l||(p[f].toJSON=b.noop)),("object"==typeof n||"function"==typeof n)&&(i?p[f]=b.extend(p[f],n):p[f].data=b.extend(p[f].data,n)),o=p[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[b.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[b.camelCase(n)])):a=o,a}}function R(e,t,n){if(b.acceptData(e)){var r,i,o,a=e.nodeType,s=a?b.cache:e,u=a?e[b.expando]:b.expando;if(s[u]){if(t&&(o=n?s[u]:s[u].data)){b.isArray(t)?t=t.concat(b.map(t,b.camelCase)):t in o?t=[t]:(t=b.camelCase(t),t=t in o?[t]:t.split(" "));for(r=0,i=t.length;i>r;r++)delete o[t[r]];if(!(n?$:b.isEmptyObject)(o))return}(n||(delete s[u].data,$(s[u])))&&(a?b.cleanData([e],!0):b.support.deleteExpando||s!=s.window?delete s[u]:s[u]=null)}}}b.extend({cache:{},expando:"jQuery"+(p+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(e){return e=e.nodeType?b.cache[e[b.expando]]:e[b.expando],!!e&&!$(e)},data:function(e,t,n){return P(e,t,n)},removeData:function(e,t){return R(e,t)},_data:function(e,t,n){return P(e,t,n,!0)},_removeData:function(e,t){return R(e,t,!0)},acceptData:function(e){if(e.nodeType&&1!==e.nodeType&&9!==e.nodeType)return!1;var t=e.nodeName&&b.noData[e.nodeName.toLowerCase()];return!t||t!==!0&&e.getAttribute("classid")===t}}),b.fn.extend({data:function(e,n){var r,i,o=this[0],a=0,s=null;if(e===t){if(this.length&&(s=b.data(o),1===o.nodeType&&!b._data(o,"parsedAttrs"))){for(r=o.attributes;r.length>a;a++)i=r[a].name,i.indexOf("data-")||(i=b.camelCase(i.slice(5)),W(o,i,s[i]));b._data(o,"parsedAttrs",!0)}return s}return"object"==typeof e?this.each(function(){b.data(this,e)}):b.access(this,function(n){return n===t?o?W(o,e,b.data(o,e)):null:(this.each(function(){b.data(this,e,n)}),t)},null,n,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){b.removeData(this,e)})}});function W(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(B,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:O.test(r)?b.parseJSON(r):r}catch(o){}b.data(e,n,r)}else r=t}return r}function $(e){var t;for(t in e)if(("data"!==t||!b.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}b.extend({queue:function(e,n,r){var i;return e?(n=(n||"fx")+"queue",i=b._data(e,n),r&&(!i||b.isArray(r)?i=b._data(e,n,b.makeArray(r)):i.push(r)),i||[]):t},dequeue:function(e,t){t=t||"fx";var n=b.queue(e,t),r=n.length,i=n.shift(),o=b._queueHooks(e,t),a=function(){b.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),o.cur=i,i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return b._data(e,n)||b._data(e,n,{empty:b.Callbacks("once memory").add(function(){b._removeData(e,t+"queue"),b._removeData(e,n)})})}}),b.fn.extend({queue:function(e,n){var r=2;return"string"!=typeof e&&(n=e,e="fx",r--),r>arguments.length?b.queue(this[0],e):n===t?this:this.each(function(){var t=b.queue(this,e,n);b._queueHooks(this,e),"fx"===e&&"inprogress"!==t[0]&&b.dequeue(this,e)})},dequeue:function(e){return this.each(function(){b.dequeue(this,e)})},delay:function(e,t){return e=b.fx?b.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,n){var r,i=1,o=b.Deferred(),a=this,s=this.length,u=function(){--i||o.resolveWith(a,[a])};"string"!=typeof e&&(n=e,e=t),e=e||"fx";while(s--)r=b._data(a[s],e+"queueHooks"),r&&r.empty&&(i++,r.empty.add(u));return u(),o.promise(n)}});var I,z,X=/[\t\r\n]/g,U=/\r/g,V=/^(?:input|select|textarea|button|object)$/i,Y=/^(?:a|area)$/i,J=/^(?:checked|selected|autofocus|autoplay|async|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped)$/i,G=/^(?:checked|selected)$/i,Q=b.support.getSetAttribute,K=b.support.input;b.fn.extend({attr:function(e,t){return b.access(this,b.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){b.removeAttr(this,e)})},prop:function(e,t){return b.access(this,b.prop,e,t,arguments.length>1)},removeProp:function(e){return e=b.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,o,a=0,s=this.length,u="string"==typeof e&&e;if(b.isFunction(e))return this.each(function(t){b(this).addClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(X," "):" ")){o=0;while(i=t[o++])0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=b.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,a=0,s=this.length,u=0===arguments.length||"string"==typeof e&&e;if(b.isFunction(e))return this.each(function(t){b(this).removeClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(X," "):"")){o=0;while(i=t[o++])while(r.indexOf(" "+i+" ")>=0)r=r.replace(" "+i+" "," ");n.className=e?b.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e,r="boolean"==typeof t;return b.isFunction(e)?this.each(function(n){b(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n){var o,a=0,s=b(this),u=t,l=e.match(w)||[];while(o=l[a++])u=r?u:!s.hasClass(o),s[u?"addClass":"removeClass"](o)}else(n===i||"boolean"===n)&&(this.className&&b._data(this,"__className__",this.className),this.className=this.className||e===!1?"":b._data(this,"__className__")||"")})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(X," ").indexOf(t)>=0)return!0;return!1},val:function(e){var n,r,i,o=this[0];{if(arguments.length)return i=b.isFunction(e),this.each(function(n){var o,a=b(this);1===this.nodeType&&(o=i?e.call(this,n,a.val()):e,null==o?o="":"number"==typeof o?o+="":b.isArray(o)&&(o=b.map(o,function(e){return null==e?"":e+""})),r=b.valHooks[this.type]||b.valHooks[this.nodeName.toLowerCase()],r&&"set"in r&&r.set(this,o,"value")!==t||(this.value=o))});if(o)return r=b.valHooks[o.type]||b.valHooks[o.nodeName.toLowerCase()],r&&"get"in r&&(n=r.get(o,"value"))!==t?n:(n=o.value,"string"==typeof n?n.replace(U,""):null==n?"":n)}}}),b.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,a=o?null:[],s=o?i+1:r.length,u=0>i?s:o?i:0;for(;s>u;u++)if(n=r[u],!(!n.selected&&u!==i||(b.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&b.nodeName(n.parentNode,"optgroup"))){if(t=b(n).val(),o)return t;a.push(t)}return a},set:function(e,t){var n=b.makeArray(t);return b(e).find("option").each(function(){this.selected=b.inArray(b(this).val(),n)>=0}),n.length||(e.selectedIndex=-1),n}}},attr:function(e,n,r){var o,a,s,u=e.nodeType;if(e&&3!==u&&8!==u&&2!==u)return typeof e.getAttribute===i?b.prop(e,n,r):(a=1!==u||!b.isXMLDoc(e),a&&(n=n.toLowerCase(),o=b.attrHooks[n]||(J.test(n)?z:I)),r===t?o&&a&&"get"in o&&null!==(s=o.get(e,n))?s:(typeof e.getAttribute!==i&&(s=e.getAttribute(n)),null==s?t:s):null!==r?o&&a&&"set"in o&&(s=o.set(e,r,n))!==t?s:(e.setAttribute(n,r+""),r):(b.removeAttr(e,n),t))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(w);if(o&&1===e.nodeType)while(n=o[i++])r=b.propFix[n]||n,J.test(n)?!Q&&G.test(n)?e[b.camelCase("default-"+n)]=e[r]=!1:e[r]=!1:b.attr(e,n,""),e.removeAttribute(Q?n:r)},attrHooks:{type:{set:function(e,t){if(!b.support.radioValue&&"radio"===t&&b.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{tabindex:"tabIndex",readonly:"readOnly","for":"htmlFor","class":"className",maxlength:"maxLength",cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},prop:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return a=1!==s||!b.isXMLDoc(e),a&&(n=b.propFix[n]||n,o=b.propHooks[n]),r!==t?o&&"set"in o&&(i=o.set(e,r,n))!==t?i:e[n]=r:o&&"get"in o&&null!==(i=o.get(e,n))?i:e[n]},propHooks:{tabIndex:{get:function(e){var n=e.getAttributeNode("tabindex");return n&&n.specified?parseInt(n.value,10):V.test(e.nodeName)||Y.test(e.nodeName)&&e.href?0:t}}}}),z={get:function(e,n){var r=b.prop(e,n),i="boolean"==typeof r&&e.getAttribute(n),o="boolean"==typeof r?K&&Q?null!=i:G.test(n)?e[b.camelCase("default-"+n)]:!!i:e.getAttributeNode(n);return o&&o.value!==!1?n.toLowerCase():t},set:function(e,t,n){return t===!1?b.removeAttr(e,n):K&&Q||!G.test(n)?e.setAttribute(!Q&&b.propFix[n]||n,n):e[b.camelCase("default-"+n)]=e[n]=!0,n}},K&&Q||(b.attrHooks.value={get:function(e,n){var r=e.getAttributeNode(n);return b.nodeName(e,"input")?e.defaultValue:r&&r.specified?r.value:t},set:function(e,n,r){return b.nodeName(e,"input")?(e.defaultValue=n,t):I&&I.set(e,n,r)}}),Q||(I=b.valHooks.button={get:function(e,n){var r=e.getAttributeNode(n);return r&&("id"===n||"name"===n||"coords"===n?""!==r.value:r.specified)?r.value:t},set:function(e,n,r){var i=e.getAttributeNode(r);return i||e.setAttributeNode(i=e.ownerDocument.createAttribute(r)),i.value=n+="","value"===r||n===e.getAttribute(r)?n:t}},b.attrHooks.contenteditable={get:I.get,set:function(e,t,n){I.set(e,""===t?!1:t,n)}},b.each(["width","height"],function(e,n){b.attrHooks[n]=b.extend(b.attrHooks[n],{set:function(e,r){return""===r?(e.setAttribute(n,"auto"),r):t}})})),b.support.hrefNormalized||(b.each(["href","src","width","height"],function(e,n){b.attrHooks[n]=b.extend(b.attrHooks[n],{get:function(e){var r=e.getAttribute(n,2);return null==r?t:r}})}),b.each(["href","src"],function(e,t){b.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}})),b.support.style||(b.attrHooks.style={get:function(e){return e.style.cssText||t},set:function(e,t){return e.style.cssText=t+""}}),b.support.optSelected||(b.propHooks.selected=b.extend(b.propHooks.selected,{get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null}})),b.support.enctype||(b.propFix.enctype="encoding"),b.support.checkOn||b.each(["radio","checkbox"],function(){b.valHooks[this]={get:function(e){return null===e.getAttribute("value")?"on":e.value}}}),b.each(["radio","checkbox"],function(){b.valHooks[this]=b.extend(b.valHooks[this],{set:function(e,n){return b.isArray(n)?e.checked=b.inArray(b(e).val(),n)>=0:t}})});var Z=/^(?:input|select|textarea)$/i,et=/^key/,tt=/^(?:mouse|contextmenu)|click/,nt=/^(?:focusinfocus|focusoutblur)$/,rt=/^([^.]*)(?:\.(.+)|)$/;function it(){return!0}function ot(){return!1}b.event={global:{},add:function(e,n,r,o,a){var s,u,l,c,p,f,d,h,g,m,y,v=b._data(e);if(v){r.handler&&(c=r,r=c.handler,a=c.selector),r.guid||(r.guid=b.guid++),(u=v.events)||(u=v.events={}),(f=v.handle)||(f=v.handle=function(e){return typeof b===i||e&&b.event.triggered===e.type?t:b.event.dispatch.apply(f.elem,arguments)},f.elem=e),n=(n||"").match(w)||[""],l=n.length;while(l--)s=rt.exec(n[l])||[],g=y=s[1],m=(s[2]||"").split(".").sort(),p=b.event.special[g]||{},g=(a?p.delegateType:p.bindType)||g,p=b.event.special[g]||{},d=b.extend({type:g,origType:y,data:o,handler:r,guid:r.guid,selector:a,needsContext:a&&b.expr.match.needsContext.test(a),namespace:m.join(".")},c),(h=u[g])||(h=u[g]=[],h.delegateCount=0,p.setup&&p.setup.call(e,o,m,f)!==!1||(e.addEventListener?e.addEventListener(g,f,!1):e.attachEvent&&e.attachEvent("on"+g,f))),p.add&&(p.add.call(e,d),d.handler.guid||(d.handler.guid=r.guid)),a?h.splice(h.delegateCount++,0,d):h.push(d),b.event.global[g]=!0;e=null}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,p,f,d,h,g,m=b.hasData(e)&&b._data(e);if(m&&(c=m.events)){t=(t||"").match(w)||[""],l=t.length;while(l--)if(s=rt.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){p=b.event.special[d]||{},d=(r?p.delegateType:p.bindType)||d,f=c[d]||[],s=s[2]&&RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),u=o=f.length;while(o--)a=f[o],!i&&g!==a.origType||n&&n.guid!==a.guid||s&&!s.test(a.namespace)||r&&r!==a.selector&&("**"!==r||!a.selector)||(f.splice(o,1),a.selector&&f.delegateCount--,p.remove&&p.remove.call(e,a));u&&!f.length&&(p.teardown&&p.teardown.call(e,h,m.handle)!==!1||b.removeEvent(e,d,m.handle),delete c[d])}else for(d in c)b.event.remove(e,d+t[l],n,r,!0);b.isEmptyObject(c)&&(delete m.handle,b._removeData(e,"events"))}},trigger:function(n,r,i,a){var s,u,l,c,p,f,d,h=[i||o],g=y.call(n,"type")?n.type:n,m=y.call(n,"namespace")?n.namespace.split("."):[];if(l=f=i=i||o,3!==i.nodeType&&8!==i.nodeType&&!nt.test(g+b.event.triggered)&&(g.indexOf(".")>=0&&(m=g.split("."),g=m.shift(),m.sort()),u=0>g.indexOf(":")&&"on"+g,n=n[b.expando]?n:new b.Event(g,"object"==typeof n&&n),n.isTrigger=!0,n.namespace=m.join("."),n.namespace_re=n.namespace?RegExp("(^|\\.)"+m.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,n.result=t,n.target||(n.target=i),r=null==r?[n]:b.makeArray(r,[n]),p=b.event.special[g]||{},a||!p.trigger||p.trigger.apply(i,r)!==!1)){if(!a&&!p.noBubble&&!b.isWindow(i)){for(c=p.delegateType||g,nt.test(c+g)||(l=l.parentNode);l;l=l.parentNode)h.push(l),f=l;f===(i.ownerDocument||o)&&h.push(f.defaultView||f.parentWindow||e)}d=0;while((l=h[d++])&&!n.isPropagationStopped())n.type=d>1?c:p.bindType||g,s=(b._data(l,"events")||{})[n.type]&&b._data(l,"handle"),s&&s.apply(l,r),s=u&&l[u],s&&b.acceptData(l)&&s.apply&&s.apply(l,r)===!1&&n.preventDefault();if(n.type=g,!(a||n.isDefaultPrevented()||p._default&&p._default.apply(i.ownerDocument,r)!==!1||"click"===g&&b.nodeName(i,"a")||!b.acceptData(i)||!u||!i[g]||b.isWindow(i))){f=i[u],f&&(i[u]=null),b.event.triggered=g;try{i[g]()}catch(v){}b.event.triggered=t,f&&(i[u]=f)}return n.result}},dispatch:function(e){e=b.event.fix(e);var n,r,i,o,a,s=[],u=h.call(arguments),l=(b._data(this,"events")||{})[e.type]||[],c=b.event.special[e.type]||{};if(u[0]=e,e.delegateTarget=this,!c.preDispatch||c.preDispatch.call(this,e)!==!1){s=b.event.handlers.call(this,e,l),n=0;while((o=s[n++])&&!e.isPropagationStopped()){e.currentTarget=o.elem,a=0;while((i=o.handlers[a++])&&!e.isImmediatePropagationStopped())(!e.namespace_re||e.namespace_re.test(i.namespace))&&(e.handleObj=i,e.data=i.data,r=((b.event.special[i.origType]||{}).handle||i.handler).apply(o.elem,u),r!==t&&(e.result=r)===!1&&(e.preventDefault(),e.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,n){var r,i,o,a,s=[],u=n.delegateCount,l=e.target;if(u&&l.nodeType&&(!e.button||"click"!==e.type))for(;l!=this;l=l.parentNode||this)if(1===l.nodeType&&(l.disabled!==!0||"click"!==e.type)){for(o=[],a=0;u>a;a++)i=n[a],r=i.selector+" ",o[r]===t&&(o[r]=i.needsContext?b(r,this).index(l)>=0:b.find(r,this,null,[l]).length),o[r]&&o.push(i);o.length&&s.push({elem:l,handlers:o})}return n.length>u&&s.push({elem:this,handlers:n.slice(u)}),s},fix:function(e){if(e[b.expando])return e;var t,n,r,i=e.type,a=e,s=this.fixHooks[i];s||(this.fixHooks[i]=s=tt.test(i)?this.mouseHooks:et.test(i)?this.keyHooks:{}),r=s.props?this.props.concat(s.props):this.props,e=new b.Event(a),t=r.length;while(t--)n=r[t],e[n]=a[n];return e.target||(e.target=a.srcElement||o),3===e.target.nodeType&&(e.target=e.target.parentNode),e.metaKey=!!e.metaKey,s.filter?s.filter(e,a):e},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,n){var r,i,a,s=n.button,u=n.fromElement;return null==e.pageX&&null!=n.clientX&&(i=e.target.ownerDocument||o,a=i.documentElement,r=i.body,e.pageX=n.clientX+(a&&a.scrollLeft||r&&r.scrollLeft||0)-(a&&a.clientLeft||r&&r.clientLeft||0),e.pageY=n.clientY+(a&&a.scrollTop||r&&r.scrollTop||0)-(a&&a.clientTop||r&&r.clientTop||0)),!e.relatedTarget&&u&&(e.relatedTarget=u===e.target?n.toElement:u),e.which||s===t||(e.which=1&s?1:2&s?3:4&s?2:0),e}},special:{load:{noBubble:!0},click:{trigger:function(){return b.nodeName(this,"input")&&"checkbox"===this.type&&this.click?(this.click(),!1):t}},focus:{trigger:function(){if(this!==o.activeElement&&this.focus)try{return this.focus(),!1}catch(e){}},delegateType:"focusin"},blur:{trigger:function(){return this===o.activeElement&&this.blur?(this.blur(),!1):t},delegateType:"focusout"},beforeunload:{postDispatch:function(e){e.result!==t&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=b.extend(new b.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?b.event.trigger(i,null,t):b.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},b.removeEvent=o.removeEventListener?function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)}:function(e,t,n){var r="on"+t;e.detachEvent&&(typeof e[r]===i&&(e[r]=null),e.detachEvent(r,n))},b.Event=function(e,n){return this instanceof b.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.returnValue===!1||e.getPreventDefault&&e.getPreventDefault()?it:ot):this.type=e,n&&b.extend(this,n),this.timeStamp=e&&e.timeStamp||b.now(),this[b.expando]=!0,t):new b.Event(e,n)},b.Event.prototype={isDefaultPrevented:ot,isPropagationStopped:ot,isImmediatePropagationStopped:ot,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=it,e&&(e.preventDefault?e.preventDefault():e.returnValue=!1)},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=it,e&&(e.stopPropagation&&e.stopPropagation(),e.cancelBubble=!0)},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=it,this.stopPropagation()}},b.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){b.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj;
 return(!i||i!==r&&!b.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),b.support.submitBubbles||(b.event.special.submit={setup:function(){return b.nodeName(this,"form")?!1:(b.event.add(this,"click._submit keypress._submit",function(e){var n=e.target,r=b.nodeName(n,"input")||b.nodeName(n,"button")?n.form:t;r&&!b._data(r,"submitBubbles")&&(b.event.add(r,"submit._submit",function(e){e._submit_bubble=!0}),b._data(r,"submitBubbles",!0))}),t)},postDispatch:function(e){e._submit_bubble&&(delete e._submit_bubble,this.parentNode&&!e.isTrigger&&b.event.simulate("submit",this.parentNode,e,!0))},teardown:function(){return b.nodeName(this,"form")?!1:(b.event.remove(this,"._submit"),t)}}),b.support.changeBubbles||(b.event.special.change={setup:function(){return Z.test(this.nodeName)?(("checkbox"===this.type||"radio"===this.type)&&(b.event.add(this,"propertychange._change",function(e){"checked"===e.originalEvent.propertyName&&(this._just_changed=!0)}),b.event.add(this,"click._change",function(e){this._just_changed&&!e.isTrigger&&(this._just_changed=!1),b.event.simulate("change",this,e,!0)})),!1):(b.event.add(this,"beforeactivate._change",function(e){var t=e.target;Z.test(t.nodeName)&&!b._data(t,"changeBubbles")&&(b.event.add(t,"change._change",function(e){!this.parentNode||e.isSimulated||e.isTrigger||b.event.simulate("change",this.parentNode,e,!0)}),b._data(t,"changeBubbles",!0))}),t)},handle:function(e){var n=e.target;return this!==n||e.isSimulated||e.isTrigger||"radio"!==n.type&&"checkbox"!==n.type?e.handleObj.handler.apply(this,arguments):t},teardown:function(){return b.event.remove(this,"._change"),!Z.test(this.nodeName)}}),b.support.focusinBubbles||b.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){b.event.simulate(t,e.target,b.event.fix(e),!0)};b.event.special[t]={setup:function(){0===n++&&o.addEventListener(e,r,!0)},teardown:function(){0===--n&&o.removeEventListener(e,r,!0)}}}),b.fn.extend({on:function(e,n,r,i,o){var a,s;if("object"==typeof e){"string"!=typeof n&&(r=r||n,n=t);for(a in e)this.on(a,n,r,e[a],o);return this}if(null==r&&null==i?(i=n,r=n=t):null==i&&("string"==typeof n?(i=r,r=t):(i=r,r=n,n=t)),i===!1)i=ot;else if(!i)return this;return 1===o&&(s=i,i=function(e){return b().off(e),s.apply(this,arguments)},i.guid=s.guid||(s.guid=b.guid++)),this.each(function(){b.event.add(this,e,i,r,n)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,n,r){var i,o;if(e&&e.preventDefault&&e.handleObj)return i=e.handleObj,b(e.delegateTarget).off(i.namespace?i.origType+"."+i.namespace:i.origType,i.selector,i.handler),this;if("object"==typeof e){for(o in e)this.off(o,n,e[o]);return this}return(n===!1||"function"==typeof n)&&(r=n,n=t),r===!1&&(r=ot),this.each(function(){b.event.remove(this,e,r,n)})},bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},trigger:function(e,t){return this.each(function(){b.event.trigger(e,t,this)})},triggerHandler:function(e,n){var r=this[0];return r?b.event.trigger(e,n,r,!0):t}}),function(e,t){var n,r,i,o,a,s,u,l,c,p,f,d,h,g,m,y,v,x="sizzle"+-new Date,w=e.document,T={},N=0,C=0,k=it(),E=it(),S=it(),A=typeof t,j=1<<31,D=[],L=D.pop,H=D.push,q=D.slice,M=D.indexOf||function(e){var t=0,n=this.length;for(;n>t;t++)if(this[t]===e)return t;return-1},_="[\\x20\\t\\r\\n\\f]",F="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",O=F.replace("w","w#"),B="([*^$|!~]?=)",P="\\["+_+"*("+F+")"+_+"*(?:"+B+_+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+O+")|)|)"+_+"*\\]",R=":("+F+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+P.replace(3,8)+")*)|.*)\\)|)",W=RegExp("^"+_+"+|((?:^|[^\\\\])(?:\\\\.)*)"+_+"+$","g"),$=RegExp("^"+_+"*,"+_+"*"),I=RegExp("^"+_+"*([\\x20\\t\\r\\n\\f>+~])"+_+"*"),z=RegExp(R),X=RegExp("^"+O+"$"),U={ID:RegExp("^#("+F+")"),CLASS:RegExp("^\\.("+F+")"),NAME:RegExp("^\\[name=['\"]?("+F+")['\"]?\\]"),TAG:RegExp("^("+F.replace("w","w*")+")"),ATTR:RegExp("^"+P),PSEUDO:RegExp("^"+R),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+_+"*(even|odd|(([+-]|)(\\d*)n|)"+_+"*(?:([+-]|)"+_+"*(\\d+)|))"+_+"*\\)|)","i"),needsContext:RegExp("^"+_+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+_+"*((?:-\\d)?\\d*)"+_+"*\\)|)(?=[^-]|$)","i")},V=/[\x20\t\r\n\f]*[+~]/,Y=/^[^{]+\{\s*\[native code/,J=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,G=/^(?:input|select|textarea|button)$/i,Q=/^h\d$/i,K=/'|\\/g,Z=/\=[\x20\t\r\n\f]*([^'"\]]*)[\x20\t\r\n\f]*\]/g,et=/\\([\da-fA-F]{1,6}[\x20\t\r\n\f]?|.)/g,tt=function(e,t){var n="0x"+t-65536;return n!==n?t:0>n?String.fromCharCode(n+65536):String.fromCharCode(55296|n>>10,56320|1023&n)};try{q.call(w.documentElement.childNodes,0)[0].nodeType}catch(nt){q=function(e){var t,n=[];while(t=this[e++])n.push(t);return n}}function rt(e){return Y.test(e+"")}function it(){var e,t=[];return e=function(n,r){return t.push(n+=" ")>i.cacheLength&&delete e[t.shift()],e[n]=r}}function ot(e){return e[x]=!0,e}function at(e){var t=p.createElement("div");try{return e(t)}catch(n){return!1}finally{t=null}}function st(e,t,n,r){var i,o,a,s,u,l,f,g,m,v;if((t?t.ownerDocument||t:w)!==p&&c(t),t=t||p,n=n||[],!e||"string"!=typeof e)return n;if(1!==(s=t.nodeType)&&9!==s)return[];if(!d&&!r){if(i=J.exec(e))if(a=i[1]){if(9===s){if(o=t.getElementById(a),!o||!o.parentNode)return n;if(o.id===a)return n.push(o),n}else if(t.ownerDocument&&(o=t.ownerDocument.getElementById(a))&&y(t,o)&&o.id===a)return n.push(o),n}else{if(i[2])return H.apply(n,q.call(t.getElementsByTagName(e),0)),n;if((a=i[3])&&T.getByClassName&&t.getElementsByClassName)return H.apply(n,q.call(t.getElementsByClassName(a),0)),n}if(T.qsa&&!h.test(e)){if(f=!0,g=x,m=t,v=9===s&&e,1===s&&"object"!==t.nodeName.toLowerCase()){l=ft(e),(f=t.getAttribute("id"))?g=f.replace(K,"\\$&"):t.setAttribute("id",g),g="[id='"+g+"'] ",u=l.length;while(u--)l[u]=g+dt(l[u]);m=V.test(e)&&t.parentNode||t,v=l.join(",")}if(v)try{return H.apply(n,q.call(m.querySelectorAll(v),0)),n}catch(b){}finally{f||t.removeAttribute("id")}}}return wt(e.replace(W,"$1"),t,n,r)}a=st.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},c=st.setDocument=function(e){var n=e?e.ownerDocument||e:w;return n!==p&&9===n.nodeType&&n.documentElement?(p=n,f=n.documentElement,d=a(n),T.tagNameNoComments=at(function(e){return e.appendChild(n.createComment("")),!e.getElementsByTagName("*").length}),T.attributes=at(function(e){e.innerHTML="<select></select>";var t=typeof e.lastChild.getAttribute("multiple");return"boolean"!==t&&"string"!==t}),T.getByClassName=at(function(e){return e.innerHTML="<div class='hidden e'></div><div class='hidden'></div>",e.getElementsByClassName&&e.getElementsByClassName("e").length?(e.lastChild.className="e",2===e.getElementsByClassName("e").length):!1}),T.getByName=at(function(e){e.id=x+0,e.innerHTML="<a name='"+x+"'></a><div name='"+x+"'></div>",f.insertBefore(e,f.firstChild);var t=n.getElementsByName&&n.getElementsByName(x).length===2+n.getElementsByName(x+0).length;return T.getIdNotName=!n.getElementById(x),f.removeChild(e),t}),i.attrHandle=at(function(e){return e.innerHTML="<a href='#'></a>",e.firstChild&&typeof e.firstChild.getAttribute!==A&&"#"===e.firstChild.getAttribute("href")})?{}:{href:function(e){return e.getAttribute("href",2)},type:function(e){return e.getAttribute("type")}},T.getIdNotName?(i.find.ID=function(e,t){if(typeof t.getElementById!==A&&!d){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},i.filter.ID=function(e){var t=e.replace(et,tt);return function(e){return e.getAttribute("id")===t}}):(i.find.ID=function(e,n){if(typeof n.getElementById!==A&&!d){var r=n.getElementById(e);return r?r.id===e||typeof r.getAttributeNode!==A&&r.getAttributeNode("id").value===e?[r]:t:[]}},i.filter.ID=function(e){var t=e.replace(et,tt);return function(e){var n=typeof e.getAttributeNode!==A&&e.getAttributeNode("id");return n&&n.value===t}}),i.find.TAG=T.tagNameNoComments?function(e,n){return typeof n.getElementsByTagName!==A?n.getElementsByTagName(e):t}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},i.find.NAME=T.getByName&&function(e,n){return typeof n.getElementsByName!==A?n.getElementsByName(name):t},i.find.CLASS=T.getByClassName&&function(e,n){return typeof n.getElementsByClassName===A||d?t:n.getElementsByClassName(e)},g=[],h=[":focus"],(T.qsa=rt(n.querySelectorAll))&&(at(function(e){e.innerHTML="<select><option selected=''></option></select>",e.querySelectorAll("[selected]").length||h.push("\\["+_+"*(?:checked|disabled|ismap|multiple|readonly|selected|value)"),e.querySelectorAll(":checked").length||h.push(":checked")}),at(function(e){e.innerHTML="<input type='hidden' i=''/>",e.querySelectorAll("[i^='']").length&&h.push("[*^$]="+_+"*(?:\"\"|'')"),e.querySelectorAll(":enabled").length||h.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),h.push(",.*:")})),(T.matchesSelector=rt(m=f.matchesSelector||f.mozMatchesSelector||f.webkitMatchesSelector||f.oMatchesSelector||f.msMatchesSelector))&&at(function(e){T.disconnectedMatch=m.call(e,"div"),m.call(e,"[s!='']:x"),g.push("!=",R)}),h=RegExp(h.join("|")),g=RegExp(g.join("|")),y=rt(f.contains)||f.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},v=f.compareDocumentPosition?function(e,t){var r;return e===t?(u=!0,0):(r=t.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(t))?1&r||e.parentNode&&11===e.parentNode.nodeType?e===n||y(w,e)?-1:t===n||y(w,t)?1:0:4&r?-1:1:e.compareDocumentPosition?-1:1}:function(e,t){var r,i=0,o=e.parentNode,a=t.parentNode,s=[e],l=[t];if(e===t)return u=!0,0;if(!o||!a)return e===n?-1:t===n?1:o?-1:a?1:0;if(o===a)return ut(e,t);r=e;while(r=r.parentNode)s.unshift(r);r=t;while(r=r.parentNode)l.unshift(r);while(s[i]===l[i])i++;return i?ut(s[i],l[i]):s[i]===w?-1:l[i]===w?1:0},u=!1,[0,0].sort(v),T.detectDuplicates=u,p):p},st.matches=function(e,t){return st(e,null,null,t)},st.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&c(e),t=t.replace(Z,"='$1']"),!(!T.matchesSelector||d||g&&g.test(t)||h.test(t)))try{var n=m.call(e,t);if(n||T.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(r){}return st(t,p,null,[e]).length>0},st.contains=function(e,t){return(e.ownerDocument||e)!==p&&c(e),y(e,t)},st.attr=function(e,t){var n;return(e.ownerDocument||e)!==p&&c(e),d||(t=t.toLowerCase()),(n=i.attrHandle[t])?n(e):d||T.attributes?e.getAttribute(t):((n=e.getAttributeNode(t))||e.getAttribute(t))&&e[t]===!0?t:n&&n.specified?n.value:null},st.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},st.uniqueSort=function(e){var t,n=[],r=1,i=0;if(u=!T.detectDuplicates,e.sort(v),u){for(;t=e[r];r++)t===e[r-1]&&(i=n.push(r));while(i--)e.splice(n[i],1)}return e};function ut(e,t){var n=t&&e,r=n&&(~t.sourceIndex||j)-(~e.sourceIndex||j);if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function lt(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function ct(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function pt(e){return ot(function(t){return t=+t,ot(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}o=st.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=o(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=o(t);return n},i=st.selectors={cacheLength:50,createPseudo:ot,match:U,find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(et,tt),e[3]=(e[4]||e[5]||"").replace(et,tt),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||st.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&st.error(e[0]),e},PSEUDO:function(e){var t,n=!e[5]&&e[2];return U.CHILD.test(e[0])?null:(e[4]?e[2]=e[4]:n&&z.test(n)&&(t=ft(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){return"*"===e?function(){return!0}:(e=e.replace(et,tt).toLowerCase(),function(t){return t.nodeName&&t.nodeName.toLowerCase()===e})},CLASS:function(e){var t=k[e+" "];return t||(t=RegExp("(^|"+_+")"+e+"("+_+"|$)"))&&k(e,function(e){return t.test(e.className||typeof e.getAttribute!==A&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=st.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.slice(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,p,f,d,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,y=s&&t.nodeName.toLowerCase(),v=!u&&!s;if(m){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===y:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&v){c=m[x]||(m[x]={}),l=c[e]||[],d=l[0]===N&&l[1],f=l[0]===N&&l[2],p=d&&m.childNodes[d];while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if(1===p.nodeType&&++f&&p===t){c[e]=[N,d,f];break}}else if(v&&(l=(t[x]||(t[x]={}))[e])&&l[0]===N)f=l[1];else while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===y:1===p.nodeType)&&++f&&(v&&((p[x]||(p[x]={}))[e]=[N,f]),p===t))break;return f-=i,f===r||0===f%r&&f/r>=0}}},PSEUDO:function(e,t){var n,r=i.pseudos[e]||i.setFilters[e.toLowerCase()]||st.error("unsupported pseudo: "+e);return r[x]?r(t):r.length>1?(n=[e,e,"",t],i.setFilters.hasOwnProperty(e.toLowerCase())?ot(function(e,n){var i,o=r(e,t),a=o.length;while(a--)i=M.call(e,o[a]),e[i]=!(n[i]=o[a])}):function(e){return r(e,0,n)}):r}},pseudos:{not:ot(function(e){var t=[],n=[],r=s(e.replace(W,"$1"));return r[x]?ot(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:ot(function(e){return function(t){return st(e,t).length>0}}),contains:ot(function(e){return function(t){return(t.textContent||t.innerText||o(t)).indexOf(e)>-1}}),lang:ot(function(e){return X.test(e||"")||st.error("unsupported lang: "+e),e=e.replace(et,tt).toLowerCase(),function(t){var n;do if(n=d?t.getAttribute("xml:lang")||t.getAttribute("lang"):t.lang)return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===f},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!i.pseudos.empty(e)},header:function(e){return Q.test(e.nodeName)},input:function(e){return G.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:pt(function(){return[0]}),last:pt(function(e,t){return[t-1]}),eq:pt(function(e,t,n){return[0>n?n+t:n]}),even:pt(function(e,t){var n=0;for(;t>n;n+=2)e.push(n);return e}),odd:pt(function(e,t){var n=1;for(;t>n;n+=2)e.push(n);return e}),lt:pt(function(e,t,n){var r=0>n?n+t:n;for(;--r>=0;)e.push(r);return e}),gt:pt(function(e,t,n){var r=0>n?n+t:n;for(;t>++r;)e.push(r);return e})}};for(n in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})i.pseudos[n]=lt(n);for(n in{submit:!0,reset:!0})i.pseudos[n]=ct(n);function ft(e,t){var n,r,o,a,s,u,l,c=E[e+" "];if(c)return t?0:c.slice(0);s=e,u=[],l=i.preFilter;while(s){(!n||(r=$.exec(s)))&&(r&&(s=s.slice(r[0].length)||s),u.push(o=[])),n=!1,(r=I.exec(s))&&(n=r.shift(),o.push({value:n,type:r[0].replace(W," ")}),s=s.slice(n.length));for(a in i.filter)!(r=U[a].exec(s))||l[a]&&!(r=l[a](r))||(n=r.shift(),o.push({value:n,type:a,matches:r}),s=s.slice(n.length));if(!n)break}return t?s.length:s?st.error(e):E(e,u).slice(0)}function dt(e){var t=0,n=e.length,r="";for(;n>t;t++)r+=e[t].value;return r}function ht(e,t,n){var i=t.dir,o=n&&"parentNode"===i,a=C++;return t.first?function(t,n,r){while(t=t[i])if(1===t.nodeType||o)return e(t,n,r)}:function(t,n,s){var u,l,c,p=N+" "+a;if(s){while(t=t[i])if((1===t.nodeType||o)&&e(t,n,s))return!0}else while(t=t[i])if(1===t.nodeType||o)if(c=t[x]||(t[x]={}),(l=c[i])&&l[0]===p){if((u=l[1])===!0||u===r)return u===!0}else if(l=c[i]=[p],l[1]=e(t,n,s)||r,l[1]===!0)return!0}}function gt(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function mt(e,t,n,r,i){var o,a=[],s=0,u=e.length,l=null!=t;for(;u>s;s++)(o=e[s])&&(!n||n(o,r,i))&&(a.push(o),l&&t.push(s));return a}function yt(e,t,n,r,i,o){return r&&!r[x]&&(r=yt(r)),i&&!i[x]&&(i=yt(i,o)),ot(function(o,a,s,u){var l,c,p,f=[],d=[],h=a.length,g=o||xt(t||"*",s.nodeType?[s]:s,[]),m=!e||!o&&t?g:mt(g,f,e,s,u),y=n?i||(o?e:h||r)?[]:a:m;if(n&&n(m,y,s,u),r){l=mt(y,d),r(l,[],s,u),c=l.length;while(c--)(p=l[c])&&(y[d[c]]=!(m[d[c]]=p))}if(o){if(i||e){if(i){l=[],c=y.length;while(c--)(p=y[c])&&l.push(m[c]=p);i(null,y=[],l,u)}c=y.length;while(c--)(p=y[c])&&(l=i?M.call(o,p):f[c])>-1&&(o[l]=!(a[l]=p))}}else y=mt(y===a?y.splice(h,y.length):y),i?i(null,a,y,u):H.apply(a,y)})}function vt(e){var t,n,r,o=e.length,a=i.relative[e[0].type],s=a||i.relative[" "],u=a?1:0,c=ht(function(e){return e===t},s,!0),p=ht(function(e){return M.call(t,e)>-1},s,!0),f=[function(e,n,r){return!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):p(e,n,r))}];for(;o>u;u++)if(n=i.relative[e[u].type])f=[ht(gt(f),n)];else{if(n=i.filter[e[u].type].apply(null,e[u].matches),n[x]){for(r=++u;o>r;r++)if(i.relative[e[r].type])break;return yt(u>1&&gt(f),u>1&&dt(e.slice(0,u-1)).replace(W,"$1"),n,r>u&&vt(e.slice(u,r)),o>r&&vt(e=e.slice(r)),o>r&&dt(e))}f.push(n)}return gt(f)}function bt(e,t){var n=0,o=t.length>0,a=e.length>0,s=function(s,u,c,f,d){var h,g,m,y=[],v=0,b="0",x=s&&[],w=null!=d,T=l,C=s||a&&i.find.TAG("*",d&&u.parentNode||u),k=N+=null==T?1:Math.random()||.1;for(w&&(l=u!==p&&u,r=n);null!=(h=C[b]);b++){if(a&&h){g=0;while(m=e[g++])if(m(h,u,c)){f.push(h);break}w&&(N=k,r=++n)}o&&((h=!m&&h)&&v--,s&&x.push(h))}if(v+=b,o&&b!==v){g=0;while(m=t[g++])m(x,y,u,c);if(s){if(v>0)while(b--)x[b]||y[b]||(y[b]=L.call(f));y=mt(y)}H.apply(f,y),w&&!s&&y.length>0&&v+t.length>1&&st.uniqueSort(f)}return w&&(N=k,l=T),x};return o?ot(s):s}s=st.compile=function(e,t){var n,r=[],i=[],o=S[e+" "];if(!o){t||(t=ft(e)),n=t.length;while(n--)o=vt(t[n]),o[x]?r.push(o):i.push(o);o=S(e,bt(i,r))}return o};function xt(e,t,n){var r=0,i=t.length;for(;i>r;r++)st(e,t[r],n);return n}function wt(e,t,n,r){var o,a,u,l,c,p=ft(e);if(!r&&1===p.length){if(a=p[0]=p[0].slice(0),a.length>2&&"ID"===(u=a[0]).type&&9===t.nodeType&&!d&&i.relative[a[1].type]){if(t=i.find.ID(u.matches[0].replace(et,tt),t)[0],!t)return n;e=e.slice(a.shift().value.length)}o=U.needsContext.test(e)?0:a.length;while(o--){if(u=a[o],i.relative[l=u.type])break;if((c=i.find[l])&&(r=c(u.matches[0].replace(et,tt),V.test(a[0].type)&&t.parentNode||t))){if(a.splice(o,1),e=r.length&&dt(a),!e)return H.apply(n,q.call(r,0)),n;break}}}return s(e,p)(r,t,d,n,V.test(e)),n}i.pseudos.nth=i.pseudos.eq;function Tt(){}i.filters=Tt.prototype=i.pseudos,i.setFilters=new Tt,c(),st.attr=b.attr,b.find=st,b.expr=st.selectors,b.expr[":"]=b.expr.pseudos,b.unique=st.uniqueSort,b.text=st.getText,b.isXMLDoc=st.isXML,b.contains=st.contains}(e);var at=/Until$/,st=/^(?:parents|prev(?:Until|All))/,ut=/^.[^:#\[\.,]*$/,lt=b.expr.match.needsContext,ct={children:!0,contents:!0,next:!0,prev:!0};b.fn.extend({find:function(e){var t,n,r,i=this.length;if("string"!=typeof e)return r=this,this.pushStack(b(e).filter(function(){for(t=0;i>t;t++)if(b.contains(r[t],this))return!0}));for(n=[],t=0;i>t;t++)b.find(e,this[t],n);return n=this.pushStack(i>1?b.unique(n):n),n.selector=(this.selector?this.selector+" ":"")+e,n},has:function(e){var t,n=b(e,this),r=n.length;return this.filter(function(){for(t=0;r>t;t++)if(b.contains(this,n[t]))return!0})},not:function(e){return this.pushStack(ft(this,e,!1))},filter:function(e){return this.pushStack(ft(this,e,!0))},is:function(e){return!!e&&("string"==typeof e?lt.test(e)?b(e,this.context).index(this[0])>=0:b.filter(e,this).length>0:this.filter(e).length>0)},closest:function(e,t){var n,r=0,i=this.length,o=[],a=lt.test(e)||"string"!=typeof e?b(e,t||this.context):0;for(;i>r;r++){n=this[r];while(n&&n.ownerDocument&&n!==t&&11!==n.nodeType){if(a?a.index(n)>-1:b.find.matchesSelector(n,e)){o.push(n);break}n=n.parentNode}}return this.pushStack(o.length>1?b.unique(o):o)},index:function(e){return e?"string"==typeof e?b.inArray(this[0],b(e)):b.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?b(e,t):b.makeArray(e&&e.nodeType?[e]:e),r=b.merge(this.get(),n);return this.pushStack(b.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),b.fn.andSelf=b.fn.addBack;function pt(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}b.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return b.dir(e,"parentNode")},parentsUntil:function(e,t,n){return b.dir(e,"parentNode",n)},next:function(e){return pt(e,"nextSibling")},prev:function(e){return pt(e,"previousSibling")},nextAll:function(e){return b.dir(e,"nextSibling")},prevAll:function(e){return b.dir(e,"previousSibling")},nextUntil:function(e,t,n){return b.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return b.dir(e,"previousSibling",n)},siblings:function(e){return b.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return b.sibling(e.firstChild)},contents:function(e){return b.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:b.merge([],e.childNodes)}},function(e,t){b.fn[e]=function(n,r){var i=b.map(this,t,n);return at.test(e)||(r=n),r&&"string"==typeof r&&(i=b.filter(r,i)),i=this.length>1&&!ct[e]?b.unique(i):i,this.length>1&&st.test(e)&&(i=i.reverse()),this.pushStack(i)}}),b.extend({filter:function(e,t,n){return n&&(e=":not("+e+")"),1===t.length?b.find.matchesSelector(t[0],e)?[t[0]]:[]:b.find.matches(e,t)},dir:function(e,n,r){var i=[],o=e[n];while(o&&9!==o.nodeType&&(r===t||1!==o.nodeType||!b(o).is(r)))1===o.nodeType&&i.push(o),o=o[n];return i},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});function ft(e,t,n){if(t=t||0,b.isFunction(t))return b.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return b.grep(e,function(e){return e===t===n});if("string"==typeof t){var r=b.grep(e,function(e){return 1===e.nodeType});if(ut.test(t))return b.filter(t,r,!n);t=b.filter(t,r)}return b.grep(e,function(e){return b.inArray(e,t)>=0===n})}function dt(e){var t=ht.split("|"),n=e.createDocumentFragment();if(n.createElement)while(t.length)n.createElement(t.pop());return n}var ht="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",gt=/ jQuery\d+="(?:null|\d+)"/g,mt=RegExp("<(?:"+ht+")[\\s/>]","i"),yt=/^\s+/,vt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,bt=/<([\w:]+)/,xt=/<tbody/i,wt=/<|&#?\w+;/,Tt=/<(?:script|style|link)/i,Nt=/^(?:checkbox|radio)$/i,Ct=/checked\s*(?:[^=]|=\s*.checked.)/i,kt=/^$|\/(?:java|ecma)script/i,Et=/^true\/(.*)/,St=/^\s*<!(?:\[CDATA\[|--)|(?:\]\]|--)>\s*$/g,At={option:[1,"<select multiple='multiple'>","</select>"],legend:[1,"<fieldset>","</fieldset>"],area:[1,"<map>","</map>"],param:[1,"<object>","</object>"],thead:[1,"<table>","</table>"],tr:[2,"<table><tbody>","</tbody></table>"],col:[2,"<table><tbody></tbody><colgroup>","</colgroup></table>"],td:[3,"<table><tbody><tr>","</tr></tbody></table>"],_default:b.support.htmlSerialize?[0,"",""]:[1,"X<div>","</div>"]},jt=dt(o),Dt=jt.appendChild(o.createElement("div"));At.optgroup=At.option,At.tbody=At.tfoot=At.colgroup=At.caption=At.thead,At.th=At.td,b.fn.extend({text:function(e){return b.access(this,function(e){return e===t?b.text(this):this.empty().append((this[0]&&this[0].ownerDocument||o).createTextNode(e))},null,e,arguments.length)},wrapAll:function(e){if(b.isFunction(e))return this.each(function(t){b(this).wrapAll(e.call(this,t))});if(this[0]){var t=b(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstChild&&1===e.firstChild.nodeType)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return b.isFunction(e)?this.each(function(t){b(this).wrapInner(e.call(this,t))}):this.each(function(){var t=b(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=b.isFunction(e);return this.each(function(n){b(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){b.nodeName(this,"body")||b(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.appendChild(e)})},prepend:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.insertBefore(e,this.firstChild)})},before:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){var n,r=0;for(;null!=(n=this[r]);r++)(!e||b.filter(e,[n]).length>0)&&(t||1!==n.nodeType||b.cleanData(Ot(n)),n.parentNode&&(t&&b.contains(n.ownerDocument,n)&&Mt(Ot(n,"script")),n.parentNode.removeChild(n)));return this},empty:function(){var e,t=0;for(;null!=(e=this[t]);t++){1===e.nodeType&&b.cleanData(Ot(e,!1));while(e.firstChild)e.removeChild(e.firstChild);e.options&&b.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return b.clone(this,e,t)})},html:function(e){return b.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return 1===n.nodeType?n.innerHTML.replace(gt,""):t;if(!("string"!=typeof e||Tt.test(e)||!b.support.htmlSerialize&&mt.test(e)||!b.support.leadingWhitespace&&yt.test(e)||At[(bt.exec(e)||["",""])[1].toLowerCase()])){e=e.replace(vt,"<$1></$2>");try{for(;i>r;r++)n=this[r]||{},1===n.nodeType&&(b.cleanData(Ot(n,!1)),n.innerHTML=e);n=0}catch(o){}}n&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(e){var t=b.isFunction(e);return t||"string"==typeof e||(e=b(e).not(this).detach()),this.domManip([e],!0,function(e){var t=this.nextSibling,n=this.parentNode;n&&(b(this).remove(),n.insertBefore(e,t))})},detach:function(e){return this.remove(e,!0)},domManip:function(e,n,r){e=f.apply([],e);var i,o,a,s,u,l,c=0,p=this.length,d=this,h=p-1,g=e[0],m=b.isFunction(g);if(m||!(1>=p||"string"!=typeof g||b.support.checkClone)&&Ct.test(g))return this.each(function(i){var o=d.eq(i);m&&(e[0]=g.call(this,i,n?o.html():t)),o.domManip(e,n,r)});if(p&&(l=b.buildFragment(e,this[0].ownerDocument,!1,this),i=l.firstChild,1===l.childNodes.length&&(l=i),i)){for(n=n&&b.nodeName(i,"tr"),s=b.map(Ot(l,"script"),Ht),a=s.length;p>c;c++)o=l,c!==h&&(o=b.clone(o,!0,!0),a&&b.merge(s,Ot(o,"script"))),r.call(n&&b.nodeName(this[c],"table")?Lt(this[c],"tbody"):this[c],o,c);if(a)for(u=s[s.length-1].ownerDocument,b.map(s,qt),c=0;a>c;c++)o=s[c],kt.test(o.type||"")&&!b._data(o,"globalEval")&&b.contains(u,o)&&(o.src?b.ajax({url:o.src,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0}):b.globalEval((o.text||o.textContent||o.innerHTML||"").replace(St,"")));l=i=null}return this}});function Lt(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function Ht(e){var t=e.getAttributeNode("type");return e.type=(t&&t.specified)+"/"+e.type,e}function qt(e){var t=Et.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function Mt(e,t){var n,r=0;for(;null!=(n=e[r]);r++)b._data(n,"globalEval",!t||b._data(t[r],"globalEval"))}function _t(e,t){if(1===t.nodeType&&b.hasData(e)){var n,r,i,o=b._data(e),a=b._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)b.event.add(t,n,s[n][r])}a.data&&(a.data=b.extend({},a.data))}}function Ft(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!b.support.noCloneEvent&&t[b.expando]){i=b._data(t);for(r in i.events)b.removeEvent(t,r,i.handle);t.removeAttribute(b.expando)}"script"===n&&t.text!==e.text?(Ht(t).text=e.text,qt(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),b.support.html5Clone&&e.innerHTML&&!b.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Nt.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}b.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){b.fn[e]=function(e){var n,r=0,i=[],o=b(e),a=o.length-1;for(;a>=r;r++)n=r===a?this:this.clone(!0),b(o[r])[t](n),d.apply(i,n.get());return this.pushStack(i)}});function Ot(e,n){var r,o,a=0,s=typeof e.getElementsByTagName!==i?e.getElementsByTagName(n||"*"):typeof e.querySelectorAll!==i?e.querySelectorAll(n||"*"):t;if(!s)for(s=[],r=e.childNodes||e;null!=(o=r[a]);a++)!n||b.nodeName(o,n)?s.push(o):b.merge(s,Ot(o,n));return n===t||n&&b.nodeName(e,n)?b.merge([e],s):s}function Bt(e){Nt.test(e.type)&&(e.defaultChecked=e.checked)}b.extend({clone:function(e,t,n){var r,i,o,a,s,u=b.contains(e.ownerDocument,e);if(b.support.html5Clone||b.isXMLDoc(e)||!mt.test("<"+e.nodeName+">")?o=e.cloneNode(!0):(Dt.innerHTML=e.outerHTML,Dt.removeChild(o=Dt.firstChild)),!(b.support.noCloneEvent&&b.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||b.isXMLDoc(e)))for(r=Ot(o),s=Ot(e),a=0;null!=(i=s[a]);++a)r[a]&&Ft(i,r[a]);if(t)if(n)for(s=s||Ot(e),r=r||Ot(o),a=0;null!=(i=s[a]);a++)_t(i,r[a]);else _t(e,o);return r=Ot(o,"script"),r.length>0&&Mt(r,!u&&Ot(e,"script")),r=s=i=null,o},buildFragment:function(e,t,n,r){var i,o,a,s,u,l,c,p=e.length,f=dt(t),d=[],h=0;for(;p>h;h++)if(o=e[h],o||0===o)if("object"===b.type(o))b.merge(d,o.nodeType?[o]:o);else if(wt.test(o)){s=s||f.appendChild(t.createElement("div")),u=(bt.exec(o)||["",""])[1].toLowerCase(),c=At[u]||At._default,s.innerHTML=c[1]+o.replace(vt,"<$1></$2>")+c[2],i=c[0];while(i--)s=s.lastChild;if(!b.support.leadingWhitespace&&yt.test(o)&&d.push(t.createTextNode(yt.exec(o)[0])),!b.support.tbody){o="table"!==u||xt.test(o)?"<table>"!==c[1]||xt.test(o)?0:s:s.firstChild,i=o&&o.childNodes.length;while(i--)b.nodeName(l=o.childNodes[i],"tbody")&&!l.childNodes.length&&o.removeChild(l)
 }b.merge(d,s.childNodes),s.textContent="";while(s.firstChild)s.removeChild(s.firstChild);s=f.lastChild}else d.push(t.createTextNode(o));s&&f.removeChild(s),b.support.appendChecked||b.grep(Ot(d,"input"),Bt),h=0;while(o=d[h++])if((!r||-1===b.inArray(o,r))&&(a=b.contains(o.ownerDocument,o),s=Ot(f.appendChild(o),"script"),a&&Mt(s),n)){i=0;while(o=s[i++])kt.test(o.type||"")&&n.push(o)}return s=null,f},cleanData:function(e,t){var n,r,o,a,s=0,u=b.expando,l=b.cache,p=b.support.deleteExpando,f=b.event.special;for(;null!=(n=e[s]);s++)if((t||b.acceptData(n))&&(o=n[u],a=o&&l[o])){if(a.events)for(r in a.events)f[r]?b.event.remove(n,r):b.removeEvent(n,r,a.handle);l[o]&&(delete l[o],p?delete n[u]:typeof n.removeAttribute!==i?n.removeAttribute(u):n[u]=null,c.push(o))}}});var Pt,Rt,Wt,$t=/alpha\([^)]*\)/i,It=/opacity\s*=\s*([^)]*)/,zt=/^(top|right|bottom|left)$/,Xt=/^(none|table(?!-c[ea]).+)/,Ut=/^margin/,Vt=RegExp("^("+x+")(.*)$","i"),Yt=RegExp("^("+x+")(?!px)[a-z%]+$","i"),Jt=RegExp("^([+-])=("+x+")","i"),Gt={BODY:"block"},Qt={position:"absolute",visibility:"hidden",display:"block"},Kt={letterSpacing:0,fontWeight:400},Zt=["Top","Right","Bottom","Left"],en=["Webkit","O","Moz","ms"];function tn(e,t){if(t in e)return t;var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=en.length;while(i--)if(t=en[i]+n,t in e)return t;return r}function nn(e,t){return e=t||e,"none"===b.css(e,"display")||!b.contains(e.ownerDocument,e)}function rn(e,t){var n,r,i,o=[],a=0,s=e.length;for(;s>a;a++)r=e[a],r.style&&(o[a]=b._data(r,"olddisplay"),n=r.style.display,t?(o[a]||"none"!==n||(r.style.display=""),""===r.style.display&&nn(r)&&(o[a]=b._data(r,"olddisplay",un(r.nodeName)))):o[a]||(i=nn(r),(n&&"none"!==n||!i)&&b._data(r,"olddisplay",i?n:b.css(r,"display"))));for(a=0;s>a;a++)r=e[a],r.style&&(t&&"none"!==r.style.display&&""!==r.style.display||(r.style.display=t?o[a]||"":"none"));return e}b.fn.extend({css:function(e,n){return b.access(this,function(e,n,r){var i,o,a={},s=0;if(b.isArray(n)){for(o=Rt(e),i=n.length;i>s;s++)a[n[s]]=b.css(e,n[s],!1,o);return a}return r!==t?b.style(e,n,r):b.css(e,n)},e,n,arguments.length>1)},show:function(){return rn(this,!0)},hide:function(){return rn(this)},toggle:function(e){var t="boolean"==typeof e;return this.each(function(){(t?e:nn(this))?b(this).show():b(this).hide()})}}),b.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Wt(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":b.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var o,a,s,u=b.camelCase(n),l=e.style;if(n=b.cssProps[u]||(b.cssProps[u]=tn(l,u)),s=b.cssHooks[n]||b.cssHooks[u],r===t)return s&&"get"in s&&(o=s.get(e,!1,i))!==t?o:l[n];if(a=typeof r,"string"===a&&(o=Jt.exec(r))&&(r=(o[1]+1)*o[2]+parseFloat(b.css(e,n)),a="number"),!(null==r||"number"===a&&isNaN(r)||("number"!==a||b.cssNumber[u]||(r+="px"),b.support.clearCloneStyle||""!==r||0!==n.indexOf("background")||(l[n]="inherit"),s&&"set"in s&&(r=s.set(e,r,i))===t)))try{l[n]=r}catch(c){}}},css:function(e,n,r,i){var o,a,s,u=b.camelCase(n);return n=b.cssProps[u]||(b.cssProps[u]=tn(e.style,u)),s=b.cssHooks[n]||b.cssHooks[u],s&&"get"in s&&(a=s.get(e,!0,r)),a===t&&(a=Wt(e,n,i)),"normal"===a&&n in Kt&&(a=Kt[n]),""===r||r?(o=parseFloat(a),r===!0||b.isNumeric(o)?o||0:a):a},swap:function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i}}),e.getComputedStyle?(Rt=function(t){return e.getComputedStyle(t,null)},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),u=s?s.getPropertyValue(n)||s[n]:t,l=e.style;return s&&(""!==u||b.contains(e.ownerDocument,e)||(u=b.style(e,n)),Yt.test(u)&&Ut.test(n)&&(i=l.width,o=l.minWidth,a=l.maxWidth,l.minWidth=l.maxWidth=l.width=u,u=s.width,l.width=i,l.minWidth=o,l.maxWidth=a)),u}):o.documentElement.currentStyle&&(Rt=function(e){return e.currentStyle},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),u=s?s[n]:t,l=e.style;return null==u&&l&&l[n]&&(u=l[n]),Yt.test(u)&&!zt.test(n)&&(i=l.left,o=e.runtimeStyle,a=o&&o.left,a&&(o.left=e.currentStyle.left),l.left="fontSize"===n?"1em":u,u=l.pixelLeft+"px",l.left=i,a&&(o.left=a)),""===u?"auto":u});function on(e,t,n){var r=Vt.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function an(e,t,n,r,i){var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;for(;4>o;o+=2)"margin"===n&&(a+=b.css(e,n+Zt[o],!0,i)),r?("content"===n&&(a-=b.css(e,"padding"+Zt[o],!0,i)),"margin"!==n&&(a-=b.css(e,"border"+Zt[o]+"Width",!0,i))):(a+=b.css(e,"padding"+Zt[o],!0,i),"padding"!==n&&(a+=b.css(e,"border"+Zt[o]+"Width",!0,i)));return a}function sn(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=Rt(e),a=b.support.boxSizing&&"border-box"===b.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=Wt(e,t,o),(0>i||null==i)&&(i=e.style[t]),Yt.test(i))return i;r=a&&(b.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+an(e,t,n||(a?"border":"content"),r,o)+"px"}function un(e){var t=o,n=Gt[e];return n||(n=ln(e,t),"none"!==n&&n||(Pt=(Pt||b("<iframe frameborder='0' width='0' height='0'/>").css("cssText","display:block !important")).appendTo(t.documentElement),t=(Pt[0].contentWindow||Pt[0].contentDocument).document,t.write("<!doctype html><html><body>"),t.close(),n=ln(e,t),Pt.detach()),Gt[e]=n),n}function ln(e,t){var n=b(t.createElement(e)).appendTo(t.body),r=b.css(n[0],"display");return n.remove(),r}b.each(["height","width"],function(e,n){b.cssHooks[n]={get:function(e,r,i){return r?0===e.offsetWidth&&Xt.test(b.css(e,"display"))?b.swap(e,Qt,function(){return sn(e,n,i)}):sn(e,n,i):t},set:function(e,t,r){var i=r&&Rt(e);return on(e,t,r?an(e,n,r,b.support.boxSizing&&"border-box"===b.css(e,"boxSizing",!1,i),i):0)}}}),b.support.opacity||(b.cssHooks.opacity={get:function(e,t){return It.test((t&&e.currentStyle?e.currentStyle.filter:e.style.filter)||"")?.01*parseFloat(RegExp.$1)+"":t?"1":""},set:function(e,t){var n=e.style,r=e.currentStyle,i=b.isNumeric(t)?"alpha(opacity="+100*t+")":"",o=r&&r.filter||n.filter||"";n.zoom=1,(t>=1||""===t)&&""===b.trim(o.replace($t,""))&&n.removeAttribute&&(n.removeAttribute("filter"),""===t||r&&!r.filter)||(n.filter=$t.test(o)?o.replace($t,i):o+" "+i)}}),b(function(){b.support.reliableMarginRight||(b.cssHooks.marginRight={get:function(e,n){return n?b.swap(e,{display:"inline-block"},Wt,[e,"marginRight"]):t}}),!b.support.pixelPosition&&b.fn.position&&b.each(["top","left"],function(e,n){b.cssHooks[n]={get:function(e,r){return r?(r=Wt(e,n),Yt.test(r)?b(e).position()[n]+"px":r):t}}})}),b.expr&&b.expr.filters&&(b.expr.filters.hidden=function(e){return 0>=e.offsetWidth&&0>=e.offsetHeight||!b.support.reliableHiddenOffsets&&"none"===(e.style&&e.style.display||b.css(e,"display"))},b.expr.filters.visible=function(e){return!b.expr.filters.hidden(e)}),b.each({margin:"",padding:"",border:"Width"},function(e,t){b.cssHooks[e+t]={expand:function(n){var r=0,i={},o="string"==typeof n?n.split(" "):[n];for(;4>r;r++)i[e+Zt[r]+t]=o[r]||o[r-2]||o[0];return i}},Ut.test(e)||(b.cssHooks[e+t].set=on)});var cn=/%20/g,pn=/\[\]$/,fn=/\r?\n/g,dn=/^(?:submit|button|image|reset|file)$/i,hn=/^(?:input|select|textarea|keygen)/i;b.fn.extend({serialize:function(){return b.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var e=b.prop(this,"elements");return e?b.makeArray(e):this}).filter(function(){var e=this.type;return this.name&&!b(this).is(":disabled")&&hn.test(this.nodeName)&&!dn.test(e)&&(this.checked||!Nt.test(e))}).map(function(e,t){var n=b(this).val();return null==n?null:b.isArray(n)?b.map(n,function(e){return{name:t.name,value:e.replace(fn,"\r\n")}}):{name:t.name,value:n.replace(fn,"\r\n")}}).get()}}),b.param=function(e,n){var r,i=[],o=function(e,t){t=b.isFunction(t)?t():null==t?"":t,i[i.length]=encodeURIComponent(e)+"="+encodeURIComponent(t)};if(n===t&&(n=b.ajaxSettings&&b.ajaxSettings.traditional),b.isArray(e)||e.jquery&&!b.isPlainObject(e))b.each(e,function(){o(this.name,this.value)});else for(r in e)gn(r,e[r],n,o);return i.join("&").replace(cn,"+")};function gn(e,t,n,r){var i;if(b.isArray(t))b.each(t,function(t,i){n||pn.test(e)?r(e,i):gn(e+"["+("object"==typeof i?t:"")+"]",i,n,r)});else if(n||"object"!==b.type(t))r(e,t);else for(i in t)gn(e+"["+i+"]",t[i],n,r)}b.each("blur focus focusin focusout load resize scroll unload click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup error contextmenu".split(" "),function(e,t){b.fn[t]=function(e,n){return arguments.length>0?this.on(t,null,e,n):this.trigger(t)}}),b.fn.hover=function(e,t){return this.mouseenter(e).mouseleave(t||e)};var mn,yn,vn=b.now(),bn=/\?/,xn=/#.*$/,wn=/([?&])_=[^&]*/,Tn=/^(.*?):[ \t]*([^\r\n]*)\r?$/gm,Nn=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Cn=/^(?:GET|HEAD)$/,kn=/^\/\//,En=/^([\w.+-]+:)(?:\/\/([^\/?#:]*)(?::(\d+)|)|)/,Sn=b.fn.load,An={},jn={},Dn="*/".concat("*");try{yn=a.href}catch(Ln){yn=o.createElement("a"),yn.href="",yn=yn.href}mn=En.exec(yn.toLowerCase())||[];function Hn(e){return function(t,n){"string"!=typeof t&&(n=t,t="*");var r,i=0,o=t.toLowerCase().match(w)||[];if(b.isFunction(n))while(r=o[i++])"+"===r[0]?(r=r.slice(1)||"*",(e[r]=e[r]||[]).unshift(n)):(e[r]=e[r]||[]).push(n)}}function qn(e,n,r,i){var o={},a=e===jn;function s(u){var l;return o[u]=!0,b.each(e[u]||[],function(e,u){var c=u(n,r,i);return"string"!=typeof c||a||o[c]?a?!(l=c):t:(n.dataTypes.unshift(c),s(c),!1)}),l}return s(n.dataTypes[0])||!o["*"]&&s("*")}function Mn(e,n){var r,i,o=b.ajaxSettings.flatOptions||{};for(i in n)n[i]!==t&&((o[i]?e:r||(r={}))[i]=n[i]);return r&&b.extend(!0,e,r),e}b.fn.load=function(e,n,r){if("string"!=typeof e&&Sn)return Sn.apply(this,arguments);var i,o,a,s=this,u=e.indexOf(" ");return u>=0&&(i=e.slice(u,e.length),e=e.slice(0,u)),b.isFunction(n)?(r=n,n=t):n&&"object"==typeof n&&(a="POST"),s.length>0&&b.ajax({url:e,type:a,dataType:"html",data:n}).done(function(e){o=arguments,s.html(i?b("<div>").append(b.parseHTML(e)).find(i):e)}).complete(r&&function(e,t){s.each(r,o||[e.responseText,t,e])}),this},b.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){b.fn[t]=function(e){return this.on(t,e)}}),b.each(["get","post"],function(e,n){b[n]=function(e,r,i,o){return b.isFunction(r)&&(o=o||i,i=r,r=t),b.ajax({url:e,type:n,dataType:o,data:r,success:i})}}),b.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:yn,type:"GET",isLocal:Nn.test(mn[1]),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Dn,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText"},converters:{"* text":e.String,"text html":!0,"text json":b.parseJSON,"text xml":b.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?Mn(Mn(e,b.ajaxSettings),t):Mn(b.ajaxSettings,e)},ajaxPrefilter:Hn(An),ajaxTransport:Hn(jn),ajax:function(e,n){"object"==typeof e&&(n=e,e=t),n=n||{};var r,i,o,a,s,u,l,c,p=b.ajaxSetup({},n),f=p.context||p,d=p.context&&(f.nodeType||f.jquery)?b(f):b.event,h=b.Deferred(),g=b.Callbacks("once memory"),m=p.statusCode||{},y={},v={},x=0,T="canceled",N={readyState:0,getResponseHeader:function(e){var t;if(2===x){if(!c){c={};while(t=Tn.exec(a))c[t[1].toLowerCase()]=t[2]}t=c[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return 2===x?a:null},setRequestHeader:function(e,t){var n=e.toLowerCase();return x||(e=v[n]=v[n]||e,y[e]=t),this},overrideMimeType:function(e){return x||(p.mimeType=e),this},statusCode:function(e){var t;if(e)if(2>x)for(t in e)m[t]=[m[t],e[t]];else N.always(e[N.status]);return this},abort:function(e){var t=e||T;return l&&l.abort(t),k(0,t),this}};if(h.promise(N).complete=g.add,N.success=N.done,N.error=N.fail,p.url=((e||p.url||yn)+"").replace(xn,"").replace(kn,mn[1]+"//"),p.type=n.method||n.type||p.method||p.type,p.dataTypes=b.trim(p.dataType||"*").toLowerCase().match(w)||[""],null==p.crossDomain&&(r=En.exec(p.url.toLowerCase()),p.crossDomain=!(!r||r[1]===mn[1]&&r[2]===mn[2]&&(r[3]||("http:"===r[1]?80:443))==(mn[3]||("http:"===mn[1]?80:443)))),p.data&&p.processData&&"string"!=typeof p.data&&(p.data=b.param(p.data,p.traditional)),qn(An,p,n,N),2===x)return N;u=p.global,u&&0===b.active++&&b.event.trigger("ajaxStart"),p.type=p.type.toUpperCase(),p.hasContent=!Cn.test(p.type),o=p.url,p.hasContent||(p.data&&(o=p.url+=(bn.test(o)?"&":"?")+p.data,delete p.data),p.cache===!1&&(p.url=wn.test(o)?o.replace(wn,"$1_="+vn++):o+(bn.test(o)?"&":"?")+"_="+vn++)),p.ifModified&&(b.lastModified[o]&&N.setRequestHeader("If-Modified-Since",b.lastModified[o]),b.etag[o]&&N.setRequestHeader("If-None-Match",b.etag[o])),(p.data&&p.hasContent&&p.contentType!==!1||n.contentType)&&N.setRequestHeader("Content-Type",p.contentType),N.setRequestHeader("Accept",p.dataTypes[0]&&p.accepts[p.dataTypes[0]]?p.accepts[p.dataTypes[0]]+("*"!==p.dataTypes[0]?", "+Dn+"; q=0.01":""):p.accepts["*"]);for(i in p.headers)N.setRequestHeader(i,p.headers[i]);if(p.beforeSend&&(p.beforeSend.call(f,N,p)===!1||2===x))return N.abort();T="abort";for(i in{success:1,error:1,complete:1})N[i](p[i]);if(l=qn(jn,p,n,N)){N.readyState=1,u&&d.trigger("ajaxSend",[N,p]),p.async&&p.timeout>0&&(s=setTimeout(function(){N.abort("timeout")},p.timeout));try{x=1,l.send(y,k)}catch(C){if(!(2>x))throw C;k(-1,C)}}else k(-1,"No Transport");function k(e,n,r,i){var c,y,v,w,T,C=n;2!==x&&(x=2,s&&clearTimeout(s),l=t,a=i||"",N.readyState=e>0?4:0,r&&(w=_n(p,N,r)),e>=200&&300>e||304===e?(p.ifModified&&(T=N.getResponseHeader("Last-Modified"),T&&(b.lastModified[o]=T),T=N.getResponseHeader("etag"),T&&(b.etag[o]=T)),204===e?(c=!0,C="nocontent"):304===e?(c=!0,C="notmodified"):(c=Fn(p,w),C=c.state,y=c.data,v=c.error,c=!v)):(v=C,(e||!C)&&(C="error",0>e&&(e=0))),N.status=e,N.statusText=(n||C)+"",c?h.resolveWith(f,[y,C,N]):h.rejectWith(f,[N,C,v]),N.statusCode(m),m=t,u&&d.trigger(c?"ajaxSuccess":"ajaxError",[N,p,c?y:v]),g.fireWith(f,[N,C]),u&&(d.trigger("ajaxComplete",[N,p]),--b.active||b.event.trigger("ajaxStop")))}return N},getScript:function(e,n){return b.get(e,t,n,"script")},getJSON:function(e,t,n){return b.get(e,t,n,"json")}});function _n(e,n,r){var i,o,a,s,u=e.contents,l=e.dataTypes,c=e.responseFields;for(s in c)s in r&&(n[c[s]]=r[s]);while("*"===l[0])l.shift(),o===t&&(o=e.mimeType||n.getResponseHeader("Content-Type"));if(o)for(s in u)if(u[s]&&u[s].test(o)){l.unshift(s);break}if(l[0]in r)a=l[0];else{for(s in r){if(!l[0]||e.converters[s+" "+l[0]]){a=s;break}i||(i=s)}a=a||i}return a?(a!==l[0]&&l.unshift(a),r[a]):t}function Fn(e,t){var n,r,i,o,a={},s=0,u=e.dataTypes.slice(),l=u[0];if(e.dataFilter&&(t=e.dataFilter(t,e.dataType)),u[1])for(i in e.converters)a[i.toLowerCase()]=e.converters[i];for(;r=u[++s];)if("*"!==r){if("*"!==l&&l!==r){if(i=a[l+" "+r]||a["* "+r],!i)for(n in a)if(o=n.split(" "),o[1]===r&&(i=a[l+" "+o[0]]||a["* "+o[0]])){i===!0?i=a[n]:a[n]!==!0&&(r=o[0],u.splice(s--,0,r));break}if(i!==!0)if(i&&e["throws"])t=i(t);else try{t=i(t)}catch(c){return{state:"parsererror",error:i?c:"No conversion from "+l+" to "+r}}}l=r}return{state:"success",data:t}}b.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/(?:java|ecma)script/},converters:{"text script":function(e){return b.globalEval(e),e}}}),b.ajaxPrefilter("script",function(e){e.cache===t&&(e.cache=!1),e.crossDomain&&(e.type="GET",e.global=!1)}),b.ajaxTransport("script",function(e){if(e.crossDomain){var n,r=o.head||b("head")[0]||o.documentElement;return{send:function(t,i){n=o.createElement("script"),n.async=!0,e.scriptCharset&&(n.charset=e.scriptCharset),n.src=e.url,n.onload=n.onreadystatechange=function(e,t){(t||!n.readyState||/loaded|complete/.test(n.readyState))&&(n.onload=n.onreadystatechange=null,n.parentNode&&n.parentNode.removeChild(n),n=null,t||i(200,"success"))},r.insertBefore(n,r.firstChild)},abort:function(){n&&n.onload(t,!0)}}}});var On=[],Bn=/(=)\?(?=&|$)|\?\?/;b.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=On.pop()||b.expando+"_"+vn++;return this[e]=!0,e}}),b.ajaxPrefilter("json jsonp",function(n,r,i){var o,a,s,u=n.jsonp!==!1&&(Bn.test(n.url)?"url":"string"==typeof n.data&&!(n.contentType||"").indexOf("application/x-www-form-urlencoded")&&Bn.test(n.data)&&"data");return u||"jsonp"===n.dataTypes[0]?(o=n.jsonpCallback=b.isFunction(n.jsonpCallback)?n.jsonpCallback():n.jsonpCallback,u?n[u]=n[u].replace(Bn,"$1"+o):n.jsonp!==!1&&(n.url+=(bn.test(n.url)?"&":"?")+n.jsonp+"="+o),n.converters["script json"]=function(){return s||b.error(o+" was not called"),s[0]},n.dataTypes[0]="json",a=e[o],e[o]=function(){s=arguments},i.always(function(){e[o]=a,n[o]&&(n.jsonpCallback=r.jsonpCallback,On.push(o)),s&&b.isFunction(a)&&a(s[0]),s=a=t}),"script"):t});var Pn,Rn,Wn=0,$n=e.ActiveXObject&&function(){var e;for(e in Pn)Pn[e](t,!0)};function In(){try{return new e.XMLHttpRequest}catch(t){}}function zn(){try{return new e.ActiveXObject("Microsoft.XMLHTTP")}catch(t){}}b.ajaxSettings.xhr=e.ActiveXObject?function(){return!this.isLocal&&In()||zn()}:In,Rn=b.ajaxSettings.xhr(),b.support.cors=!!Rn&&"withCredentials"in Rn,Rn=b.support.ajax=!!Rn,Rn&&b.ajaxTransport(function(n){if(!n.crossDomain||b.support.cors){var r;return{send:function(i,o){var a,s,u=n.xhr();if(n.username?u.open(n.type,n.url,n.async,n.username,n.password):u.open(n.type,n.url,n.async),n.xhrFields)for(s in n.xhrFields)u[s]=n.xhrFields[s];n.mimeType&&u.overrideMimeType&&u.overrideMimeType(n.mimeType),n.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");try{for(s in i)u.setRequestHeader(s,i[s])}catch(l){}u.send(n.hasContent&&n.data||null),r=function(e,i){var s,l,c,p;try{if(r&&(i||4===u.readyState))if(r=t,a&&(u.onreadystatechange=b.noop,$n&&delete Pn[a]),i)4!==u.readyState&&u.abort();else{p={},s=u.status,l=u.getAllResponseHeaders(),"string"==typeof u.responseText&&(p.text=u.responseText);try{c=u.statusText}catch(f){c=""}s||!n.isLocal||n.crossDomain?1223===s&&(s=204):s=p.text?200:404}}catch(d){i||o(-1,d)}p&&o(s,c,p,l)},n.async?4===u.readyState?setTimeout(r):(a=++Wn,$n&&(Pn||(Pn={},b(e).unload($n)),Pn[a]=r),u.onreadystatechange=r):r()},abort:function(){r&&r(t,!0)}}}});var Xn,Un,Vn=/^(?:toggle|show|hide)$/,Yn=RegExp("^(?:([+-])=|)("+x+")([a-z%]*)$","i"),Jn=/queueHooks$/,Gn=[nr],Qn={"*":[function(e,t){var n,r,i=this.createTween(e,t),o=Yn.exec(t),a=i.cur(),s=+a||0,u=1,l=20;if(o){if(n=+o[2],r=o[3]||(b.cssNumber[e]?"":"px"),"px"!==r&&s){s=b.css(i.elem,e,!0)||n||1;do u=u||".5",s/=u,b.style(i.elem,e,s+r);while(u!==(u=i.cur()/a)&&1!==u&&--l)}i.unit=r,i.start=s,i.end=o[1]?s+(o[1]+1)*n:n}return i}]};function Kn(){return setTimeout(function(){Xn=t}),Xn=b.now()}function Zn(e,t){b.each(t,function(t,n){var r=(Qn[t]||[]).concat(Qn["*"]),i=0,o=r.length;for(;o>i;i++)if(r[i].call(e,t,n))return})}function er(e,t,n){var r,i,o=0,a=Gn.length,s=b.Deferred().always(function(){delete u.elem}),u=function(){if(i)return!1;var t=Xn||Kn(),n=Math.max(0,l.startTime+l.duration-t),r=n/l.duration||0,o=1-r,a=0,u=l.tweens.length;for(;u>a;a++)l.tweens[a].run(o);return s.notifyWith(e,[l,o,n]),1>o&&u?n:(s.resolveWith(e,[l]),!1)},l=s.promise({elem:e,props:b.extend({},t),opts:b.extend(!0,{specialEasing:{}},n),originalProperties:t,originalOptions:n,startTime:Xn||Kn(),duration:n.duration,tweens:[],createTween:function(t,n){var r=b.Tween(e,l.opts,t,n,l.opts.specialEasing[t]||l.opts.easing);return l.tweens.push(r),r},stop:function(t){var n=0,r=t?l.tweens.length:0;if(i)return this;for(i=!0;r>n;n++)l.tweens[n].run(1);return t?s.resolveWith(e,[l,t]):s.rejectWith(e,[l,t]),this}}),c=l.props;for(tr(c,l.opts.specialEasing);a>o;o++)if(r=Gn[o].call(l,e,c,l.opts))return r;return Zn(l,c),b.isFunction(l.opts.start)&&l.opts.start.call(e,l),b.fx.timer(b.extend(u,{elem:e,anim:l,queue:l.opts.queue})),l.progress(l.opts.progress).done(l.opts.done,l.opts.complete).fail(l.opts.fail).always(l.opts.always)}function tr(e,t){var n,r,i,o,a;for(i in e)if(r=b.camelCase(i),o=t[r],n=e[i],b.isArray(n)&&(o=n[1],n=e[i]=n[0]),i!==r&&(e[r]=n,delete e[i]),a=b.cssHooks[r],a&&"expand"in a){n=a.expand(n),delete e[r];for(i in n)i in e||(e[i]=n[i],t[i]=o)}else t[r]=o}b.Animation=b.extend(er,{tweener:function(e,t){b.isFunction(e)?(t=e,e=["*"]):e=e.split(" ");var n,r=0,i=e.length;for(;i>r;r++)n=e[r],Qn[n]=Qn[n]||[],Qn[n].unshift(t)},prefilter:function(e,t){t?Gn.unshift(e):Gn.push(e)}});function nr(e,t,n){var r,i,o,a,s,u,l,c,p,f=this,d=e.style,h={},g=[],m=e.nodeType&&nn(e);n.queue||(c=b._queueHooks(e,"fx"),null==c.unqueued&&(c.unqueued=0,p=c.empty.fire,c.empty.fire=function(){c.unqueued||p()}),c.unqueued++,f.always(function(){f.always(function(){c.unqueued--,b.queue(e,"fx").length||c.empty.fire()})})),1===e.nodeType&&("height"in t||"width"in t)&&(n.overflow=[d.overflow,d.overflowX,d.overflowY],"inline"===b.css(e,"display")&&"none"===b.css(e,"float")&&(b.support.inlineBlockNeedsLayout&&"inline"!==un(e.nodeName)?d.zoom=1:d.display="inline-block")),n.overflow&&(d.overflow="hidden",b.support.shrinkWrapBlocks||f.always(function(){d.overflow=n.overflow[0],d.overflowX=n.overflow[1],d.overflowY=n.overflow[2]}));for(i in t)if(a=t[i],Vn.exec(a)){if(delete t[i],u=u||"toggle"===a,a===(m?"hide":"show"))continue;g.push(i)}if(o=g.length){s=b._data(e,"fxshow")||b._data(e,"fxshow",{}),"hidden"in s&&(m=s.hidden),u&&(s.hidden=!m),m?b(e).show():f.done(function(){b(e).hide()}),f.done(function(){var t;b._removeData(e,"fxshow");for(t in h)b.style(e,t,h[t])});for(i=0;o>i;i++)r=g[i],l=f.createTween(r,m?s[r]:0),h[r]=s[r]||b.style(e,r),r in s||(s[r]=l.start,m&&(l.end=l.start,l.start="width"===r||"height"===r?1:0))}}function rr(e,t,n,r,i){return new rr.prototype.init(e,t,n,r,i)}b.Tween=rr,rr.prototype={constructor:rr,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||"swing",this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(b.cssNumber[n]?"":"px")},cur:function(){var e=rr.propHooks[this.prop];return e&&e.get?e.get(this):rr.propHooks._default.get(this)},run:function(e){var t,n=rr.propHooks[this.prop];return this.pos=t=this.options.duration?b.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):rr.propHooks._default.set(this),this}},rr.prototype.init.prototype=rr.prototype,rr.propHooks={_default:{get:function(e){var t;return null==e.elem[e.prop]||e.elem.style&&null!=e.elem.style[e.prop]?(t=b.css(e.elem,e.prop,""),t&&"auto"!==t?t:0):e.elem[e.prop]},set:function(e){b.fx.step[e.prop]?b.fx.step[e.prop](e):e.elem.style&&(null!=e.elem.style[b.cssProps[e.prop]]||b.cssHooks[e.prop])?b.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},rr.propHooks.scrollTop=rr.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},b.each(["toggle","show","hide"],function(e,t){var n=b.fn[t];b.fn[t]=function(e,r,i){return null==e||"boolean"==typeof e?n.apply(this,arguments):this.animate(ir(t,!0),e,r,i)}}),b.fn.extend({fadeTo:function(e,t,n,r){return this.filter(nn).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(e,t,n,r){var i=b.isEmptyObject(e),o=b.speed(t,n,r),a=function(){var t=er(this,b.extend({},e),o);a.finish=function(){t.stop(!0)},(i||b._data(this,"finish"))&&t.stop(!0)};return a.finish=a,i||o.queue===!1?this.each(a):this.queue(o.queue,a)},stop:function(e,n,r){var i=function(e){var t=e.stop;delete e.stop,t(r)};return"string"!=typeof e&&(r=n,n=e,e=t),n&&e!==!1&&this.queue(e||"fx",[]),this.each(function(){var t=!0,n=null!=e&&e+"queueHooks",o=b.timers,a=b._data(this);if(n)a[n]&&a[n].stop&&i(a[n]);else for(n in a)a[n]&&a[n].stop&&Jn.test(n)&&i(a[n]);for(n=o.length;n--;)o[n].elem!==this||null!=e&&o[n].queue!==e||(o[n].anim.stop(r),t=!1,o.splice(n,1));(t||!r)&&b.dequeue(this,e)})},finish:function(e){return e!==!1&&(e=e||"fx"),this.each(function(){var t,n=b._data(this),r=n[e+"queue"],i=n[e+"queueHooks"],o=b.timers,a=r?r.length:0;for(n.finish=!0,b.queue(this,e,[]),i&&i.cur&&i.cur.finish&&i.cur.finish.call(this),t=o.length;t--;)o[t].elem===this&&o[t].queue===e&&(o[t].anim.stop(!0),o.splice(t,1));for(t=0;a>t;t++)r[t]&&r[t].finish&&r[t].finish.call(this);delete n.finish})}});function ir(e,t){var n,r={height:e},i=0;for(t=t?1:0;4>i;i+=2-t)n=Zt[i],r["margin"+n]=r["padding"+n]=e;return t&&(r.opacity=r.width=e),r}b.each({slideDown:ir("show"),slideUp:ir("hide"),slideToggle:ir("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,t){b.fn[e]=function(e,n,r){return this.animate(t,e,n,r)}}),b.speed=function(e,t,n){var r=e&&"object"==typeof e?b.extend({},e):{complete:n||!n&&t||b.isFunction(e)&&e,duration:e,easing:n&&t||t&&!b.isFunction(t)&&t};return r.duration=b.fx.off?0:"number"==typeof r.duration?r.duration:r.duration in b.fx.speeds?b.fx.speeds[r.duration]:b.fx.speeds._default,(null==r.queue||r.queue===!0)&&(r.queue="fx"),r.old=r.complete,r.complete=function(){b.isFunction(r.old)&&r.old.call(this),r.queue&&b.dequeue(this,r.queue)},r},b.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2}},b.timers=[],b.fx=rr.prototype.init,b.fx.tick=function(){var e,n=b.timers,r=0;for(Xn=b.now();n.length>r;r++)e=n[r],e()||n[r]!==e||n.splice(r--,1);n.length||b.fx.stop(),Xn=t},b.fx.timer=function(e){e()&&b.timers.push(e)&&b.fx.start()},b.fx.interval=13,b.fx.start=function(){Un||(Un=setInterval(b.fx.tick,b.fx.interval))},b.fx.stop=function(){clearInterval(Un),Un=null},b.fx.speeds={slow:600,fast:200,_default:400},b.fx.step={},b.expr&&b.expr.filters&&(b.expr.filters.animated=function(e){return b.grep(b.timers,function(t){return e===t.elem}).length}),b.fn.offset=function(e){if(arguments.length)return e===t?this:this.each(function(t){b.offset.setOffset(this,e,t)});var n,r,o={top:0,left:0},a=this[0],s=a&&a.ownerDocument;if(s)return n=s.documentElement,b.contains(n,a)?(typeof a.getBoundingClientRect!==i&&(o=a.getBoundingClientRect()),r=or(s),{top:o.top+(r.pageYOffset||n.scrollTop)-(n.clientTop||0),left:o.left+(r.pageXOffset||n.scrollLeft)-(n.clientLeft||0)}):o},b.offset={setOffset:function(e,t,n){var r=b.css(e,"position");"static"===r&&(e.style.position="relative");var i=b(e),o=i.offset(),a=b.css(e,"top"),s=b.css(e,"left"),u=("absolute"===r||"fixed"===r)&&b.inArray("auto",[a,s])>-1,l={},c={},p,f;u?(c=i.position(),p=c.top,f=c.left):(p=parseFloat(a)||0,f=parseFloat(s)||0),b.isFunction(t)&&(t=t.call(e,n,o)),null!=t.top&&(l.top=t.top-o.top+p),null!=t.left&&(l.left=t.left-o.left+f),"using"in t?t.using.call(e,l):i.css(l)}},b.fn.extend({position:function(){if(this[0]){var e,t,n={top:0,left:0},r=this[0];return"fixed"===b.css(r,"position")?t=r.getBoundingClientRect():(e=this.offsetParent(),t=this.offset(),b.nodeName(e[0],"html")||(n=e.offset()),n.top+=b.css(e[0],"borderTopWidth",!0),n.left+=b.css(e[0],"borderLeftWidth",!0)),{top:t.top-n.top-b.css(r,"marginTop",!0),left:t.left-n.left-b.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent||o.documentElement;while(e&&!b.nodeName(e,"html")&&"static"===b.css(e,"position"))e=e.offsetParent;return e||o.documentElement})}}),b.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,n){var r=/Y/.test(n);b.fn[e]=function(i){return b.access(this,function(e,i,o){var a=or(e);return o===t?a?n in a?a[n]:a.document.documentElement[i]:e[i]:(a?a.scrollTo(r?b(a).scrollLeft():o,r?o:b(a).scrollTop()):e[i]=o,t)},e,i,arguments.length,null)}});function or(e){return b.isWindow(e)?e:9===e.nodeType?e.defaultView||e.parentWindow:!1}b.each({Height:"height",Width:"width"},function(e,n){b.each({padding:"inner"+e,content:n,"":"outer"+e},function(r,i){b.fn[i]=function(i,o){var a=arguments.length&&(r||"boolean"!=typeof i),s=r||(i===!0||o===!0?"margin":"border");return b.access(this,function(n,r,i){var o;return b.isWindow(n)?n.document.documentElement["client"+e]:9===n.nodeType?(o=n.documentElement,Math.max(n.body["scroll"+e],o["scroll"+e],n.body["offset"+e],o["offset"+e],o["client"+e])):i===t?b.css(n,r,s):b.style(n,r,i,s)},n,a?i:t,a,null)}})}),e.jQuery=e.$=b,"function"==typeof define&&define.amd&&define.amd.jQuery&&define("jquery",[],function(){return b})})(window);
\ No newline at end of file
diff --git a/app/assets/javascripts/jquery-visible-min.js b/vendor/assets/javascripts/jquery-visible-min.js
similarity index 100%
rename from app/assets/javascripts/jquery-visible-min.js
rename to vendor/assets/javascripts/jquery-visible-min.js
diff --git a/app/assets/javascripts/mousetrap-min.js b/vendor/assets/javascripts/mousetrap-min.js
similarity index 100%
rename from app/assets/javascripts/mousetrap-min.js
rename to vendor/assets/javascripts/mousetrap-min.js
diff --git a/app/assets/javascripts/underscore-min.js b/vendor/assets/javascripts/underscore-min.js
similarity index 100%
rename from app/assets/javascripts/underscore-min.js
rename to vendor/assets/javascripts/underscore-min.js
