├── .circleci └── config.yml ├── .dockerignore ├── .env.development ├── .env.test ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── .ruby-version ├── .stringer.env ├── .tool-versions ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── Procfile ├── README.md ├── Rakefile ├── app.json ├── app ├── assets │ ├── config │ │ └── manifest.js │ ├── images │ │ └── .keep │ ├── javascripts │ │ └── application.js │ └── stylesheets │ │ ├── application.css │ │ ├── bootstrap-min.css │ │ ├── flat-ui-no-icons.css │ │ ├── font-awesome-min.css │ │ ├── lato-fonts.css │ │ └── reenie-beanie-font.css ├── commands │ ├── cast_boolean.rb │ ├── feed │ │ ├── create.rb │ │ ├── export_to_opml.rb │ │ ├── fetch_all.rb │ │ ├── fetch_all_for_user.rb │ │ ├── fetch_one.rb │ │ ├── find_new_stories.rb │ │ └── import_from_opml.rb │ ├── fever_api.rb │ ├── fever_api │ │ ├── authentication.rb │ │ ├── read_favicons.rb │ │ ├── read_feeds.rb │ │ ├── read_feeds_groups.rb │ │ ├── read_groups.rb │ │ ├── read_items.rb │ │ ├── read_links.rb │ │ ├── response.rb │ │ ├── sync_saved_item_ids.rb │ │ ├── sync_unread_item_ids.rb │ │ ├── write_mark_feed.rb │ │ ├── write_mark_group.rb │ │ └── write_mark_item.rb │ ├── story │ │ ├── mark_all_as_read.rb │ │ ├── mark_as_read.rb │ │ ├── mark_as_starred.rb │ │ ├── mark_as_unread.rb │ │ ├── mark_as_unstarred.rb │ │ ├── mark_feed_as_read.rb │ │ └── mark_group_as_read.rb │ └── user │ │ └── sign_in_user.rb ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ ├── debug_controller.rb │ ├── exports_controller.rb │ ├── feeds_controller.rb │ ├── fever_controller.rb │ ├── imports_controller.rb │ ├── passwords_controller.rb │ ├── profiles_controller.rb │ ├── sessions_controller.rb │ ├── settings_controller.rb │ ├── stories_controller.rb │ └── tutorials_controller.rb ├── helpers │ └── url_helpers.rb ├── jobs │ ├── application_job.rb │ └── callable_job.rb ├── models │ ├── application_record.rb │ ├── concerns │ │ └── .keep │ ├── feed.rb │ ├── group.rb │ ├── migration_status.rb │ ├── setting.rb │ ├── setting │ │ └── user_signup.rb │ ├── story.rb │ ├── subscription.rb │ └── user.rb ├── repositories │ ├── feed_repository.rb │ ├── group_repository.rb │ ├── story_repository.rb │ └── user_repository.rb ├── tasks │ └── remove_old_stories.rb ├── utils │ ├── authorization.rb │ ├── content_sanitizer.rb │ ├── feed_discovery.rb │ ├── opml_parser.rb │ └── sample_story.rb └── views │ ├── debug │ ├── heroku.html.erb │ └── index.html.erb │ ├── feeds │ ├── _action_bar.html.erb │ ├── _feed.html.erb │ ├── _single_feed_action_bar.html.erb │ ├── edit.html.erb │ ├── index.html.erb │ ├── new.html.erb │ └── show.html.erb │ ├── imports │ └── new.html.erb │ ├── js │ └── templates │ │ └── _story.js.erb │ ├── layouts │ ├── _flash.html.erb │ ├── _footer.html.erb │ ├── _shortcuts.html.erb │ └── application.html.erb │ ├── passwords │ └── new.html.erb │ ├── profiles │ └── edit.html.erb │ ├── sessions │ └── new.erb │ ├── settings │ └── index.html.erb │ ├── stories │ ├── _action_bar.html.erb │ ├── _js.html.erb │ ├── _mark_all_as_read_form.html.erb │ ├── _templates.html.erb │ ├── _zen.html.erb │ ├── archived.html.erb │ ├── index.html.erb │ └── starred.html.erb │ └── tutorials │ ├── _action_bar.html.erb │ └── index.html.erb ├── bin ├── dev ├── rails ├── rake ├── rubocop ├── setup └── thrust ├── config.ru ├── config ├── application.rb ├── boot.rb ├── database.yml ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── assets.rb │ ├── content_security_policy.rb │ ├── dotenv.rb │ ├── filter_parameter_logging.rb │ ├── good_job.rb │ ├── inflections.rb │ ├── permissions_policy.rb │ └── session_store.rb ├── locales │ ├── de.yml │ ├── el-GR.yml │ ├── en.yml │ ├── eo.yml │ ├── es.yml │ ├── fr.yml │ ├── he.yml │ ├── it.yml │ ├── ja.yml │ ├── nl.yml │ ├── pt-BR.yml │ ├── pt.yml │ ├── ru.yml │ ├── sv.yml │ ├── tr.yml │ ├── zh-CN.yml │ └── zh-TW.yml ├── puma.rb └── routes.rb ├── db ├── migrate │ ├── 20130409010818_create_feeds.rb │ ├── 20130409010826_create_stories.rb │ ├── 20130412185253_add_new_fields_to_stories.rb │ ├── 20130418221144_add_user_model.rb │ ├── 20130423001740_drop_email_from_user.rb │ ├── 20130423180446_remove_author_from_stories.rb │ ├── 20130425211008_add_setup_complete_to_user.rb │ ├── 20130425222157_add_delayed_job.rb │ ├── 20130429232127_add_status_to_feeds.rb │ ├── 20130504005816_text_url.rb │ ├── 20130504022615_change_story_permalink_column.rb │ ├── 20130509131045_add_unique_constraints.rb │ ├── 20130513025939_add_keep_unread_to_stories.rb │ ├── 20130513044029_add_is_starred_status_for_stories.rb │ ├── 20130522014405_add_api_key_to_user.rb │ ├── 20130730120312_add_entry_id_to_stories.rb │ ├── 20130805113712_update_stories_unique_constraints.rb │ ├── 20130821020313_update_nil_entry_ids.rb │ ├── 20130905204142_use_text_datatype_for_title_and_entry_id.rb │ ├── 20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb │ ├── 20140421224454_fix_invalid_unicode.rb │ ├── 20141102103617_fix_invalid_titles_with_unicode_line_endings.rb │ ├── 20221206231914_add_enclosure_url_to_stories.rb │ ├── 20230120222742_drop_setup_complete.rb │ ├── 20230221233057_add_user_id_to_tables.rb │ ├── 20230223045525_add_null_false_to_associations.rb │ ├── 20230223231930_add_username_to_users.rb │ ├── 20230224042638_update_unique_indexes.rb │ ├── 20230301024452_encrypt_api_key.rb │ ├── 20230305010750_create_good_jobs.rb │ ├── 20230312193113_drop_delayed_job.rb │ ├── 20230313034938_add_admin_to_users.rb │ ├── 20230330215830_create_subscriptions.rb │ ├── 20230721160939_create_settings.rb │ ├── 20230801025230_create_good_job_settings.rb │ ├── 20230801025231_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb │ ├── 20230801025232_create_good_job_batches.rb │ ├── 20230801025233_create_good_job_executions.rb │ ├── 20230801025234_create_good_jobs_error_event.rb │ ├── 20240226201050_add_precision_to_timestamps.rb │ ├── 20240313195404_add_default_to_stories.rb │ ├── 20240314031219_recreate_good_job_cron_indexes_with_conditional.rb │ ├── 20240314031220_create_good_job_labels.rb │ ├── 20240314031221_create_good_job_labels_index.rb │ ├── 20240314031222_remove_good_job_active_id_index.rb │ ├── 20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb │ ├── 20240316211109_add_stories_order_to_users.rb │ ├── 20240709172405_create_good_job_execution_error_backtrace.rb │ ├── 20240709172406_create_good_job_process_lock_ids.rb │ ├── 20240709172407_create_good_job_process_lock_indexes.rb │ └── 20240709172408_create_good_job_execution_duration.rb ├── schema.rb └── seeds.rb ├── docker-compose.yml ├── docker ├── init_or_update_env.rb ├── start.sh └── supervisord.conf ├── docs ├── Docker.md ├── Heroku.md ├── OpenShift.md └── VPS.md ├── lib ├── admin_constraint.rb ├── assets │ └── .keep └── tasks │ └── .keep ├── log ├── .gitkeep └── .keep ├── public ├── 400.html ├── 404.html ├── 406-unsupported-browser.html ├── 422.html ├── 500.html ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── favicon.ico ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ ├── lato │ │ ├── Lato-Black.eot │ │ ├── Lato-Black.ttf │ │ ├── Lato-Black.woff │ │ ├── Lato-Black.woff2 │ │ ├── Lato-Bold.eot │ │ ├── Lato-Bold.ttf │ │ ├── Lato-Bold.woff │ │ ├── Lato-Bold.woff2 │ │ ├── Lato-Italic.eot │ │ ├── Lato-Italic.ttf │ │ ├── Lato-Italic.woff │ │ ├── Lato-Italic.woff2 │ │ ├── Lato-Light.eot │ │ ├── Lato-Light.ttf │ │ ├── Lato-Light.woff │ │ ├── Lato-Light.woff2 │ │ ├── Lato-Regular.eot │ │ ├── Lato-Regular.ttf │ │ ├── Lato-Regular.woff │ │ └── Lato-Regular.woff2 │ └── reenie-beanie │ │ ├── ReenieBeanie.eot │ │ ├── ReenieBeanie.svg │ │ ├── ReenieBeanie.ttf │ │ ├── ReenieBeanie.woff │ │ └── ReenieBeanie.woff2 ├── icon.png ├── icon.svg ├── img │ ├── apple-touch-icon-precomposed.png │ ├── arrow-left.png │ ├── arrow-right-up.png │ ├── arrow-right.png │ ├── arrow-up-left.png │ ├── arrow-up-right.png │ ├── favicon.png │ └── glyphicons-halflings-white.png ├── manifest.json └── robots.txt ├── screenshots ├── feed.png ├── instructions.png ├── keyboard_shortcuts.png ├── logo.png ├── rss-zero.png └── stories.png ├── spec ├── commands │ ├── cast_boolean_spec.rb │ ├── feed │ │ ├── create_spec.rb │ │ ├── export_to_opml_spec.rb │ │ ├── fetch_all_for_user_spec.rb │ │ ├── fetch_all_spec.rb │ │ ├── fetch_one_spec.rb │ │ ├── find_new_stories_spec.rb │ │ └── import_from_opml_spec.rb │ ├── fever_api │ │ ├── authentication_spec.rb │ │ ├── read_favicons_spec.rb │ │ ├── read_feeds_groups_spec.rb │ │ ├── read_feeds_spec.rb │ │ ├── read_groups_spec.rb │ │ ├── read_items_spec.rb │ │ ├── read_links_spec.rb │ │ ├── sync_saved_item_ids_spec.rb │ │ ├── sync_unread_item_ids_spec.rb │ │ ├── write_mark_feed_spec.rb │ │ ├── write_mark_group_spec.rb │ │ └── write_mark_item_spec.rb │ ├── story │ │ ├── mark_all_as_read_spec.rb │ │ ├── mark_as_read_spec.rb │ │ ├── mark_as_starred_spec.rb │ │ ├── mark_as_unread_spec.rb │ │ ├── mark_as_unstarred_spec.rb │ │ ├── mark_feed_as_read_spec.rb │ │ └── mark_group_as_read_spec.rb │ └── user │ │ └── sign_in_user_spec.rb ├── factories │ ├── feeds.rb │ ├── groups.rb │ ├── stories.rb │ └── users.rb ├── fixtures │ └── feeds.opml ├── helpers │ └── url_helpers_spec.rb ├── integration │ └── feed_importing_spec.rb ├── javascript │ ├── spec │ │ ├── models │ │ │ └── story_spec.js │ │ ├── spec_helper.js │ │ └── views │ │ │ └── story_view_spec.js │ ├── support │ │ ├── vendor │ │ │ ├── css │ │ │ │ └── mocha.css │ │ │ └── js │ │ │ │ ├── chai-backbone.js │ │ │ │ ├── chai-changes.js │ │ │ │ ├── chai.js │ │ │ │ ├── mocha.js │ │ │ │ ├── sinon-chai.js │ │ │ │ └── sinon.js │ │ └── views │ │ │ └── test │ │ │ └── index.html.erb │ └── test_controller.rb ├── jobs │ └── callable_job_spec.rb ├── lib │ └── admin_constraint_spec.rb ├── models │ ├── application_record_spec.rb │ ├── feed_spec.rb │ ├── group_spec.rb │ ├── migration_status_spec.rb │ ├── setting │ │ └── user_signup_spec.rb │ ├── story_spec.rb │ └── user_spec.rb ├── rails_helper.rb ├── repositories │ ├── feed_repository_spec.rb │ ├── group_repository_spec.rb │ ├── story_repository_spec.rb │ └── user_repository_spec.rb ├── requests │ ├── debug_controller_spec.rb │ ├── exports_controller_spec.rb │ ├── feeds_controller_spec.rb │ ├── fever_controller_spec.rb │ ├── imports_controller_spec.rb │ ├── passwords_controller_spec.rb │ ├── profiles_controller_spec.rb │ ├── sessions_controller_spec.rb │ ├── settings_controller_spec.rb │ ├── stories_controller_spec.rb │ └── tutorials_controller_spec.rb ├── sample_data │ ├── feeds │ │ ├── feed01_valid_feed │ │ │ ├── feed.xml │ │ │ └── feed_updated.xml │ │ └── feed02_invalid_published_dates │ │ │ └── feed.xml │ └── subscriptions.xml ├── spec_helper.rb ├── support │ ├── axe_core.rb │ ├── capybara.rb │ ├── coverage.rb │ ├── downloads.rb │ ├── factory_bot.rb │ ├── feed_server.rb │ ├── files │ │ └── subscriptions.xml │ ├── generate_xml.rb │ ├── matchers.rb │ ├── matchers │ │ ├── change_all_records.rb │ │ ├── change_record.rb │ │ ├── delete_record.rb │ │ └── invoke.rb │ ├── request_helpers.rb │ ├── system_helpers.rb │ ├── webmock.rb │ └── with_model.rb ├── system │ ├── account_setup_spec.rb │ ├── application_settings_spec.rb │ ├── export_spec.rb │ ├── feeds_index_spec.rb │ ├── good_job_spec.rb │ ├── import_spec.rb │ ├── js_tests_spec.rb │ ├── profile_spec.rb │ └── stories_index_spec.rb ├── tasks │ └── remove_old_stories_spec.rb └── utils │ ├── authorization_spec.rb │ ├── content_sanitizer_spec.rb │ ├── feed_discovery_spec.rb │ ├── i18n_support_spec.rb │ └── opml_parser_spec.rb ├── tmp ├── .keep └── pids │ └── .keep └── vendor ├── .keep └── assets └── javascripts ├── backbone-min.js ├── bootstrap-min.js ├── bootstrap.file-input.js ├── jquery-min.js ├── jquery-visible-min.js ├── mousetrap-min.js └── underscore-min.js /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/* 2 | .gitignore 3 | log/* 4 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | # you can use `rails secret` to generate this for production 2 | SECRET_KEY_BASE=dev_secret 3 | 4 | # you can use `rails db:encryption:init` to generate these for production 5 | ENCRYPTION_PRIMARY_KEY=dev_primary_key 6 | ENCRYPTION_DETERMINISTIC_KEY=dev_deterministic_key 7 | ENCRYPTION_KEY_DERIVATION_SALT=dev_derivation_salt 8 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | # you can use `rails secret` to generate this for production 2 | SECRET_KEY_BASE=test_secret 3 | 4 | # you can use `rails db:encryption:init` to generate these for production 5 | ENCRYPTION_PRIMARY_KEY=test_primary_key 6 | ENCRYPTION_DETERMINISTIC_KEY=test_deterministic_key 7 | ENCRYPTION_KEY_DERIVATION_SALT=test_derivation_salt 8 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # See https://git-scm.com/docs/gitattributes for more about git attribute files. 2 | 3 | # Mark the database schema as having been generated. 4 | db/schema.rb linguist-generated 5 | 6 | # Mark any vendored files as having been vendored. 7 | vendor/* linguist-vendored 8 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mockdeep 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker image and push to Dockerhub 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | 8 | env: 9 | IMAGE_NAME: stringerrss/stringer 10 | 11 | jobs: 12 | build_docker: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v3 20 | 21 | - name: Set up Docker Buildx 22 | id: buildx 23 | uses: docker/setup-buildx-action@v3 24 | 25 | - name: Extract Docker sha tag 26 | id: get-tag-sha 27 | uses: docker/metadata-action@v4 28 | with: 29 | images: ${{ env.IMAGE_NAME }} 30 | tags: type=sha 31 | 32 | - name: Extract Docker latest tag 33 | id: get-tag-latest 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: ${{ env.IMAGE_NAME }} 37 | tags: type=raw, value=latest 38 | 39 | - name: Log in to Docker Hub 40 | if: ${{ github.ref_name == 'main' }} 41 | id: login 42 | uses: docker/login-action@v3 43 | with: 44 | username: ${{ secrets.DOCKERHUB_USERNAME }} 45 | password: ${{ secrets.DOCKERHUB_TOKEN }} 46 | 47 | - name: Build and Push 48 | id: build-and-push 49 | uses: docker/build-push-action@v6 50 | with: 51 | context: . 52 | file: ./Dockerfile 53 | platforms: linux/amd64,linux/arm64 54 | push: ${{ github.ref_name == 'main' }} 55 | tags: | 56 | ${{ steps.get-tag-latest.outputs.tags }} 57 | ${{ steps.get-tag-sha.outputs.tags }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | # Ignore pidfiles, but keep the directory. 17 | /tmp/pids/* 18 | !/tmp/pids/ 19 | !/tmp/pids/.keep 20 | 21 | /coverage 22 | /public/assets 23 | 24 | # Ignore master key for decrypting credentials and more. 25 | /config/master.key 26 | 27 | spec/examples.txt 28 | 29 | # Ignore local .env files 30 | *.local 31 | .env 32 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require rails_helper 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.4 2 | -------------------------------------------------------------------------------- /.stringer.env: -------------------------------------------------------------------------------- 1 | SECRET_KEY_BASE=5e1a0474b6c8b517c58a676bb9baae9da8fc82d4c5a13a42a1b69c3b310fe666ff824bfe9426db1c0814ea83fdacb1a8f80eed90ae3501006ea17440136620d9 2 | ENCRYPTION_PRIMARY_KEY=773dddc695536c2e7bcfe7e56f1bfc9ba29a793663304a6b6ef4764867fd7fdb93b9bbf52f3cf67b11b5bcb31d1da3583202e216e08d645363d453feef776a60 3 | ENCRYPTION_DETERMINISTIC_KEY=a827a6e936dec463b1635803bb19b96815b74e7aa871c656ac8bce45c070dbdf893300ff5e633ee5143efcf5915ee52c760d851e1bfb48f794ac12a40d433398 4 | ENCRYPTION_KEY_DERIVATION_SALT=63a7e1e618d35721a98d9d71628bda6d5e4b154f5357ad84d78b20b3be09416a204dce57ba1bf1946cfcad05cc990ffc6e8693d801ee4b184d6ba92d5831d68a 5 | 6 | DATABASE_URL=postgres://:@/ 7 | FETCH_FEEDS_CRON='*/5 * * * *' 8 | CLEANUP_CRON='0 0 * * *' 9 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.4.4 2 | bundler 2.6.2 3 | postgres 14.6 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.4.4 2 | 3 | ENV RACK_ENV=production 4 | ENV RAILS_ENV=production 5 | ENV PORT=8080 6 | ENV BUNDLER_VERSION=2.6.2 7 | 8 | EXPOSE 8080 9 | 10 | SHELL ["/bin/bash", "-c"] 11 | 12 | WORKDIR /app 13 | ADD Gemfile Gemfile.lock /app/ 14 | RUN gem install bundler:$BUNDLER_VERSION && bundle install 15 | 16 | RUN apt-get update \ 17 | && apt-get install -y --no-install-recommends \ 18 | supervisor locales nodejs vim nano \ 19 | && apt-get clean \ 20 | && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 21 | 22 | RUN DEBIAN_FRONTEND=noninteractive dpkg-reconfigure locales \ 23 | && locale-gen C.UTF-8 \ 24 | && /usr/sbin/update-locale LANG=C.UTF-8 25 | 26 | ENV LC_ALL=C.UTF-8 27 | 28 | ARG TARGETARCH 29 | ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.1.3/supercronic-linux-$TARGETARCH \ 30 | SUPERCRONIC=supercronic-linux-$TARGETARCH \ 31 | SUPERCRONIC_amd64_SHA1SUM=96960ba3207756bb01e6892c978264e5362e117e \ 32 | SUPERCRONIC_arm_SHA1SUM=8c1e7af256ee35a9fcaf19c6a22aa59a8ccc03ef \ 33 | SUPERCRONIC_arm64_SHA1SUM=f0e8049f3aa8e24ec43e76955a81b76e90c02270 \ 34 | SUPERCRONIC_SHA1SUM="SUPERCRONIC_${TARGETARCH}_SHA1SUM" 35 | 36 | RUN curl -fsSLO "$SUPERCRONIC_URL" \ 37 | && echo "${!SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \ 38 | && chmod +x "$SUPERCRONIC" \ 39 | && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \ 40 | && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic 41 | 42 | ADD docker/supervisord.conf /etc/supervisord.conf 43 | ADD docker/start.sh /app/ 44 | ADD . /app 45 | 46 | RUN useradd -m stringer 47 | RUN chown -R stringer:stringer /app 48 | USER stringer 49 | 50 | ENV RAILS_SERVE_STATIC_FILES=true 51 | 52 | CMD /app/start.sh 53 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ruby_version_file = File.expand_path(".ruby-version", __dir__) 4 | ruby File.read(ruby_version_file).chomp if File.readable?(ruby_version_file) 5 | source "https://rubygems.org" 6 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 7 | 8 | gem "dotenv-rails" 9 | 10 | gem "rails", "~> 8.0.0" 11 | 12 | gem "bcrypt" 13 | gem "bootsnap", require: false 14 | gem "feedbag" 15 | gem "feedjira" 16 | gem "good_job", "~> 4.10.0" 17 | gem "httparty" 18 | gem "nokogiri", "~> 1.18.0" 19 | gem "pg" 20 | gem "puma", "~> 6.4" 21 | gem "rack-ssl" 22 | gem "sass" 23 | gem "sprockets" 24 | gem "sprockets-rails" 25 | gem "stripe" 26 | gem "thread" 27 | gem "uglifier" 28 | gem "will_paginate" 29 | 30 | group :development do 31 | gem "rubocop", require: false 32 | gem "rubocop-capybara", require: false 33 | gem "rubocop-factory_bot", require: false 34 | gem "rubocop-rails", require: false 35 | gem "rubocop-rake", require: false 36 | gem "rubocop-rspec", require: false 37 | gem "rubocop-rspec_rails", require: false 38 | gem "web-console" 39 | end 40 | 41 | group :development, :test do 42 | gem "capybara" 43 | gem "coveralls_reborn", require: false 44 | gem "debug" 45 | gem "factory_bot" 46 | gem "pry-byebug" 47 | gem "rspec" 48 | gem "rspec-rails" 49 | gem "simplecov" 50 | gem "webmock", require: false 51 | end 52 | 53 | group :test do 54 | gem "axe-core-rspec" 55 | gem "selenium-webdriver" 56 | gem "webdrivers" 57 | gem "with_model" 58 | end 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Matt Swanson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -p $PORT -C ./config/puma.rb 2 | release: bundle exec rails db:migrate 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "config/application" 4 | 5 | Rails.application.load_tasks 6 | 7 | desc "Fetch all feeds." 8 | task fetch_feeds: :environment do 9 | Feed::FetchAll.call 10 | end 11 | 12 | desc "Lazily fetch all feeds." 13 | task lazy_fetch: :environment do 14 | if ENV["APP_URL"] 15 | uri = URI(ENV["APP_URL"]) 16 | 17 | # warm up server by fetching the root path 18 | Net::HTTP.get_response(uri) 19 | end 20 | 21 | FeedRepository.list.each do |feed| 22 | CallableJob.perform_later(Feed::FetchOne, feed) 23 | end 24 | end 25 | 26 | desc "Fetch single feed" 27 | task :fetch_feed, [:id] => :environment do |_t, args| 28 | Feed::FetchOne.call(Feed.find(args[:id])) 29 | end 30 | 31 | desc "Clean up old stories that are read and unstarred" 32 | task :cleanup_old_stories, [:number_of_days] => :environment do |_t, args| 33 | args.with_defaults(number_of_days: 30) 34 | RemoveOldStories.call(args[:number_of_days].to_i) 35 | end 36 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Stringer", 3 | "description": "A self-hosted, anti-social RSS reader.", 4 | "logo": "https://raw.githubusercontent.com/stringer-rss/stringer/main/screenshots/logo.png", 5 | "keywords": [ 6 | "RSS", 7 | "Ruby" 8 | ], 9 | "website": "https://github.com/stringer-rss/stringer", 10 | "success_url": "/heroku", 11 | "scripts": { 12 | "postdeploy": "bundle exec rake db:migrate" 13 | }, 14 | "env": { 15 | "SECRET_KEY_BASE": { 16 | "description": "Secret key used by rails for encryption", 17 | "generator": "secret" 18 | }, 19 | "ENCRYPTION_PRIMARY_KEY": { 20 | "description": "Secret key used by rails for encryption", 21 | "generator": "secret" 22 | }, 23 | "ENCRYPTION_DETERMINISTIC_KEY": { 24 | "description": "Secret key used by rails for encryption", 25 | "generator": "secret" 26 | }, 27 | "ENCRYPTION_KEY_DERIVATION_SALT": { 28 | "description": "Secret key used by rails for encryption", 29 | "generator": "secret" 30 | }, 31 | "LOCALE": { 32 | "description": "Specify the translation locale you wish to use", 33 | "value": "en" 34 | }, 35 | "ENFORCE_SSL": { 36 | "description": "Force all clients to connect over SSL", 37 | "value": "true" 38 | }, 39 | "WORKER_EMBEDDED": { 40 | "description": "Force worker threads to be spawned by main process", 41 | "value": "true" 42 | }, 43 | "WORKER_RETRY": { 44 | "description": "Number of times to respawn the worker thread if it fails", 45 | "value": "3" 46 | } 47 | }, 48 | "addons": [ 49 | "heroku-postgresql:hobby-dev", 50 | "scheduler:standard" 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link_directory ../javascripts .js 4 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/stylesheets/reenie-beanie-font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Reenie Beanie'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: local('Reenie Beanie'), local('ReenieBeanie'); 6 | src: url('/fonts/reenie-beanie/ReenieBeanie.eot?#iefix') format('embedded-opentype'), 7 | url('/fonts/reenie-beanie/ReenieBeanie.woff2') format('woff2'), 8 | url('/fonts/reenie-beanie/ReenieBeanie.woff') format('woff'), 9 | url('/fonts/reenie-beanie/ReenieBeanie.ttf') format('truetype'), 10 | url('/fonts/reenie-beanie/ReenieBeanie.svg#ReenieBeanie') format('svg'); 11 | } 12 | -------------------------------------------------------------------------------- /app/commands/cast_boolean.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module CastBoolean 4 | TRUE_VALUES = Set.new(["true", true, "1"]).freeze 5 | FALSE_VALUES = Set.new(["false", false, "0"]).freeze 6 | 7 | def self.call(boolean) 8 | if (TRUE_VALUES + FALSE_VALUES).exclude?(boolean) 9 | raise(ArgumentError, "cannot cast to boolean: #{boolean.inspect}") 10 | end 11 | 12 | TRUE_VALUES.include?(boolean) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/commands/feed/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Feed::Create 4 | def self.call(url, user:) 5 | result = FeedDiscovery.call(url) 6 | return false unless result 7 | 8 | name = ContentSanitizer.call(result.title.presence || result.feed_url) 9 | 10 | Feed.create(name:, user:, url: result.feed_url, last_fetched: 1.day.ago) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/commands/feed/export_to_opml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Feed::ExportToOpml 4 | class << self 5 | def call(feeds) 6 | builder = 7 | Nokogiri::XML::Builder.new do |xml| 8 | xml.opml(version: "1.0") do 9 | xml.head { xml.title("Feeds from Stringer") } 10 | xml.body { feeds.each { |feed| feed_outline(xml, feed) } } 11 | end 12 | end 13 | 14 | builder.to_xml 15 | end 16 | 17 | private 18 | 19 | def feed_outline(xml, feed) 20 | xml.outline( 21 | text: feed.name, 22 | title: feed.name, 23 | type: "rss", 24 | xmlUrl: feed.url 25 | ) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/commands/feed/fetch_all.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "thread/pool" 4 | 5 | module Feed::FetchAll 6 | def self.call 7 | pool = Thread.pool(10) 8 | 9 | Feed.find_each { |feed| pool.process { Feed::FetchOne.call(feed) } } 10 | 11 | pool.shutdown 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/commands/feed/fetch_all_for_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "thread/pool" 4 | 5 | module Feed::FetchAllForUser 6 | def self.call(user) 7 | pool = Thread.pool(10) 8 | 9 | user.feeds.find_each { |feed| pool.process { Feed::FetchOne.call(feed) } } 10 | 11 | pool.shutdown 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/commands/feed/fetch_one.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Feed::FetchOne 4 | class << self 5 | def call(feed) 6 | raw_feed = fetch_raw_feed(feed) 7 | 8 | new_entries_from(feed, raw_feed).each do |entry| 9 | StoryRepository.add(entry, feed) 10 | end 11 | 12 | FeedRepository.update_last_fetched(feed, raw_feed.last_modified) 13 | 14 | FeedRepository.set_status(:green, feed) 15 | rescue StandardError => e 16 | FeedRepository.set_status(:red, feed) 17 | 18 | Rails.logger.error("Something went wrong when parsing #{feed.url}: #{e}") 19 | end 20 | 21 | private 22 | 23 | def fetch_raw_feed(feed) 24 | response = HTTParty.get(feed.url).to_s 25 | Feedjira.parse(response) 26 | end 27 | 28 | def new_entries_from(feed, raw_feed) 29 | Feed::FindNewStories.call(raw_feed, feed.id, latest_entry_id(feed)) 30 | end 31 | 32 | def latest_entry_id(feed) 33 | feed.stories.first.entry_id unless feed.stories.empty? 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/commands/feed/find_new_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Feed::FindNewStories 4 | STORY_AGE_THRESHOLD_DAYS = 3 5 | 6 | def self.call(raw_feed, feed_id, latest_entry_id = nil) 7 | stories = [] 8 | 9 | raw_feed.entries.each do |story| 10 | break if latest_entry_id && story.id == latest_entry_id 11 | next if story_age_exceeds_threshold?(story) || StoryRepository.exists?( 12 | story.id, 13 | feed_id 14 | ) 15 | 16 | stories << story 17 | end 18 | 19 | stories 20 | end 21 | 22 | def self.story_age_exceeds_threshold?(story) 23 | max_age = Time.now - STORY_AGE_THRESHOLD_DAYS.days 24 | story.published && story.published < max_age 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/commands/feed/import_from_opml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Feed::ImportFromOpml 4 | class << self 5 | def call(opml_contents, user:) 6 | feeds_with_groups = OpmlParser.call(opml_contents) 7 | 8 | # It considers a situation when feeds are already imported without 9 | # groups, so it's possible to re-import the same subscriptions.xml just 10 | # to set group_id for existing feeds. Feeds without groups are in 11 | # 'Ungrouped' group, we don't create such group and create such feeds 12 | # with group_id = nil. 13 | feeds_with_groups.each do |group_name, parsed_feeds| 14 | group = find_or_create_group(group_name, user) 15 | 16 | parsed_feeds.each do |parsed_feed| 17 | create_feed(parsed_feed, group, user) 18 | end 19 | end 20 | end 21 | 22 | private 23 | 24 | def find_or_create_group(group_name, user) 25 | return if group_name == "Ungrouped" 26 | 27 | user.groups.create_or_find_by(name: group_name) 28 | end 29 | 30 | def create_feed(parsed_feed, group, user) 31 | feed = user.feeds.where(**parsed_feed.slice(:name, :url)) 32 | .first_or_initialize 33 | find_feed_name(feed, parsed_feed) 34 | feed.last_fetched = 1.day.ago if feed.new_record? 35 | feed.group_id = group.id if group 36 | feed.save 37 | end 38 | 39 | def find_feed_name(feed, parsed_feed) 40 | return if feed.name? 41 | 42 | result = FeedDiscovery.call(parsed_feed[:url]) 43 | title = result.title if result 44 | feed.name = ContentSanitizer.call(title.presence || parsed_feed[:url]) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /app/commands/fever_api.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI 4 | API_VERSION = 3 5 | 6 | PARAMS = [ 7 | :as, 8 | :before, 9 | :favicons, 10 | :feeds, 11 | :groups, 12 | :id, 13 | :items, 14 | :links, 15 | :mark, 16 | :saved_item_ids, 17 | :since_id, 18 | :unread_item_ids, 19 | :with_ids 20 | ].freeze 21 | end 22 | -------------------------------------------------------------------------------- /app/commands/fever_api/authentication.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::Authentication 4 | def self.call(authorization:, **_params) 5 | feeds = authorization.scope(Feed) 6 | last_refreshed_on_time = (feeds.maximum(:last_fetched) || 0).to_i 7 | 8 | { auth: 1, last_refreshed_on_time: } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/commands/fever_api/read_favicons.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FeverAPI::ReadFavicons 4 | ICON = "R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" 5 | 6 | class << self 7 | def call(params) 8 | if params.key?(:favicons) 9 | { favicons: } 10 | else 11 | {} 12 | end 13 | end 14 | 15 | private 16 | 17 | def favicons 18 | [ 19 | { 20 | id: 0, 21 | data: "image/gif;base64,#{ICON}" 22 | } 23 | ] 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/commands/fever_api/read_feeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::ReadFeeds 4 | class << self 5 | def call(authorization:, **params) 6 | if params.key?(:feeds) 7 | { feeds: feeds(authorization) } 8 | else 9 | {} 10 | end 11 | end 12 | 13 | private 14 | 15 | def feeds(authorization) 16 | authorization.scope(FeedRepository.list).map(&:as_fever_json) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/commands/fever_api/read_feeds_groups.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FeverAPI::ReadFeedsGroups 4 | class << self 5 | def call(authorization:, **params) 6 | if params.key?(:feeds) || params.key?(:groups) 7 | { feeds_groups: feeds_groups(authorization) } 8 | else 9 | {} 10 | end 11 | end 12 | 13 | private 14 | 15 | def feeds_groups(authorization) 16 | scoped_feeds = authorization.scope(FeedRepository.list) 17 | grouped_feeds = scoped_feeds.order("LOWER(name)").group_by(&:group_id) 18 | grouped_feeds.map do |group_id, feeds| 19 | group_id ||= Group::UNGROUPED.id 20 | 21 | { 22 | group_id:, 23 | feed_ids: feeds.map(&:id).join(",") 24 | } 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/commands/fever_api/read_groups.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::ReadGroups 4 | class << self 5 | def call(authorization:, **params) 6 | if params.key?(:groups) 7 | { groups: groups(authorization) } 8 | else 9 | {} 10 | end 11 | end 12 | 13 | private 14 | 15 | def groups(authorization) 16 | [Group::UNGROUPED, *authorization.scope(GroupRepository.list)] 17 | .map(&:as_fever_json) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/commands/fever_api/read_items.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::ReadItems 4 | class << self 5 | def call(authorization:, **params) 6 | return {} unless params.key?(:items) 7 | 8 | item_ids = params[:with_ids].split(",") if params.key?(:with_ids) 9 | 10 | { 11 | items: items(item_ids, params[:since_id], authorization), 12 | total_items: total_items(item_ids, authorization) 13 | } 14 | end 15 | 16 | private 17 | 18 | def items(item_ids, since_id, authorization) 19 | items = 20 | if item_ids 21 | stories_by_ids(item_ids, authorization) 22 | else 23 | unread_stories(since_id, authorization) 24 | end 25 | items.order(:published, :id).map(&:as_fever_json) 26 | end 27 | 28 | def total_items(item_ids, authorization) 29 | items = 30 | if item_ids 31 | stories_by_ids(item_ids, authorization) 32 | else 33 | unread_stories(nil, authorization) 34 | end 35 | items.count 36 | end 37 | 38 | def stories_by_ids(ids, authorization) 39 | authorization.scope(StoryRepository.fetch_by_ids(ids)) 40 | end 41 | 42 | def unread_stories(since_id, authorization) 43 | if since_id.present? 44 | authorization.scope(StoryRepository.unread_since_id(since_id)) 45 | else 46 | authorization.scope(StoryRepository.unread) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/commands/fever_api/read_links.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::ReadLinks 4 | def self.call(params) 5 | if params.key?(:links) 6 | { links: [] } 7 | else 8 | {} 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/commands/fever_api/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::Response 4 | ACTIONS = [ 5 | FeverAPI::Authentication, 6 | FeverAPI::ReadFeeds, 7 | FeverAPI::ReadGroups, 8 | FeverAPI::ReadFeedsGroups, 9 | FeverAPI::ReadFavicons, 10 | FeverAPI::ReadItems, 11 | FeverAPI::ReadLinks, 12 | FeverAPI::SyncUnreadItemIds, 13 | FeverAPI::SyncSavedItemIds, 14 | FeverAPI::WriteMarkItem, 15 | FeverAPI::WriteMarkFeed, 16 | FeverAPI::WriteMarkGroup 17 | ].freeze 18 | 19 | def self.call(params) 20 | result = { api_version: FeverAPI::API_VERSION } 21 | ACTIONS.each { |action| result.merge!(action.call(**params)) } 22 | result.to_json 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/commands/fever_api/sync_saved_item_ids.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::SyncSavedItemIds 4 | class << self 5 | def call(authorization:, **params) 6 | if params.key?(:saved_item_ids) 7 | { saved_item_ids: saved_item_ids(authorization) } 8 | else 9 | {} 10 | end 11 | end 12 | 13 | private 14 | 15 | def saved_item_ids(authorization) 16 | all_starred_stories(authorization).map(&:id).join(",") 17 | end 18 | 19 | def all_starred_stories(authorization) 20 | authorization.scope(StoryRepository.all_starred) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/commands/fever_api/sync_unread_item_ids.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::SyncUnreadItemIds 4 | class << self 5 | def call(authorization:, **params) 6 | if params.key?(:unread_item_ids) 7 | { unread_item_ids: unread_item_ids(authorization) } 8 | else 9 | {} 10 | end 11 | end 12 | 13 | private 14 | 15 | def unread_item_ids(authorization) 16 | authorization.scope(unread_stories).map(&:id).join(",") 17 | end 18 | 19 | def unread_stories 20 | StoryRepository.unread 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/commands/fever_api/write_mark_feed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::WriteMarkFeed 4 | def self.call(authorization:, **params) 5 | if params[:mark] == "feed" 6 | authorization.check(Feed.find(params[:id])) 7 | MarkFeedAsRead.call(params[:id], params[:before]) 8 | end 9 | 10 | {} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/commands/fever_api/write_mark_group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::WriteMarkGroup 4 | def self.call(authorization:, **params) 5 | if params[:mark] == "group" 6 | authorization.check(Group.find(params[:id])) 7 | MarkGroupAsRead.call(params[:id], params[:before]) 8 | end 9 | 10 | {} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/commands/fever_api/write_mark_item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeverAPI::WriteMarkItem 4 | class << self 5 | def call(authorization:, **params) 6 | if params[:mark] == "item" 7 | authorization.check(Story.find(params[:id])) if params.key?(:id) 8 | mark_item_as(params[:id], params[:as]) 9 | end 10 | 11 | {} 12 | end 13 | 14 | private 15 | 16 | def mark_item_as(id, mark_as) 17 | case mark_as 18 | when "read" 19 | MarkAsRead.call(id) 20 | when "unread" 21 | MarkAsUnread.call(id) 22 | when "saved" 23 | MarkAsStarred.call(id) 24 | when "unsaved" 25 | MarkAsUnstarred.call(id) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/commands/story/mark_all_as_read.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MarkAllAsRead 4 | def self.call(story_ids) 5 | StoryRepository.fetch_by_ids(story_ids).update_all(is_read: true) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/commands/story/mark_as_read.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MarkAsRead 4 | def self.call(story_id) 5 | StoryRepository.fetch(story_id).update!(is_read: true) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/commands/story/mark_as_starred.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MarkAsStarred 4 | def self.call(story_id) 5 | StoryRepository.fetch(story_id).update!(is_starred: true) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/commands/story/mark_as_unread.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MarkAsUnread 4 | def self.call(story_id) 5 | StoryRepository.fetch(story_id).update!(is_read: false) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/commands/story/mark_as_unstarred.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MarkAsUnstarred 4 | def self.call(story_id) 5 | StoryRepository.fetch(story_id).update!(is_starred: false) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/commands/story/mark_feed_as_read.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MarkFeedAsRead 4 | def self.call(feed_id, timestamp) 5 | StoryRepository 6 | .fetch_unread_for_feed_by_timestamp(feed_id, timestamp) 7 | .update_all(is_read: true) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/commands/story/mark_group_as_read.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MarkGroupAsRead 4 | KINDLING_GROUP_ID = 0 5 | SPARKS_GROUP_ID = -1 6 | 7 | def self.call(group_id, timestamp) 8 | return unless group_id 9 | 10 | if [KINDLING_GROUP_ID, SPARKS_GROUP_ID].include?(group_id.to_i) 11 | StoryRepository 12 | .fetch_unread_by_timestamp(timestamp).update_all(is_read: true) 13 | else 14 | StoryRepository 15 | .fetch_unread_by_timestamp_and_group(timestamp, group_id) 16 | .update_all(is_read: true) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/commands/user/sign_in_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SignInUser 4 | def self.call(username, submitted_password) 5 | user = User.find_by(username:) 6 | return unless user 7 | 8 | user_password = BCrypt::Password.new(user.password_digest) 9 | 10 | user if user_password == submitted_password 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | before_action :complete_setup 5 | before_action :authenticate_user 6 | after_action -> { authorization.verify } 7 | 8 | private 9 | 10 | def authorization 11 | @authorization ||= Authorization.new(current_user) 12 | end 13 | 14 | def complete_setup 15 | redirect_to("/setup/password") unless UserRepository.setup_complete? 16 | end 17 | 18 | def authenticate_user 19 | return if current_user 20 | 21 | session[:redirect_to] = request.fullpath 22 | redirect_to("/login") 23 | end 24 | 25 | def current_user 26 | @current_user ||= UserRepository.fetch(session[:user_id]) 27 | end 28 | helper_method :current_user 29 | end 30 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/debug_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DebugController < ApplicationController 4 | skip_before_action :complete_setup, only: [:heroku] 5 | skip_before_action :authenticate_user, only: [:heroku] 6 | 7 | def index 8 | authorization.skip 9 | render( 10 | locals: { 11 | queued_jobs_count: GoodJob::Job.queued.count, 12 | pending_migrations: MigrationStatus.call 13 | } 14 | ) 15 | end 16 | 17 | def heroku 18 | authorization.skip 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/controllers/exports_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ExportsController < ApplicationController 4 | def index 5 | xml = Feed::ExportToOpml.call(authorization.scope(Feed.all)) 6 | 7 | send_data( 8 | xml, 9 | type: "application/xml", 10 | disposition: "attachment", 11 | filename: "stringer.opml" 12 | ) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/feeds_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FeedsController < ApplicationController 4 | def index 5 | @feeds = authorization.scope(FeedRepository.list.with_unread_stories_counts) 6 | end 7 | 8 | def show 9 | @feed = FeedRepository.fetch(params[:feed_id]) 10 | authorization.check(@feed) 11 | 12 | @stories = StoryRepository.feed(params[:feed_id]) 13 | @unread_stories = @stories.reject(&:is_read) 14 | end 15 | 16 | def new 17 | authorization.skip 18 | @feed_url = params[:feed_url] 19 | end 20 | 21 | def edit 22 | @feed = FeedRepository.fetch(params[:id]) 23 | authorization.check(@feed) 24 | end 25 | 26 | def create 27 | authorization.skip 28 | @feed_url = params[:feed_url] 29 | feed = Feed::Create.call(@feed_url, user: current_user) 30 | 31 | if feed && feed.valid? 32 | CallableJob.perform_later(Feed::FetchOne, feed) 33 | 34 | redirect_to("/", flash: { success: t(".success") }) 35 | else 36 | flash.now[:error] = feed ? t(".already_subscribed") : t(".feed_not_found") 37 | 38 | render(:new) 39 | end 40 | end 41 | 42 | def update 43 | feed = FeedRepository.fetch(params[:id]) 44 | authorization.check(feed) 45 | 46 | FeedRepository.update_feed( 47 | feed, 48 | params[:feed_name], 49 | params[:feed_url], 50 | params[:group_id] 51 | ) 52 | 53 | flash[:success] = t("feeds.edit.flash.updated_successfully") 54 | redirect_to("/feeds") 55 | end 56 | 57 | def destroy 58 | authorization.check(Feed.find(params[:id])) 59 | FeedRepository.delete(params[:id]) 60 | 61 | flash[:success] = t(".success") 62 | redirect_to("/feeds") 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /app/controllers/fever_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FeverController < ApplicationController 4 | skip_before_action :complete_setup, only: [:index, :update] 5 | protect_from_forgery with: :null_session, only: [:update] 6 | 7 | def index 8 | authorization.skip 9 | render(json: FeverAPI::Response.call(fever_params)) 10 | end 11 | 12 | def update 13 | authorization.skip 14 | render(json: FeverAPI::Response.call(fever_params)) 15 | end 16 | 17 | private 18 | 19 | def fever_params 20 | params.permit(FeverAPI::PARAMS).to_hash.symbolize_keys.merge(authorization:) 21 | end 22 | 23 | def authenticate_user 24 | return if current_user 25 | 26 | render(json: { api_version: FeverAPI::API_VERSION, auth: 0 }) 27 | end 28 | 29 | def current_user 30 | @current_user ||= User.find_by(api_key: params[:api_key]) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/controllers/imports_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ImportsController < ApplicationController 4 | def new 5 | authorization.skip 6 | end 7 | 8 | def create 9 | authorization.skip 10 | Feed::ImportFromOpml.call(params["opml_file"].read, user: current_user) 11 | 12 | redirect_to("/setup/tutorial") 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/passwords_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PasswordsController < ApplicationController 4 | skip_before_action :complete_setup, only: [:new, :create] 5 | skip_before_action :authenticate_user, only: [:new, :create] 6 | before_action :check_signups_enabled, only: [:new, :create] 7 | 8 | def new 9 | authorization.skip 10 | end 11 | 12 | def create 13 | authorization.skip 14 | 15 | user = User.new(user_params) 16 | if user.save 17 | session[:user_id] = user.id 18 | 19 | redirect_to("/feeds/import") 20 | else 21 | flash.now[:error] = user.error_messages 22 | render(:new) 23 | end 24 | end 25 | 26 | def update 27 | authorization.skip 28 | 29 | if current_user.update(password_params) 30 | redirect_to("/news", flash: { success: t(".success") }) 31 | else 32 | flash.now[:error] = t(".failure", errors: current_user.error_messages) 33 | render("profiles/edit", locals: { user: current_user }) 34 | end 35 | end 36 | 37 | private 38 | 39 | def check_signups_enabled 40 | redirect_to(login_path) unless Setting::UserSignup.enabled? 41 | end 42 | 43 | def password_params 44 | params 45 | .expect(user: [ 46 | :password_challenge, 47 | :password, 48 | :password_confirmation 49 | ]) 50 | end 51 | 52 | def user_params 53 | params 54 | .expect(user: [:username, :password, :password_confirmation]) 55 | .merge(admin: User.none?) 56 | .to_h.symbolize_keys 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /app/controllers/profiles_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ProfilesController < ApplicationController 4 | def edit 5 | authorization.skip 6 | 7 | render(locals: { user: current_user }) 8 | end 9 | 10 | def update 11 | authorization.skip 12 | 13 | if current_user.update(user_params) 14 | redirect_to(news_path, flash: { success: t(".success") }) 15 | else 16 | errors = current_user.error_messages 17 | flash.now[:error] = t(".failure", errors:) 18 | render(:edit, locals: { user: current_user }) 19 | end 20 | end 21 | 22 | private 23 | 24 | def user_params 25 | params.expect(user: [:username, :password_challenge, :stories_order]) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SessionsController < ApplicationController 4 | skip_before_action :authenticate_user, only: [:new, :create] 5 | 6 | def new 7 | authorization.skip 8 | end 9 | 10 | def create 11 | authorization.skip 12 | user = SignInUser.call(params[:username], params[:password]) 13 | if user 14 | session[:user_id] = user.id 15 | 16 | redirect_uri = session.delete(:redirect_to) || "/" 17 | redirect_to(redirect_uri) 18 | else 19 | flash.now[:error] = t("sessions.new.flash.wrong_password") 20 | render(:new) 21 | end 22 | end 23 | 24 | def destroy 25 | authorization.skip 26 | flash[:success] = t("sessions.destroy.flash.logged_out_successfully") 27 | session[:user_id] = nil 28 | 29 | redirect_to("/") 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/controllers/settings_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class SettingsController < ApplicationController 4 | def index 5 | authorization.skip 6 | end 7 | 8 | def update 9 | authorization.skip 10 | 11 | setting = Setting.find(params[:id]) 12 | setting.update!(setting_params) 13 | 14 | redirect_to(settings_path) 15 | end 16 | 17 | private 18 | 19 | def setting_params 20 | params.expect(setting: [:enabled]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/stories_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class StoriesController < ApplicationController 4 | def index 5 | order = current_user.stories_order 6 | @unread_stories = authorization.scope(StoryRepository.unread(order:)) 7 | end 8 | 9 | def update 10 | json_params = JSON.parse(request.body.read, symbolize_names: true) 11 | 12 | story = authorization.check(StoryRepository.fetch(params[:id])) 13 | story.update!(json_params.slice(:is_read, :is_starred, :keep_unread)) 14 | 15 | head(:no_content) 16 | end 17 | 18 | def mark_all_as_read 19 | stories = authorization.scope(Story.where(id: params[:story_ids])) 20 | MarkAllAsRead.call(stories.ids) 21 | 22 | redirect_to("/news") 23 | end 24 | 25 | def archived 26 | @read_stories = authorization.scope(StoryRepository.read(params[:page])) 27 | end 28 | 29 | def starred 30 | @starred_stories = 31 | authorization.scope(StoryRepository.starred(params[:page])) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /app/controllers/tutorials_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TutorialsController < ApplicationController 4 | def index 5 | authorization.skip 6 | CallableJob.perform_later(Feed::FetchAllForUser, current_user) 7 | 8 | @sample_stories = StoryRepository.samples 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/helpers/url_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module UrlHelpers 4 | def expand_absolute_urls(content, base_url) 5 | doc = Nokogiri::HTML.fragment(content) 6 | 7 | [["a", "href"], ["img", "src"], ["video", "src"]].each do |tag, attr| 8 | doc.css("#{tag}[#{attr}]").each do |node| 9 | url = node.get_attribute(attr) 10 | next if url =~ URI::RFC2396_PARSER.regexp[:ABS_URI] 11 | 12 | node.set_attribute(attr, URI.join(base_url, url).to_s) 13 | rescue URI::InvalidURIError 14 | # Just ignore. If we cannot parse the url, we don't want the entire 15 | # import to blow up. 16 | end 17 | end 18 | 19 | doc.to_html 20 | end 21 | 22 | def normalize_url(url, base_url) 23 | uri = URI.parse(url.strip) 24 | 25 | # resolve (protocol) relative URIs 26 | if uri.relative? 27 | base_uri = URI.parse(base_url.strip) 28 | scheme = base_uri.scheme || "http" 29 | uri = URI.join("#{scheme}://#{base_uri.host}", uri) 30 | end 31 | 32 | uri.to_s 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationJob < ActiveJob::Base 4 | end 5 | -------------------------------------------------------------------------------- /app/jobs/callable_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CallableJob < ApplicationJob 4 | def perform(callable, *, **) 5 | callable.call(*, **) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | primary_abstract_class 5 | 6 | def self.boolean_accessor(attribute, key, default: false) 7 | store_accessor(attribute, key) 8 | 9 | define_method(key) do 10 | value = super() 11 | value.nil? ? default : CastBoolean.call(value) 12 | end 13 | alias_method(:"#{key}?", :"#{key}") 14 | 15 | define_method(:"#{key}=") do |value| 16 | super(value.nil? ? default : CastBoolean.call(value)) 17 | end 18 | end 19 | 20 | def error_messages 21 | errors.full_messages.join(", ") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/feed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Feed < ApplicationRecord 4 | has_many :stories, -> { order("published desc") }, dependent: :delete_all 5 | has_many :unread_stories, -> { unread }, class_name: "Story" 6 | belongs_to :group 7 | belongs_to :user 8 | 9 | delegate :name, to: :group, prefix: true, allow_nil: true 10 | 11 | validates :url, presence: true, uniqueness: { scope: :user_id } 12 | validates :user_id, presence: true 13 | 14 | enum :status, { green: 0, yellow: 1, red: 2 } 15 | 16 | scope :with_unread_stories_counts, 17 | lambda { 18 | left_joins(:unread_stories) 19 | .select("feeds.*, count(stories.id) as unread_stories_count") 20 | .group("feeds.id") 21 | } 22 | 23 | def status_bubble 24 | return "yellow" if status == "red" && stories.any? 25 | 26 | status 27 | end 28 | 29 | def as_fever_json 30 | { 31 | id:, 32 | favicon_id: 0, 33 | title: name, 34 | url:, 35 | site_url: url, 36 | is_spark: 0, 37 | last_updated_on_time: last_fetched.to_i 38 | } 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/models/group.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Group < ApplicationRecord 4 | UNGROUPED = Group.new(id: 0, name: "Ungrouped") 5 | 6 | belongs_to :user 7 | has_many :feeds 8 | 9 | validates :name, presence: true, uniqueness: { scope: :user_id } 10 | validates :user_id, presence: true 11 | 12 | def as_fever_json 13 | { id:, title: name } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/migration_status.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MigrationStatus 4 | def self.call 5 | migrator = ActiveRecord::Base.connection.pool.migration_context.open 6 | 7 | migrator.pending_migrations.map do |migration| 8 | "#{migration.name} - #{migration.version}" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/models/setting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Setting < ApplicationRecord 4 | validates :type, presence: true, uniqueness: true 5 | end 6 | -------------------------------------------------------------------------------- /app/models/setting/user_signup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Setting::UserSignup < Setting 4 | boolean_accessor :data, :enabled, default: false 5 | 6 | validates :enabled, inclusion: { in: [true, false] } 7 | 8 | def self.first 9 | first_or_create! 10 | end 11 | 12 | def self.enabled? 13 | first_or_create!.enabled? || User.none? 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/models/story.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Story < ApplicationRecord 4 | belongs_to :feed 5 | has_one :user, through: :feed 6 | 7 | validates_uniqueness_of :entry_id, scope: :feed_id 8 | 9 | delegate :group_id, :user_id, to: :feed 10 | 11 | scope :unread, -> { where(is_read: false) } 12 | 13 | UNTITLED = "[untitled]" 14 | 15 | def headline 16 | title.nil? ? UNTITLED : strip_html(title)[0, 50] 17 | end 18 | 19 | def lead 20 | strip_html(body)[0, 100] 21 | end 22 | 23 | def source 24 | feed.name 25 | end 26 | 27 | def pretty_date 28 | I18n.l(published) 29 | end 30 | 31 | def as_json(_options = {}) 32 | super(methods: [:headline, :lead, :source, :pretty_date]) 33 | end 34 | 35 | def as_fever_json 36 | { 37 | id:, 38 | feed_id:, 39 | title:, 40 | author: source, 41 | html: body, 42 | url: permalink, 43 | is_saved: is_starred ? 1 : 0, 44 | is_read: is_read ? 1 : 0, 45 | created_on_time: published.to_i 46 | } 47 | end 48 | 49 | private 50 | 51 | def strip_html(contents) 52 | Loofah.fragment(contents).text 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/models/subscription.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Subscription < ApplicationRecord 4 | belongs_to :user 5 | 6 | STATUSES = ["active", "past_due", "unpaid", "canceled"].freeze 7 | 8 | validates :user_id, presence: true, uniqueness: true 9 | validates :stripe_customer_id, presence: true, uniqueness: true 10 | validates :stripe_subscription_id, presence: true, uniqueness: true 11 | validates :status, presence: true, inclusion: { in: STATUSES } 12 | validates :current_period_start, presence: true 13 | validates :current_period_end, presence: true 14 | end 15 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ApplicationRecord 4 | has_secure_password 5 | 6 | encrypts :api_key, deterministic: true 7 | 8 | has_many :feeds, dependent: :delete_all 9 | has_many :groups, dependent: :delete_all 10 | 11 | validates :username, presence: true, uniqueness: { case_sensitive: false } 12 | validate :password_challenge_matches 13 | 14 | before_save :update_api_key 15 | 16 | enum :stories_order, { desc: "desc", asc: "asc" }, prefix: true 17 | 18 | attr_accessor :password_challenge 19 | 20 | # `password_challenge` logic should be able to be removed in Rails 7.1 21 | # https://blog.appsignal.com/2023/02/15/whats-new-in-rails-7-1.html#password-challenge-via-has_secure_password 22 | def password_challenge_matches 23 | return unless password_challenge 24 | 25 | digested_password = BCrypt::Password.new(password_digest_was) 26 | return if digested_password.is_password?(password_challenge) 27 | 28 | errors.add(:original_password, "does not match") 29 | end 30 | 31 | def update_api_key 32 | return unless password_digest_changed? || username_changed? 33 | 34 | if password_challenge.blank? && password.blank? 35 | message = "Cannot change username without providing a password" 36 | 37 | raise(ActiveRecord::ActiveRecordError, message) 38 | end 39 | 40 | password = password_challenge.presence || self.password.presence 41 | 42 | # API key based on Fever spec: https://feedafever.com/api 43 | self.api_key = Digest::MD5.hexdigest("#{username}:#{password}") 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /app/repositories/feed_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FeedRepository 4 | MIN_YEAR = 1970 5 | 6 | def self.fetch(id) 7 | Feed.find(id) 8 | end 9 | 10 | def self.fetch_by_ids(ids) 11 | Feed.where(id: ids) 12 | end 13 | 14 | def self.update_feed(feed, name, url, group_id = nil) 15 | feed.name = name 16 | feed.url = url 17 | feed.group_id = group_id 18 | feed.save 19 | end 20 | 21 | def self.update_last_fetched(feed, timestamp) 22 | return unless valid_timestamp?(timestamp, feed.last_fetched) 23 | 24 | feed.last_fetched = timestamp 25 | feed.save 26 | end 27 | 28 | def self.delete(feed_id) 29 | Feed.destroy(feed_id) 30 | end 31 | 32 | def self.set_status(status, feed) 33 | feed.status = status 34 | feed.save 35 | end 36 | 37 | def self.list 38 | Feed.order(Feed.arel_table[:name].lower) 39 | end 40 | 41 | def self.in_group 42 | Feed.where.not(group_id: nil) 43 | end 44 | 45 | def self.valid_timestamp?(new_timestamp, current_timestamp) 46 | new_timestamp && new_timestamp.year >= MIN_YEAR && 47 | (current_timestamp.nil? || new_timestamp > current_timestamp) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /app/repositories/group_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class GroupRepository 4 | def self.list 5 | Group.order(Group.arel_table[:name].lower) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/repositories/user_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UserRepository 4 | def self.fetch(id) 5 | return nil unless id 6 | 7 | User.find(id) 8 | end 9 | 10 | def self.setup_complete? 11 | User.any? 12 | end 13 | 14 | def self.save(user) 15 | user.save 16 | user 17 | end 18 | 19 | def self.first 20 | User.first 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/tasks/remove_old_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RemoveOldStories 4 | class << self 5 | def call(number_of_days) 6 | stories = old_stories(number_of_days) 7 | feeds = pruned_feeds(stories) 8 | 9 | stories.delete_all 10 | feeds.each { |feed| FeedRepository.update_last_fetched(feed, Time.now) } 11 | end 12 | 13 | private 14 | 15 | def old_stories(number_of_days) 16 | StoryRepository.unstarred_read_stories_older_than(number_of_days) 17 | end 18 | 19 | def pruned_feeds(stories) 20 | FeedRepository.fetch_by_ids(stories.map(&:feed_id)) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/utils/authorization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Authorization 4 | attr_accessor :user, :authorized 5 | 6 | class NotAuthorizedError < StandardError; end 7 | 8 | def initialize(user) 9 | self.user = user 10 | self.authorized = false 11 | end 12 | 13 | alias authorized? authorized 14 | 15 | def check(record) 16 | raise(NotAuthorizedError) unless record.user_id == user.id 17 | 18 | self.authorized = true 19 | record 20 | end 21 | 22 | def scope(records) 23 | self.authorized = true 24 | records.joins(:user).where(users: { id: user.id }) 25 | end 26 | 27 | def skip 28 | self.authorized = true 29 | end 30 | 31 | def verify 32 | raise(NotAuthorizedError) unless authorized? 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/utils/content_sanitizer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ContentSanitizer 4 | def self.call(content) 5 | Loofah.fragment(content.gsub(//i, "")) 6 | .scrub!(:prune) 7 | .scrub!(:unprintable) 8 | .to_s 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/utils/feed_discovery.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module FeedDiscovery 4 | class << self 5 | def call(url) 6 | get_feed_for_url(url) do 7 | urls = Feedbag.find(url) 8 | return false if urls.empty? 9 | 10 | get_feed_for_url(urls.first) { return false } 11 | end 12 | end 13 | 14 | private 15 | 16 | def get_feed_for_url(url) 17 | response = HTTParty.get(url).to_s 18 | feed = Feedjira.parse(response) 19 | feed.feed_url ||= url 20 | feed 21 | rescue StandardError 22 | yield 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/utils/opml_parser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "nokogiri" 4 | 5 | module OpmlParser 6 | class << self 7 | def call(contents) 8 | feeds_with_groups = Hash.new { |h, k| h[k] = [] } 9 | 10 | outlines_in(contents).each do |outline| 11 | if outline_is_group?(outline) 12 | group_name = extract_name(outline.attributes).value 13 | feeds = outline.xpath("./outline") 14 | else # it's a top-level feed, which means it's a feed without group 15 | group_name = "Ungrouped" 16 | feeds = [outline] 17 | end 18 | 19 | feeds.each do |feed| 20 | feeds_with_groups[group_name] << feed_to_hash(feed) 21 | end 22 | end 23 | 24 | feeds_with_groups 25 | end 26 | 27 | private 28 | 29 | def outlines_in(contents) 30 | Nokogiri.XML(contents).xpath("//body/outline") 31 | end 32 | 33 | def outline_is_group?(outline) 34 | outline.attributes["xmlUrl"].nil? 35 | end 36 | 37 | def extract_name(attributes) 38 | attributes["title"] || attributes["text"] 39 | end 40 | 41 | def feed_to_hash(feed) 42 | { 43 | name: extract_name(feed.attributes)&.value, 44 | url: feed.attributes["xmlUrl"].value 45 | } 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/views/debug/heroku.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

<%= t('tutorial.heroku_one_more_thing') %>

3 | 4 |

5 | <%= t('tutorial.heroku_hourly_task') %> 6 |

7 |

8 | <%= t('tutorial.heroku_scheduler') %>: 9 |

10 |
11 |     Task: rake lazy_fetch
12 |     Dyno Size: 1X
13 |     Frequency: Hourly
14 |   
15 |
16 | 17 |
18 | <%= t('tutorial.ready') %> 19 |
-------------------------------------------------------------------------------- /app/views/debug/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

Debug Information

3 |
4 |
Ruby Version
5 |
<%= RUBY_VERSION %>
6 |
User Agent
7 |
<%= request.user_agent %>
8 |
Queued Jobs
9 |
<%= queued_jobs_count %>
10 |
Pending Migrations
11 |
12 | <% if pending_migrations.present? %> 13 |
    14 | <% pending_migrations.each do |pm| %> 15 |
  • <%= pm %>
  • 16 | <% end %> 17 |
18 | <% else %> 19 | None 20 | <% end %> 21 |
22 |
23 |
24 | -------------------------------------------------------------------------------- /app/views/feeds/_action_bar.html.erb: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /app/views/feeds/_feed.html.erb: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | 14 |
    15 |
    16 | <%= t('partials.feed.last_updated') %> 17 | 18 | <% if feed.last_fetched %> 19 | <%= I18n.l(feed.last_fetched) %> 20 | <% else %> 21 | <%= t("partials.feed.last_fetched.never") %> 22 | <% end %> 23 | 24 |
    25 | 26 | 27 | 28 | 29 | "> 30 | 31 | <%= button_to("/feeds/#{feed.id}", method: :delete, class: "remove-feed btn-link", aria: { label: "Delete" }) do %> 32 | 33 | <% end %> 34 |
    35 |
    36 |
  • 37 | -------------------------------------------------------------------------------- /app/views/feeds/edit.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= render "feeds/action_bar" %> 3 |
    4 | 5 |
    6 |

    <%= @feed.name %>

    7 |
    8 | <%= form_with(url: "/feeds/#{@feed.id}", method: :put, id: "add-feed-setup") do %> 9 | 10 |
    11 | 12 | 13 | 14 |
    15 |
    16 | 17 | 18 | 19 |
    20 | <% if current_user.groups.any? %> 21 |
    22 | 23 | 29 | 30 |
    31 | <% end %> 32 | 33 | 34 | <% end %> 35 |
    36 | -------------------------------------------------------------------------------- /app/views/feeds/index.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= render "feeds/action_bar" %> 3 |
    4 | 5 | <% unless @feeds.empty? %> 6 |
    7 | 12 |
    13 | <% else %> 14 |
    15 |

    <%= t('feeds.index.add_some_feeds', :add => ''+t('feeds.index.add')+'').html_safe %>

    16 |
    17 | <% end %> 18 | 19 | 24 | -------------------------------------------------------------------------------- /app/views/feeds/new.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= render "feeds/action_bar" %> 3 |
    4 | 5 |
    6 |

    <%= t('feeds.add.title') %>

    7 |
    8 |

    <%= t('feeds.add.description') %>

    9 |
    10 | <%= form_with(url: "/feeds", id: "add-feed-setup") do %> 11 |
    12 | 13 | 14 | 15 |
    16 | 17 | 18 | <% end %> 19 |
    20 | -------------------------------------------------------------------------------- /app/views/feeds/show.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title do %> 2 | <% unless @unread_stories.empty? %> 3 | <%= "(#{@unread_stories.count})" %> 4 | <% end %> 5 | <% end %> 6 | 7 |
    8 | <%= render "feeds/single_feed_action_bar", { stories: @unread_stories } %> 9 |
    10 | 11 |
    12 | <%= @feed.name %> 13 |
    14 | 15 | <%= render "stories/js", { stories: @stories } %> 16 | 17 |
    18 | 20 |
    21 | -------------------------------------------------------------------------------- /app/views/imports/new.html.erb: -------------------------------------------------------------------------------- 1 | 6 | 7 |
    8 |

    <%= t('import.title') %>

    9 |

    <%= t('import.subtitle') %>

    10 | 11 |
    12 |

    13 | <%= t('import.description') %> 14 |

    15 |
    16 | 17 | <%= form_with(url: "/feeds/import", id: "import", multipart: true) do %> 18 | 19 | 20 | <% end %> 21 |
    22 | 23 | 30 | -------------------------------------------------------------------------------- /app/views/js/templates/_story.js.erb: -------------------------------------------------------------------------------- 1 | 55 | -------------------------------------------------------------------------------- /app/views/layouts/_flash.html.erb: -------------------------------------------------------------------------------- 1 | <% if flash.key? :success %> 2 |
    3 | <%= flash[:success] %> 4 |
    5 | <% end %> 6 | 7 | <% if flash.key? :error %> 8 |
    9 | <%= flash[:error] %> 10 |
    11 | <% end %> 12 | 13 | 18 | 19 | 22 | 23 | 30 | -------------------------------------------------------------------------------- /app/views/layouts/_footer.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | 29 |
    30 |
    31 |

    32 | <%= t('layout.hey') %> <%= t('layout.back_to_work') %> 33 |

    34 |
    35 |
    36 |
    37 | -------------------------------------------------------------------------------- /app/views/layouts/_shortcuts.html.erb: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%= content_for(:title) %> 6 | <%= t('layout.title') %> 7 | 8 | <%= csrf_meta_tags %> 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | <%= content_for(:head) %> 20 | 21 | <%= stylesheet_link_tag 'application' %> 22 | <%= javascript_include_tag 'application' %> 23 | 24 | 25 |
    26 |
    27 | <%= render 'layouts/flash' %> 28 | <%= render 'layouts/shortcuts' if current_user %> 29 |
    30 |
    31 |
    32 | <%= yield %> 33 |
    34 |
    35 |
    36 |
    37 | 38 |
    39 |
    40 | 41 | 44 | 45 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /app/views/passwords/new.html.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    <%= t('first_run.password.title') %> <%= t('first_run.password.anti_social') %>.

    3 |

    <%= t('first_run.password.subtitle') %>

    4 |
    5 |

    <%= t('first_run.password.description') %>

    6 |
    7 | <%= form_with(model: User.new, url: "/setup/password", id: "password_setup") do |form| %> 8 |
    9 | <%= form.text_field :username, class: "form-control", required: true %> 10 | <%= form.label :username, t('first_run.password.fields.username'), class: "field-label" %> 11 |
    12 | 13 |
    14 | <%= form.password_field :password, class: "form-control", required: true %> 15 | 16 | <%= form.label :password, t('first_run.password.fields.password'), class: "field-label" %> 17 |
    18 | 19 |
    20 | <%= form.password_field :password_confirmation, class: "form-control", required: true %> 21 | 22 | <%= form.label :password_confirmation, t('first_run.password.fields.password_confirmation'), class: "field-label" %> 23 |
    24 | 25 | 26 | <% end %> 27 |
    28 | -------------------------------------------------------------------------------- /app/views/profiles/edit.html.erb: -------------------------------------------------------------------------------- 1 |

    <%= t('.title') %>

    2 | 3 | <%= form_with(model: user, url: profile_path) do |form| %> 4 |
    5 | <%= t(".stories_feed_settings") %> 6 | <%= form.label :stories_order %> 7 | <%= form.select :stories_order, User.stories_orders.transform_keys {|k| User.human_attribute_name("stories_order.#{k}") } %> 8 |
    9 | <%= form.submit("Update") %> 10 | <% end %> 11 | 12 | <%= form_with(model: user, url: profile_path) do |form| %> 13 |
    14 | <%= t('.change_username') %> 15 |
    16 |

    <%= t('.warning_html') %>

    17 |
    18 | <%= form.label :username %> 19 | <%= form.text_field :username, required: true %> 20 | <%= form.label :password_challenge, "Existing password" %> 21 | <%= form.password_field :password_challenge, required: true %> 22 |
    23 | <%= form.submit("Update username") %> 24 | <% end %> 25 | 26 | <%= form_with(model: user, url: password_path) do |form| %> 27 |
    28 | <%= t('.change_password') %> 29 | <%= form.label :password_challenge, "Existing password" %> 30 | <%= form.password_field :password_challenge, required: true %> 31 | <%= form.label :password, "New password" %> 32 | <%= form.password_field :password, required: true %> 33 | <%= form.label :password_confirmation %> 34 | <%= form.password_field :password_confirmation, required: true %> 35 |
    36 | <%= form.submit("Update password") %> 37 | <% end %> 38 | -------------------------------------------------------------------------------- /app/views/sessions/new.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    <%= t('sessions.new.title') %> <%= t('sessions.new.rss') %>.

    3 |

    <%= t('sessions.new.subtitle') %>

    4 | 5 |
    6 | 7 | <%= form_with(url: "/login") do |form| %> 8 |

    <%= t('sessions.new.fields.unknown_username_html') %>

    9 |
    10 | <%= form.text_field :username, class: "form-control", required: true %> 11 | <%= form.label :username, t('sessions.new.fields.username'), class: "field-label" %> 12 |
    13 | 14 |
    15 | 16 | 17 | 18 |
    19 | 20 | <% end %> 21 | 22 | <% if Setting::UserSignup.enabled? %> 23 |
    24 | <%= t('.sign_up_html', href: setup_password_path) %> 25 |
    26 | <% end %> 27 |
    28 | -------------------------------------------------------------------------------- /app/views/settings/index.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= render "feeds/action_bar" %> 3 |
    4 | 5 |
    6 |

    <%= t('.heading') %>

    7 |
    8 |

    <%= t('.description') %>

    9 |
    10 |
    11 | <% setting = Setting::UserSignup.first %> 12 | <%= form_with(model: setting, scope: :setting, url: setting_path(setting)) do |form| %> 13 | <% if Setting::UserSignup.enabled? %> 14 | <%= form.hidden_field :enabled, value: false %> 15 | <%= t('.signup.enabled') %> 16 | <%= form.submit(t('.signup.disable'), class: 'btn btn-primary pull-right') %> 17 | <% else %> 18 | <%= form.hidden_field :enabled, value: true %> 19 | <%= t('.signup.disabled') %> 20 | <%= form.submit(t('.signup.enable'), class: 'btn btn-primary pull-right') %> 21 | <% end %> 22 | <% end %> 23 |
    24 |
    25 | -------------------------------------------------------------------------------- /app/views/stories/_js.html.erb: -------------------------------------------------------------------------------- 1 | <%= render 'stories/templates' %> 2 | 3 | 44 | -------------------------------------------------------------------------------- /app/views/stories/_mark_all_as_read_form.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= form_with(url: "/stories/mark_all_as_read", id: "mark-all-as-read") do %> 3 | <% stories.each do |story| %> 4 | 5 | <% end %> 6 | <% end %> 7 |
    8 | -------------------------------------------------------------------------------- /app/views/stories/_templates.html.erb: -------------------------------------------------------------------------------- 1 | 55 | -------------------------------------------------------------------------------- /app/views/stories/_zen.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | – 0 – 3 |

    <%= t('partials.zen.rss_zero').html_safe %>

    4 |

    5 | 6 | <%= t('partials.zen.gtfo') %> <%= t('partials.zen.go_make') %>. 7 | 8 |

    9 |

    10 | <%= t('partials.zen.archive') %> 11 |

    12 |
    13 | -------------------------------------------------------------------------------- /app/views/stories/archived.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= render "feeds/action_bar" %> 3 |
    4 | 5 | <% unless @read_stories.empty? %> 6 | <%= render "stories/js", { stories: @read_stories } %> 7 | 8 |
    9 | 11 |
    12 | 13 | 26 | 27 | 48 | <% else %> 49 |
    50 |

    <%= t('archive.sorry') %>

    51 |
    52 | <% end %> 53 | -------------------------------------------------------------------------------- /app/views/stories/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :title do %> 2 | <% unless @unread_stories.empty? %> 3 | <%= "(#{@unread_stories.count})" %> 4 | <% end %> 5 | <% end %> 6 | 7 |
    8 | <%= render "stories/action_bar", { stories: @unread_stories } %> 9 |
    10 | 11 | <% if @unread_stories.empty? %> 12 | <%= render "stories/zen" %> 13 | <% else %> 14 | <%= render "stories/js", { stories: @unread_stories } %> 15 | 16 |
    17 | 19 |
    20 | <% end %> 21 | -------------------------------------------------------------------------------- /app/views/stories/starred.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <%= render "feeds/action_bar" %> 3 |
    4 | 5 | <% unless @starred_stories.empty? %> 6 | <%= render "stories/js", { stories: @starred_stories } %> 7 | 8 |
    9 | 11 |
    12 | 13 | 26 | 27 | 48 | 49 | <% else %> 50 |
    51 |

    <%= t('starred.sorry') %>

    52 |
    53 | <% end %> 54 | -------------------------------------------------------------------------------- /app/views/tutorials/_action_bar.html.erb: -------------------------------------------------------------------------------- 1 | 25 | -------------------------------------------------------------------------------- /app/views/tutorials/index.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :head do %> 2 | <%= stylesheet_link_tag 'reenie-beanie-font', media: 'all', 'data-turbolinks-track': 'reload' %> 3 | <% end %> 4 | 5 |
    6 |

    <%= t('tutorial.mark_all') %>
    <%= t('tutorial.as_read') %>

    7 |
    8 |
    9 |

    <%= t('tutorial.refresh') %>

    10 |
    11 |
    12 |

    <%= t('tutorial.your_feeds') %>

    13 |
    14 |
    15 |

    <%= t('tutorial.add_feed') %>

    16 |
    17 |
    18 |

    <%= t('tutorial.your_stories') %>
    <%= t('tutorial.click_to_read') %>

    19 |
    20 | 21 |
    22 | <%= render 'tutorials/action_bar', {stories: @sample_stories} %> 23 |
    24 | 25 | <%= render 'stories/js', { stories: @sample_stories } %> 26 | 27 |
    28 | 30 |
    31 | 32 |
    33 |

    <%= t('tutorial.title') %> <%= t('tutorial.simple') %>.

    34 |

    <%= t('tutorial.subtitle') %>

    35 |
    36 |

    <%= t('tutorial.description') %>

    37 |
    38 | 41 |
    42 | 43 | 53 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | exec "./bin/rails", "server", *ARGV 5 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | APP_PATH = File.expand_path("../config/application", __dir__) 5 | require_relative "../config/boot" 6 | require "rails/commands" 7 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative "../config/boot" 5 | require "rake" 6 | Rake.application.run 7 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "rubygems" 5 | require "bundler/setup" 6 | 7 | # explicit rubocop config increases performance slightly while avoiding config confusion. 8 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 9 | 10 | load Gem.bin_path("rubocop", "rubocop") 11 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "fileutils" 5 | 6 | APP_ROOT = File.expand_path("..", __dir__) 7 | 8 | def system!(*) 9 | system(*, exception: true) 10 | end 11 | 12 | FileUtils.chdir(APP_ROOT) do 13 | # This script is a way to set up or update your development environment automatically. 14 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 15 | # Add necessary setup steps to this file. 16 | 17 | puts "== Installing dependencies ==" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | if ARGV.exclude?("--skip-server") 32 | puts "\n== Starting development server ==" 33 | $stdout.flush # flush the output before exec(2) so that it displays 34 | exec "bin/dev" 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /bin/thrust: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "rubygems" 5 | require "bundler/setup" 6 | 7 | load Gem.bin_path("thruster", "thrust") 8 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative "config/environment" 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 4 | 5 | require "bundler/setup" # Set up gems listed in the Gemfile. 6 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: postgresql 3 | database: stringer_dev 4 | encoding: unicode 5 | host: localhost 6 | pool: 5 7 | 8 | test: 9 | adapter: postgresql 10 | database: stringer_test 11 | encoding: unicode 12 | host: localhost 13 | pool: 5 14 | 15 | production: 16 | url: <%= ENV["DATABASE_URL"] %> 17 | encoding: unicode 18 | pool: <%= Integer(ENV.fetch("DB_POOL", 15)) %> 19 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative "application" 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Version of your assets, change this if you want to expire all your assets. 6 | Rails.application.config.assets.version = "1.0" 7 | 8 | # Add additional assets to the asset load path. 9 | # Rails.application.config.assets.paths << Emoji.images_path 10 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Define an application-wide content security policy. 6 | # See the Securing Rails Applications Guide for more information: 7 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 8 | 9 | # Rails.application.configure do 10 | # config.content_security_policy do |policy| 11 | # policy.default_src :self, :https 12 | # policy.font_src :self, :https, :data 13 | # policy.img_src :self, :https, :data 14 | # policy.object_src :none 15 | # policy.script_src :self, :https 16 | # policy.style_src :self, :https 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | # 21 | # # Generate session nonces for permitted importmap, inline scripts, and 22 | # inline styles. 23 | # config.content_security_policy_nonce_generator = 24 | # ->(request) { request.session.id.to_s } 25 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 26 | # 27 | # # Report violations without enforcing the policy. 28 | # # config.content_security_policy_report_only = true 29 | # end 30 | -------------------------------------------------------------------------------- /config/initializers/dotenv.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dotenv.require_keys( 4 | "SECRET_KEY_BASE", 5 | "ENCRYPTION_PRIMARY_KEY", 6 | "ENCRYPTION_DETERMINISTIC_KEY", 7 | "ENCRYPTION_KEY_DERIVATION_SALT" 8 | ) 9 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure parameters to be partially matched (e.g. passw matches password) 6 | # and filtered from the log file. Use this to limit dissemination of sensitive 7 | # information. See the ActiveSupport::ParameterFilter documentation for 8 | # supported notations and behaviors. 9 | Rails.application.config.filter_parameters += [ 10 | :passw, 11 | :email, 12 | :secret, 13 | :token, 14 | :_key, 15 | :crypt, 16 | :salt, 17 | :certificate, 18 | :otp, 19 | :ssn, 20 | :cvv, 21 | :cvc 22 | ] 23 | -------------------------------------------------------------------------------- /config/initializers/good_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | GoodJob.preserve_job_records = false 4 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new inflection rules using the following format. Inflections 6 | # are locale specific, and you may define rules for as many different 7 | # locales as you wish. All of these examples are active by default: 8 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 9 | # inflect.plural /^(ox)$/i, "\\1en" 10 | # inflect.singular /^(ox)en/i, "\\1" 11 | # inflect.irregular "person", "people" 12 | # inflect.uncountable %w( fish sheep ) 13 | # end 14 | 15 | # These inflection rules are supported but not enabled by default: 16 | ActiveSupport::Inflector.inflections(:en) { |inflect| inflect.acronym("API") } 17 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Define an application-wide HTTP permissions policy. For further 6 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 7 | 8 | # Rails.application.config.permissions_policy do |policy| 9 | # policy.camera :none 10 | # policy.gyroscope :none 11 | # policy.microphone :none 12 | # policy.usb :none 13 | # policy.fullscreen :self 14 | # policy.payment :self, "https://secure.example.com" 15 | # end 16 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | session_config = { key: "_stringer_session", expire_after: 2.weeks } 6 | Rails.application.config.session_store(:cookie_store, **session_config) 7 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../lib/admin_constraint" 4 | 5 | Rails.application.routes.draw do 6 | scope :admin, constraints: AdminConstraint.new do 7 | mount GoodJob::Engine => "good_job" 8 | 9 | resources :settings, only: [:index, :update] 10 | get "/debug", to: "debug#index" 11 | end 12 | 13 | resource :profile, only: [:edit, :update] 14 | resource :password, only: [:update] 15 | 16 | get "/", to: "stories#index" 17 | get "/fever", to: "fever#index" 18 | post "/fever", to: "fever#update" 19 | get "/archive", to: "stories#archived" 20 | get "/feed/:feed_id", to: "feeds#show" 21 | post "/feeds", to: "feeds#create" 22 | get "/feeds", to: "feeds#index" 23 | delete "/feeds/:id", to: "feeds#destroy" 24 | put "/feeds/:id", to: "feeds#update" 25 | get "/feeds/:id/edit", to: "feeds#edit" 26 | get "/feeds/export", to: "exports#index" 27 | post "/feeds/import", to: "imports#create" 28 | get "/feeds/import", to: "imports#new" 29 | get "/feeds/new", to: "feeds#new" 30 | get "/heroku", to: "debug#heroku" 31 | post "/login", to: "sessions#create" 32 | get "/login", to: "sessions#new" 33 | get "/logout", to: "sessions#destroy" 34 | get "/news", to: "stories#index" 35 | post "/setup/password", to: "passwords#create" 36 | get "/setup/password", to: "passwords#new" 37 | get "/setup/tutorial", to: "tutorials#index" 38 | get "/starred", to: "stories#starred" 39 | put "/stories/:id", to: "stories#update" 40 | post "/stories/mark_all_as_read", to: "stories#mark_all_as_read" 41 | 42 | unless Rails.env.production? 43 | require_relative "../spec/javascript/test_controller" 44 | 45 | get "/test", to: "test#index" 46 | get "/spec/*splat", to: "test#spec" 47 | get "/vendor/js/*splat", to: "test#vendor" 48 | get "/vendor/css/*splat", to: "test#vendor" 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /db/migrate/20130409010818_create_feeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateFeeds < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :feeds do |t| 6 | t.string :name 7 | t.string :url 8 | t.timestamp :last_fetched 9 | 10 | t.timestamps null: false 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20130409010826_create_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateStories < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :stories do |t| 6 | t.string :title 7 | t.string :permalink 8 | t.text :body 9 | 10 | t.references :feed 11 | 12 | t.timestamps null: false 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20130412185253_add_new_fields_to_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddNewFieldsToStories < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :stories, :published, :timestamp 6 | add_column :stories, :is_read, :boolean 7 | add_column :stories, :author, :string 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20130418221144_add_user_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUserModel < ActiveRecord::Migration[4.2] 4 | def change 5 | create_table :users do |t| 6 | t.string :email 7 | t.string :password_digest 8 | 9 | t.timestamps null: false 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20130423001740_drop_email_from_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DropEmailFromUser < ActiveRecord::Migration[4.2] 4 | def up 5 | remove_column :users, :email 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130423180446_remove_author_from_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveAuthorFromStories < ActiveRecord::Migration[4.2] 4 | def up 5 | remove_column :stories, :author 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130425211008_add_setup_complete_to_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddSetupCompleteToUser < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :users, :setup_complete, :boolean 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130425222157_add_delayed_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDelayedJob < ActiveRecord::Migration[4.2] 4 | def self.up 5 | create_table :delayed_jobs, force: true do |table| 6 | # Allows some jobs to jump to the front of the queue 7 | table.integer :priority, default: 0 8 | 9 | # Provides for retries, but still fail eventually. 10 | table.integer :attempts, default: 0 11 | 12 | # YAML-encoded string of the object that will do work 13 | table.text :handler 14 | 15 | # reason for last failure (See Note below) 16 | table.text :last_error 17 | 18 | # When to run. Could be Time.zone.now for immediately, or sometime in the 19 | # future. 20 | table.datetime :run_at 21 | 22 | # Set when a client is working on this object 23 | table.datetime :locked_at 24 | 25 | # Set when all retries have failed (actually, by default, the record is 26 | # deleted instead) 27 | table.datetime :failed_at 28 | 29 | # Who is working on this object (if locked) 30 | table.string :locked_by 31 | 32 | # The name of the queue this job is in 33 | table.string :queue 34 | 35 | table.timestamps null: false 36 | end 37 | 38 | add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority" 39 | end 40 | 41 | def self.down 42 | drop_table :delayed_jobs 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /db/migrate/20130429232127_add_status_to_feeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddStatusToFeeds < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :feeds, :status, :int 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130504005816_text_url.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TextUrl < ActiveRecord::Migration[4.2] 4 | def up 5 | change_column :feeds, :url, :text 6 | end 7 | 8 | def down 9 | change_column :feeds, :url, :string 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20130504022615_change_story_permalink_column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ChangeStoryPermalinkColumn < ActiveRecord::Migration[4.2] 4 | def up 5 | change_column :stories, :permalink, :text 6 | end 7 | 8 | def down 9 | change_column :stories, :permalink, :string 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20130509131045_add_unique_constraints.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUniqueConstraints < ActiveRecord::Migration[4.2] 4 | def change 5 | add_index :stories, [:permalink, :feed_id], unique: true 6 | add_index :feeds, :url, unique: true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20130513025939_add_keep_unread_to_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddKeepUnreadToStories < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :stories, :keep_unread, :boolean, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130513044029_add_is_starred_status_for_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddIsStarredStatusForStories < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :stories, :is_starred, :boolean, default: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130522014405_add_api_key_to_user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAPIKeyToUser < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :users, :api_key, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130730120312_add_entry_id_to_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddEntryIdToStories < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column :stories, :entry_id, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20130805113712_update_stories_unique_constraints.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UpdateStoriesUniqueConstraints < ActiveRecord::Migration[4.2] 4 | def up 5 | remove_index :stories, [:permalink, :feed_id] 6 | add_index :stories, 7 | [:entry_id, :feed_id], 8 | unique: true, 9 | length: { permalink: 767 } 10 | end 11 | 12 | def down 13 | remove_index :stories, [:entry_id, :feed_id] 14 | add_index :stories, 15 | [:permalink, :feed_id], 16 | unique: true, 17 | length: { permalink: 767 } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20130821020313_update_nil_entry_ids.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UpdateNilEntryIds < ActiveRecord::Migration[4.2] 4 | def up 5 | Story.where(entry_id: nil).find_each do |story| 6 | story.entry_id = story.permalink || story.id 7 | story.save 8 | end 9 | end 10 | 11 | def down 12 | # skip 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20130905204142_use_text_datatype_for_title_and_entry_id.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UseTextDatatypeForTitleAndEntryId < ActiveRecord::Migration[4.2] 4 | def up 5 | change_column :stories, :title, :text 6 | change_column :stories, :entry_id, :text 7 | end 8 | 9 | def down 10 | change_column :stories, :title, :string 11 | change_column :stories, :entry_id, :string 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20140413100725_add_groups_table_and_foreign_keys_to_feeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddGroupsTableAndForeignKeysToFeeds < ActiveRecord::Migration[4.2] 4 | def up 5 | create_table :groups do |t| 6 | t.string :name, null: false 7 | t.timestamps null: false 8 | end 9 | add_column :feeds, :group_id, :integer 10 | end 11 | 12 | def down 13 | drop_table :groups 14 | remove_column :feeds, :group_id 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20140421224454_fix_invalid_unicode.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FixInvalidUnicode < ActiveRecord::Migration[4.2] 4 | def up 5 | Story.find_each do |story| 6 | valid_body = story.body.delete("\u2028").delete("\u2029") 7 | story.update_attribute(:body, valid_body) 8 | end 9 | end 10 | 11 | def down 12 | # skip 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20141102103617_fix_invalid_titles_with_unicode_line_endings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FixInvalidTitlesWithUnicodeLineEndings < ActiveRecord::Migration[4.2] 4 | def up 5 | Story.find_each do |story| 6 | unless story.title.nil? 7 | valid_title = story.title.delete("\u2028").delete("\u2029") 8 | story.update_attribute(:title, valid_title) 9 | end 10 | end 11 | end 12 | 13 | def down 14 | # skip 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20221206231914_add_enclosure_url_to_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddEnclosureUrlToStories < ActiveRecord::Migration[4.2] 4 | def change 5 | add_column(:stories, :enclosure_url, :string) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20230120222742_drop_setup_complete.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DropSetupComplete < ActiveRecord::Migration[7.0] 4 | def change 5 | remove_column :users, :setup_complete, :boolean 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20230221233057_add_user_id_to_tables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUserIdToTables < ActiveRecord::Migration[7.0] 4 | def change 5 | add_reference :feeds, :user, index: true, foreign_key: true 6 | add_reference :groups, :user, index: true, foreign_key: true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20230223045525_add_null_false_to_associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddNullFalseToAssociations < ActiveRecord::Migration[7.0] 4 | def change 5 | update_null_foreign_keys 6 | 7 | change_column_null :feeds, :user_id, false 8 | change_column_null :groups, :user_id, false 9 | change_column_null :stories, :feed_id, false 10 | end 11 | 12 | private 13 | 14 | def update_null_foreign_keys 15 | first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1") 16 | 17 | return unless first_user_id 18 | 19 | connection.update("UPDATE feeds SET user_id = #{first_user_id} WHERE user_id IS NULL") 20 | connection.update("UPDATE groups SET user_id = #{first_user_id} WHERE user_id IS NULL") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20230223231930_add_username_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddUsernameToUsers < ActiveRecord::Migration[7.0] 4 | def change 5 | add_column :users, :username, :string 6 | add_index :users, :username, unique: true 7 | 8 | set_default_username 9 | 10 | change_column_null :users, :username, false 11 | end 12 | 13 | private 14 | 15 | def set_default_username 16 | first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1") 17 | 18 | return unless first_user_id 19 | 20 | connection.update("UPDATE users SET username = 'stringer' WHERE id = #{first_user_id}") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20230224042638_update_unique_indexes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UpdateUniqueIndexes < ActiveRecord::Migration[7.0] 4 | def change 5 | remove_index :feeds, :url 6 | add_index :feeds, [:url, :user_id], unique: true 7 | add_index :groups, [:name, :user_id], unique: true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20230301024452_encrypt_api_key.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class EncryptAPIKey < ActiveRecord::Migration[7.0] 4 | def change 5 | ActiveRecord::Encryption.config.support_unencrypted_data = true 6 | 7 | encrypt_api_keys 8 | 9 | ActiveRecord::Encryption.config.support_unencrypted_data = false 10 | 11 | change_column_null :users, :api_key, false 12 | add_index :users, :api_key, unique: true 13 | end 14 | 15 | private 16 | 17 | def encrypt_api_keys 18 | connection.select_all("SELECT id, api_key FROM users").each do |user| 19 | encrypted_api_key = ActiveRecord::Encryption.encrypt(user["api_key"]) 20 | connection.update("UPDATE users SET api_key = #{connection.quote(encrypted_api_key)} WHERE id = #{user['id']}") 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /db/migrate/20230312193113_drop_delayed_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DropDelayedJob < ActiveRecord::Migration[7.0] 4 | def up 5 | drop_table :delayed_jobs 6 | end 7 | 8 | def down 9 | raise ActiveRecord::IrreversibleMigration 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20230313034938_add_admin_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAdminToUsers < ActiveRecord::Migration[7.0] 4 | def change 5 | add_column :users, :admin, :boolean, default: false 6 | 7 | set_first_user_as_admin 8 | 9 | change_column_null :users, :admin, false 10 | end 11 | 12 | private 13 | 14 | def set_first_user_as_admin 15 | first_user_id = connection.select_value("SELECT id FROM users ORDER BY id LIMIT 1") 16 | 17 | return unless first_user_id 18 | 19 | connection.update("UPDATE users SET admin = TRUE WHERE id = #{first_user_id}") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /db/migrate/20230330215830_create_subscriptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateSubscriptions < ActiveRecord::Migration[7.0] 4 | def change 5 | create_table :subscriptions do |t| 6 | t.references :user, 7 | foreign_key: true, 8 | null: false, 9 | index: { unique: true } 10 | t.text :stripe_customer_id, null: false 11 | t.text :stripe_subscription_id, null: false 12 | t.text :status, null: false 13 | t.datetime :current_period_start, null: false 14 | t.datetime :current_period_end, null: false 15 | 16 | t.timestamps 17 | end 18 | 19 | add_index :subscriptions, :stripe_customer_id, unique: true 20 | add_index :subscriptions, :stripe_subscription_id, unique: true 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20230721160939_create_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateSettings < ActiveRecord::Migration[7.0] 4 | def change 5 | create_table :settings do |t| 6 | t.string :type, null: false, index: { unique: true } 7 | t.jsonb :data, null: false, default: {} 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20230801025230_create_good_job_settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobSettings < ActiveRecord::Migration[7.0] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Ensure this incremental update migration is idempotent 8 | # with monolithic install migration. 9 | return if connection.table_exists?(:good_job_settings) 10 | end 11 | end 12 | 13 | create_table :good_job_settings, id: :uuid do |t| 14 | t.timestamps 15 | t.text :key 16 | t.jsonb :value 17 | t.index :key, unique: true 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/migrate/20230801025231_create_index_good_jobs_jobs_on_priority_created_at_when_unfinished.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateIndexGoodJobsJobsOnPriorityCreatedAtWhenUnfinished < ActiveRecord::Migration[7.0] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | reversible do |dir| 8 | dir.up do 9 | # Ensure this incremental update migration is idempotent 10 | # with monolithic install migration. 11 | return if connection.index_name_exists?( 12 | :good_jobs, 13 | :index_good_jobs_jobs_on_priority_created_at_when_unfinished 14 | ) 15 | end 16 | end 17 | 18 | add_index :good_jobs, 19 | [:priority, :created_at], 20 | order: { priority: "DESC NULLS LAST", created_at: :asc }, 21 | where: "finished_at IS NULL", 22 | name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished, 23 | algorithm: :concurrently 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /db/migrate/20230801025232_create_good_job_batches.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobBatches < ActiveRecord::Migration[7.0] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Ensure this incremental update migration is idempotent 8 | # with monolithic install migration. 9 | return if connection.table_exists?(:good_job_batches) 10 | end 11 | end 12 | 13 | create_table :good_job_batches, id: :uuid do |t| 14 | t.timestamps 15 | t.text :description 16 | t.jsonb :serialized_properties 17 | t.text :on_finish 18 | t.text :on_success 19 | t.text :on_discard 20 | t.text :callback_queue_name 21 | t.integer :callback_priority 22 | t.datetime :enqueued_at 23 | t.datetime :discarded_at 24 | t.datetime :finished_at 25 | end 26 | 27 | change_table :good_jobs do |t| 28 | t.uuid :batch_id 29 | t.uuid :batch_callback_id 30 | 31 | t.index :batch_id, where: "batch_id IS NOT NULL" 32 | t.index :batch_callback_id, where: "batch_callback_id IS NOT NULL" 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /db/migrate/20230801025233_create_good_job_executions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobExecutions < ActiveRecord::Migration[7.0] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Ensure this incremental update migration is idempotent 8 | # with monolithic install migration. 9 | return if connection.table_exists?(:good_job_executions) 10 | end 11 | end 12 | 13 | create_table :good_job_executions, id: :uuid do |t| 14 | t.timestamps 15 | 16 | t.uuid :active_job_id, null: false 17 | t.text :job_class 18 | t.text :queue_name 19 | t.jsonb :serialized_params 20 | t.datetime :scheduled_at 21 | t.datetime :finished_at 22 | t.text :error 23 | 24 | t.index [:active_job_id, :created_at], 25 | name: :index_good_job_executions_on_active_job_id_and_created_at 26 | end 27 | 28 | change_table :good_jobs do |t| 29 | t.boolean :is_discrete 30 | t.integer :executions_count 31 | t.text :job_class 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /db/migrate/20230801025234_create_good_jobs_error_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobsErrorEvent < ActiveRecord::Migration[7.0] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Ensure this incremental update migration is idempotent 8 | # with monolithic install migration. 9 | return if connection.column_exists?(:good_jobs, :error_event) 10 | end 11 | end 12 | 13 | add_column :good_jobs, :error_event, :integer, limit: 2 14 | add_column :good_job_executions, :error_event, :integer, limit: 2 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /db/migrate/20240226201050_add_precision_to_timestamps.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddPrecisionToTimestamps < ActiveRecord::Migration[7.1] 4 | SKIP_TABLES = [:schema_migrations, :ar_internal_metadata].freeze 5 | 6 | def up 7 | migrate_precision(precision: 6) 8 | end 9 | 10 | def down 11 | migrate_precision(precision: nil) 12 | end 13 | 14 | def migrate_precision(precision:) 15 | table_names = ActiveRecord::Base.connection.tables.map(&:to_sym) 16 | table_names.each do |table| 17 | next if SKIP_TABLES.include?(table) 18 | 19 | ActiveRecord::Base.connection.columns(table).each do |column| 20 | next unless datetime_column?(column) 21 | 22 | change_column(table, column.name, :datetime, precision:) 23 | end 24 | end 25 | end 26 | 27 | private 28 | 29 | def datetime_column?(column) 30 | column.sql_type_metadata.type == :datetime 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /db/migrate/20240313195404_add_default_to_stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddDefaultToStories < ActiveRecord::Migration[7.1] 4 | def change 5 | change_column_default :stories, :is_read, from: nil, to: false 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240314031220_create_good_job_labels.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobLabels < ActiveRecord::Migration[7.1] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Ensure this incremental update migration is idempotent 8 | # with monolithic install migration. 9 | return if connection.column_exists?(:good_jobs, :labels) 10 | end 11 | end 12 | 13 | add_column :good_jobs, :labels, :text, array: true 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20240314031221_create_good_job_labels_index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobLabelsIndex < ActiveRecord::Migration[7.1] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | reversible do |dir| 8 | dir.up do 9 | unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) 10 | add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", 11 | name: :index_good_jobs_on_labels, algorithm: :concurrently 12 | end 13 | end 14 | 15 | dir.down do 16 | if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_labels) 17 | remove_index :good_jobs, name: :index_good_jobs_on_labels 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20240314031222_remove_good_job_active_id_index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RemoveGoodJobActiveIdIndex < ActiveRecord::Migration[7.1] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | reversible do |dir| 8 | dir.up do 9 | if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) 10 | remove_index :good_jobs, name: :index_good_jobs_on_active_job_id 11 | end 12 | end 13 | 14 | dir.down do 15 | unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_active_job_id) 16 | add_index :good_jobs, :active_job_id, name: :index_good_jobs_on_active_job_id 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /db/migrate/20240314031223_create_index_good_job_jobs_for_candidate_lookup.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateIndexGoodJobJobsForCandidateLookup < ActiveRecord::Migration[7.1] 4 | disable_ddl_transaction! 5 | 6 | def change 7 | reversible do |dir| 8 | dir.up do 9 | # Ensure this incremental update migration is idempotent 10 | # with monolithic install migration. 11 | return if connection.index_name_exists?(:good_jobs, :index_good_job_jobs_for_candidate_lookup) 12 | end 13 | end 14 | 15 | add_index :good_jobs, [:priority, :created_at], order: { priority: "ASC NULLS LAST", created_at: :asc }, 16 | where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup, 17 | algorithm: :concurrently 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20240316211109_add_stories_order_to_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddStoriesOrderToUsers < ActiveRecord::Migration[7.1] 4 | def change 5 | add_column :users, :stories_order, :string, default: "desc" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20240709172405_create_good_job_execution_error_backtrace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration[7.1] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Ensure this incremental update migration is idempotent 8 | # with monolithic install migration. 9 | if connection.column_exists?(:good_job_executions, :error_backtrace) 10 | return 11 | end 12 | end 13 | end 14 | 15 | add_column :good_job_executions, :error_backtrace, :text, array: true 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20240709172406_create_good_job_process_lock_ids.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobProcessLockIds < ActiveRecord::Migration[7.1] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Ensure this incremental update migration is idempotent 8 | # with monolithic install migration. 9 | return if connection.column_exists?(:good_jobs, :locked_by_id) 10 | end 11 | end 12 | 13 | add_column :good_jobs, :locked_by_id, :uuid 14 | add_column :good_jobs, :locked_at, :datetime 15 | add_column :good_job_executions, :process_id, :uuid 16 | add_column :good_job_processes, :lock_type, :integer, limit: 2 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/20240709172408_create_good_job_execution_duration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.1] 4 | def change 5 | reversible do |dir| 6 | dir.up do 7 | # Ensure this incremental update migration is idempotent 8 | # with monolithic install migration. 9 | return if connection.column_exists?(:good_job_executions, :duration) 10 | end 11 | end 12 | 13 | add_column :good_job_executions, :duration, :interval 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | stringer-setup: 5 | image: stringerrss/stringer:latest 6 | container_name: stringer-setup 7 | restart: no 8 | env_file: .env 9 | volumes: 10 | - ./.env:/app/.env 11 | entrypoint: ["ruby"] 12 | command: ["/app/docker/init_or_update_env.rb"] 13 | 14 | stringer-postgres: 15 | image: postgres:16-alpine 16 | container_name: stringer-postgres 17 | restart: always 18 | depends_on: 19 | stringer-setup: 20 | condition: service_completed_successfully 21 | networks: 22 | - stringer-network 23 | volumes: 24 | - /srv/stringer/data:/var/lib/postgresql/data 25 | env_file: .env 26 | 27 | stringer: 28 | image: stringerrss/stringer:latest 29 | container_name: stringer 30 | build: . 31 | depends_on: 32 | stringer-postgres: 33 | condition: service_started 34 | stringer-setup: 35 | condition: service_completed_successfully 36 | restart: always 37 | ports: 38 | - 80:8080 39 | networks: 40 | - stringer-network 41 | env_file: .env 42 | 43 | networks: 44 | stringer-network: 45 | external: false 46 | name: stringer-network 47 | -------------------------------------------------------------------------------- /docker/init_or_update_env.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Secrets 4 | def self.generate_secret(length) 5 | `openssl rand -hex #{length}`.strip 6 | end 7 | end 8 | 9 | pg_user = ENV.fetch("POSTGRES_USER", "stringer") 10 | pg_password = ENV.fetch("POSTGRES_PASSWORD", Secrets.generate_secret(32)) 11 | pg_host = ENV.fetch("POSTGRES_HOSTNAME", "stringer-postgres") 12 | pg_db = ENV.fetch("POSTGRES_DB", "stringer") 13 | 14 | required_env = { 15 | "SECRET_KEY_BASE" => Secrets.generate_secret(64), 16 | "ENCRYPTION_PRIMARY_KEY" => Secrets.generate_secret(64), 17 | "ENCRYPTION_DETERMINISTIC_KEY" => Secrets.generate_secret(64), 18 | "ENCRYPTION_KEY_DERIVATION_SALT" => Secrets.generate_secret(64), 19 | "POSTGRES_USER" => pg_user, 20 | "POSTGRES_PASSWORD" => pg_password, 21 | "POSTGRES_HOSTNAME" => pg_host, 22 | "POSTGRES_DB" => pg_db, 23 | "FETCH_FEEDS_CRON" => "*/5 * * * *", 24 | "CLEANUP_CRON" => "0 0 * * *", 25 | "DATABASE_URL" => "postgres://#{pg_user}:#{pg_password}@#{pg_host}/#{pg_db}" 26 | } 27 | 28 | required_env.each do |key, value| 29 | next if ENV.key?(key) 30 | 31 | File.open("/app/.env", "a") { |file| file << "#{key}=#{value}\n" } 32 | end 33 | -------------------------------------------------------------------------------- /docker/start.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | if [ -z "$DATABASE_URL" ]; then 3 | cat <<-EOF 4 | $(tput setaf 1)Error: no DATABASE_URL was specified. 5 | EOF 6 | 7 | exit 1 8 | fi 9 | 10 | rails assets:precompile 11 | 12 | : ${FETCH_FEEDS_CRON:='*/5 * * * *'} 13 | : ${CLEANUP_CRON:='0 0 * * *'} 14 | 15 | cat <<-EOF > /app/crontab 16 | $FETCH_FEEDS_CRON cd /app && bundle exec rake fetch_feeds 17 | $CLEANUP_CRON cd /app && bundle exec rake cleanup_old_stories 18 | EOF 19 | 20 | exec /usr/bin/supervisord -c /etc/supervisord.conf 21 | -------------------------------------------------------------------------------- /docker/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | loglevel=debug 4 | 5 | [program:puma] 6 | command=bash -c 'bundle exec rake db:migrate && bundle exec puma -p $PORT -C ./config/puma.rb' 7 | directory=/app 8 | autostart=true 9 | autorestart=true 10 | redirect_stderr=true 11 | stopsignal = QUIT 12 | 13 | [program:cron] 14 | command = /usr/local/bin/supercronic /app/crontab 15 | autostart=true 16 | autorestart=true 17 | -------------------------------------------------------------------------------- /docs/Heroku.md: -------------------------------------------------------------------------------- 1 | ```sh 2 | git clone git@github.com:stringer-rss/stringer.git 3 | cd stringer 4 | heroku create 5 | 6 | heroku config:set SECRET_KEY_BASE=`openssl rand -hex 64` 7 | heroku config:set ENCRYPTION_PRIMARY_KEY=`openssl rand -hex 64` 8 | heroku config:set ENCRYPTION_DETERMINISTIC_KEY=`openssl rand -hex 64` 9 | heroku config:set ENCRYPTION_KEY_DERIVATION_SALT=`openssl rand -hex 64` 10 | 11 | git push heroku main 12 | 13 | heroku config:set APP_URL=`heroku apps:info --shell | grep web_url | cut -d= -f2` 14 | 15 | heroku run rake db:migrate 16 | heroku restart 17 | 18 | heroku addons:create scheduler 19 | heroku addons:open scheduler 20 | ``` 21 | 22 | Add an hourly task that runs `rake lazy_fetch` (if you are not on Heroku you may want `rake fetch_feeds` instead). 23 | 24 | Load the app and follow the instructions to import your feeds and start using the app. 25 | 26 | See the "Niceties" section of the README for a few more tips and tricks for getting the most out of Stringer on Heroku. 27 | 28 | ## Updating the app 29 | 30 | From the app's directory: 31 | 32 | ```sh 33 | git pull 34 | git push heroku main 35 | heroku run rake db:migrate 36 | heroku restart 37 | ``` 38 | -------------------------------------------------------------------------------- /lib/admin_constraint.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AdminConstraint 4 | def matches?(request) 5 | request.session.key?(:user_id) && 6 | User.find(request.session[:user_id]).admin? 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/lib/assets/.keep -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/lib/tasks/.keep -------------------------------------------------------------------------------- /log/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/log/.gitkeep -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/log/.keep -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /public/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Black.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Black.eot -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Black.ttf -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Black.woff -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Black.woff2 -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Bold.eot -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Bold.ttf -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Bold.woff -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Bold.woff2 -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Italic.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Italic.eot -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Italic.ttf -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Italic.woff -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Italic.woff2 -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Light.eot -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Light.ttf -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Light.woff -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Light.woff2 -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Regular.eot -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Regular.woff -------------------------------------------------------------------------------- /public/fonts/lato/Lato-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/lato/Lato-Regular.woff2 -------------------------------------------------------------------------------- /public/fonts/reenie-beanie/ReenieBeanie.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/reenie-beanie/ReenieBeanie.eot -------------------------------------------------------------------------------- /public/fonts/reenie-beanie/ReenieBeanie.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/reenie-beanie/ReenieBeanie.ttf -------------------------------------------------------------------------------- /public/fonts/reenie-beanie/ReenieBeanie.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/reenie-beanie/ReenieBeanie.woff -------------------------------------------------------------------------------- /public/fonts/reenie-beanie/ReenieBeanie.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/fonts/reenie-beanie/ReenieBeanie.woff2 -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/icon.png -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/img/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/img/arrow-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/img/arrow-left.png -------------------------------------------------------------------------------- /public/img/arrow-right-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/img/arrow-right-up.png -------------------------------------------------------------------------------- /public/img/arrow-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/img/arrow-right.png -------------------------------------------------------------------------------- /public/img/arrow-up-left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/img/arrow-up-left.png -------------------------------------------------------------------------------- /public/img/arrow-up-right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/img/arrow-up-right.png -------------------------------------------------------------------------------- /public/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/img/favicon.png -------------------------------------------------------------------------------- /public/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/public/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Stringer", 3 | "theme_color": "#F67100", 4 | "background_color": "#FAF2E5", 5 | "display": "standalone", 6 | "start_url": "/" 7 | } 8 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /screenshots/feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/screenshots/feed.png -------------------------------------------------------------------------------- /screenshots/instructions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/screenshots/instructions.png -------------------------------------------------------------------------------- /screenshots/keyboard_shortcuts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/screenshots/keyboard_shortcuts.png -------------------------------------------------------------------------------- /screenshots/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/screenshots/logo.png -------------------------------------------------------------------------------- /screenshots/rss-zero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/screenshots/rss-zero.png -------------------------------------------------------------------------------- /screenshots/stories.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/screenshots/stories.png -------------------------------------------------------------------------------- /spec/commands/cast_boolean_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe CastBoolean do 4 | ["true", true, "1"].each do |true_value| 5 | it "returns true when passed #{true_value.inspect}" do 6 | expect(described_class.call(true_value)).to be(true) 7 | end 8 | end 9 | 10 | ["false", false, "0"].each do |false_value| 11 | it "returns false when passed #{false_value.inspect}" do 12 | expect(described_class.call(false_value)).to be(false) 13 | end 14 | end 15 | 16 | it "raises an error when passed non-boolean value" do 17 | ["butt", 0, nil, "", []].each do |bad_value| 18 | expected_message = "cannot cast to boolean: #{bad_value.inspect}" 19 | expect { described_class.call(bad_value) } 20 | .to raise_error(ArgumentError, expected_message) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/commands/feed/create_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Feed::Create do 4 | context "feed cannot be discovered" do 5 | it "returns false if cant discover any feeds" do 6 | expect(FeedDiscovery).to receive(:call).and_return(false) 7 | result = described_class.call("http://not-a-feed.com", user: default_user) 8 | 9 | expect(result).to be(false) 10 | end 11 | end 12 | 13 | context "feed can be discovered" do 14 | it "parses and creates the feed if discovered" do 15 | feed = build(:feed) 16 | feed_result = double(title: feed.name, feed_url: feed.url) 17 | expect(FeedDiscovery).to receive(:call).and_return(feed_result) 18 | 19 | expect { described_class.call("http://feed.com", user: default_user) } 20 | .to change(Feed, :count).by(1) 21 | end 22 | 23 | context "title includes a script tag" do 24 | it "deletes the script tag from the title" do 25 | feed = 26 | double(title: "foobar", feed_url: nil) 27 | 28 | expect(FeedDiscovery).to receive(:call).and_return(feed) 29 | 30 | feed = described_class.call("http://feed.com", user: default_user) 31 | 32 | expect(feed.name).to eq("foobar") 33 | end 34 | end 35 | end 36 | 37 | it "uses feed_url as name when title is not present" do 38 | feed_url = "https://protomen.com/news/feed" 39 | result = instance_double(Feedjira::Parser::RSS, title: nil, feed_url:) 40 | expect(FeedDiscovery).to receive(:call).and_return(result) 41 | 42 | expect { described_class.call(feed_url, user: default_user) } 43 | .to change(Feed, :count).by(1) 44 | 45 | expect(Feed.last.name).to eq(feed_url) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/commands/feed/export_to_opml_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Feed::ExportToOpml do 4 | it "returns OPML XML" do 5 | feed_one, feed_two = build_pair(:feed) 6 | result = described_class.call([feed_one, feed_two]) 7 | 8 | outlines = Nokogiri.XML(result).xpath("//body//outline") 9 | expect(outlines.size).to eq(2) 10 | expect(outlines.first["title"]).to eq(feed_one.name) 11 | expect(outlines.first["xmlUrl"]).to eq(feed_one.url) 12 | expect(outlines.last["title"]).to eq(feed_two.name) 13 | expect(outlines.last["xmlUrl"]).to eq(feed_two.url) 14 | end 15 | 16 | it "handles empty feeds" do 17 | result = described_class.call([]) 18 | 19 | outlines = Nokogiri.XML(result).xpath("//body//outline") 20 | expect(outlines).to be_empty 21 | end 22 | 23 | it "has a proper title" do 24 | feed_one, feed_two = build_pair(:feed) 25 | result = described_class.call([feed_one, feed_two]) 26 | 27 | title = Nokogiri.XML(result).xpath("//head//title").first 28 | expect(title.content).to eq("Feeds from Stringer") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/commands/feed/fetch_all_for_user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Feed::FetchAllForUser do 4 | def stub_pool 5 | pool = instance_double(Thread::Pool) 6 | expect(Thread).to receive(:pool).and_return(pool) 7 | expect(pool).to receive(:process).at_least(:once).and_yield 8 | expect(pool).to receive(:shutdown) 9 | end 10 | 11 | it "calls Feed::FetchOne for every feed" do 12 | stub_pool 13 | feed1, feed2 = create_pair(:feed) 14 | 15 | expect { described_class.call(default_user) } 16 | .to invoke(:call).on(Feed::FetchOne).with(feed1) 17 | .and invoke(:call).on(Feed::FetchOne).with(feed2) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/commands/feed/fetch_all_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Feed::FetchAll do 4 | def stub_pool 5 | pool = instance_double(Thread::Pool) 6 | expect(Thread).to receive(:pool).and_return(pool) 7 | expect(pool).to receive(:process).at_least(:once).and_yield 8 | expect(pool).to receive(:shutdown) 9 | end 10 | 11 | it "calls Feed::FetchOne for every feed" do 12 | stub_pool 13 | feed1, feed2 = create_pair(:feed) 14 | 15 | expect { described_class.call } 16 | .to invoke(:call).on(Feed::FetchOne).with(feed1) 17 | .and invoke(:call).on(Feed::FetchOne).with(feed2) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/commands/fever_api/authentication_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::Authentication do 4 | def authorization 5 | Authorization.new(default_user) 6 | end 7 | 8 | it "returns the latest feed's last_fetched time" do 9 | feed = create(:feed, last_fetched: 1.month.ago) 10 | create(:feed, last_fetched: 1.year.ago) 11 | 12 | result = described_class.call(authorization:) 13 | expect(result[:last_refreshed_on_time]).to eq(Integer(feed.last_fetched)) 14 | end 15 | 16 | it "returns 0 for last_refreshed_on_time when there are no feeds" do 17 | result = described_class.call(authorization:) 18 | expect(result[:last_refreshed_on_time]).to eq(0) 19 | end 20 | 21 | it "returns a hash with keys :auth and :last_refreshed_on_time" do 22 | result = described_class.call(authorization:) 23 | expect(result).to eq(auth: 1, last_refreshed_on_time: 0) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/commands/fever_api/read_favicons_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::ReadFavicons do 4 | it "returns a fixed icon list if requested" do 5 | expect(described_class.call({ favicons: nil })).to eq( 6 | favicons: [ 7 | { 8 | id: 0, 9 | data: "image/gif;base64,#{described_class::ICON}" 10 | } 11 | ] 12 | ) 13 | end 14 | 15 | it "returns an empty hash otherwise" do 16 | expect(described_class.call({})).to eq({}) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/commands/fever_api/read_feeds_groups_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::ReadFeedsGroups do 4 | it "returns a list of groups requested through feeds" do 5 | group = create(:group) 6 | feeds = create_list(:feed, 3, group:) 7 | authorization = Authorization.new(default_user) 8 | 9 | expect(described_class.call(authorization:, feeds: nil)).to eq( 10 | feeds_groups: [ 11 | { 12 | group_id: group.id, 13 | feed_ids: feeds.map(&:id).join(",") 14 | } 15 | ] 16 | ) 17 | end 18 | 19 | it "returns a list of groups requested through groups" do 20 | group = create(:group) 21 | feeds = create_list(:feed, 3, group:) 22 | authorization = Authorization.new(default_user) 23 | 24 | expect(described_class.call(authorization:, groups: nil)).to eq( 25 | feeds_groups: [ 26 | { 27 | group_id: group.id, 28 | feed_ids: feeds.map(&:id).join(",") 29 | } 30 | ] 31 | ) 32 | end 33 | 34 | it "returns an empty hash otherwise" do 35 | authorization = Authorization.new(default_user) 36 | 37 | expect(described_class.call(authorization:)).to eq({}) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/commands/fever_api/read_feeds_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::ReadFeeds do 4 | it "returns a list of feeds" do 5 | feeds = create_list(:feed, 3) 6 | authorization = Authorization.new(default_user) 7 | 8 | expect(described_class.call(authorization:, feeds: nil)) 9 | .to eq(feeds: feeds.map(&:as_fever_json)) 10 | end 11 | 12 | it "returns an empty hash otherwise" do 13 | authorization = Authorization.new(default_user) 14 | 15 | expect(described_class.call(authorization:)).to eq({}) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/commands/fever_api/read_groups_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::ReadGroups do 4 | it "returns a group list if requested" do 5 | groups = create_pair(:group) 6 | authorization = Authorization.new(default_user) 7 | 8 | expect(described_class.call(authorization:, groups: nil)) 9 | .to eq(groups: [Group::UNGROUPED, *groups].map(&:as_fever_json)) 10 | end 11 | 12 | it "returns an empty hash otherwise" do 13 | authorization = Authorization.new(default_user) 14 | 15 | expect(described_class.call(authorization:)).to eq({}) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/commands/fever_api/read_items_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::ReadItems do 4 | it "returns a list of unread items including total count" do 5 | stories = create_list(:story, 3) 6 | authorization = Authorization.new(default_user) 7 | 8 | items = stories.map(&:as_fever_json) 9 | expect(described_class.call(authorization:, items: nil)) 10 | .to eq(items:, total_items: 3) 11 | end 12 | 13 | it "returns a list of unread items with empty since_id" do 14 | stories = create_list(:story, 3) 15 | authorization = Authorization.new(default_user) 16 | 17 | items = stories.map(&:as_fever_json) 18 | expect(described_class.call(authorization:, items: nil, since_id: "")) 19 | .to eq(items:, total_items: 3) 20 | end 21 | 22 | it "returns a list of unread items since id including total count" do 23 | story, *other_stories = create_list(:story, 3) 24 | authorization = Authorization.new(default_user) 25 | 26 | expect(described_class.call(authorization:, items: nil, since_id: story.id)) 27 | .to eq(items: other_stories.map(&:as_fever_json), total_items: 3) 28 | end 29 | 30 | it "returns a list of specified items including total count" do 31 | _story1, *other_stories = create_list(:story, 3) 32 | with_ids = other_stories.map(&:id) 33 | authorization = Authorization.new(default_user) 34 | 35 | expect(described_class.call(authorization:, items: nil, with_ids:)).to eq( 36 | items: other_stories.reverse.map(&:as_fever_json), 37 | total_items: 2 38 | ) 39 | end 40 | 41 | it "returns an empty hash otherwise" do 42 | authorization = Authorization.new(default_user) 43 | 44 | expect(described_class.call(authorization:)).to eq({}) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/commands/fever_api/read_links_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::ReadLinks do 4 | it "returns a fixed link list if requested" do 5 | expect(described_class.call(links: nil)).to eq(links: []) 6 | end 7 | 8 | it "returns an empty hash otherwise" do 9 | expect(described_class.call({})).to eq({}) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/commands/fever_api/sync_saved_item_ids_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::SyncSavedItemIds do 4 | it "returns a list of starred items if requested" do 5 | stories = create_list(:story, 3, :starred) 6 | authorization = Authorization.new(default_user) 7 | 8 | expect(described_class.call(authorization:, saved_item_ids: nil)) 9 | .to eq(saved_item_ids: stories.map(&:id).join(",")) 10 | end 11 | 12 | it "returns an empty hash otherwise" do 13 | authorization = Authorization.new(default_user) 14 | 15 | expect(described_class.call(authorization:)).to eq({}) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/commands/fever_api/sync_unread_item_ids_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::SyncUnreadItemIds do 4 | it "returns a list of unread items if requested" do 5 | stories = create_list(:story, 3) 6 | authorization = Authorization.new(default_user) 7 | 8 | expect(described_class.call(authorization:, unread_item_ids: nil)) 9 | .to eq(unread_item_ids: stories.map(&:id).join(",")) 10 | end 11 | 12 | it "returns an empty hash otherwise" do 13 | authorization = Authorization.new(default_user) 14 | 15 | expect(described_class.call(authorization:)).to eq({}) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/commands/fever_api/write_mark_feed_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::WriteMarkFeed do 4 | def params(feed, before:) 5 | authorization = Authorization.new(feed.user) 6 | 7 | { authorization:, mark: "feed", id: feed.id, before: } 8 | end 9 | 10 | it "marks the feed stories as read before the given timestamp" do 11 | feed = create(:feed) 12 | story = create(:story, feed:, created_at: 1.week.ago) 13 | 14 | expect { described_class.call(**params(feed, before: 1.day.ago.to_i)) } 15 | .to change { story.reload.is_read? }.from(false).to(true) 16 | end 17 | 18 | it "does not mark the feed stories as read after the given timestamp" do 19 | feed = create(:feed) 20 | story = create(:story, feed:) 21 | 22 | expect { described_class.call(**params(feed, before: 1.day.ago.to_i)) } 23 | .to not_change { story.reload.is_read? }.from(false) 24 | end 25 | 26 | it "returns an empty hash otherwise" do 27 | authorization = Authorization.new(default_user) 28 | 29 | expect(described_class.call(authorization:)).to eq({}) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/commands/fever_api/write_mark_group_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::WriteMarkGroup do 4 | def authorization 5 | Authorization.new(default_user) 6 | end 7 | 8 | it "marks the group stories as read before the given timestamp" do 9 | story = create(:story, :with_group, created_at: 1.week.ago) 10 | before = 1.day.ago 11 | id = story.group_id 12 | 13 | expect { described_class.call(authorization:, mark: "group", id:, before:) } 14 | .to change_record(story, :is_read).from(false).to(true) 15 | end 16 | 17 | it "returns an empty hash otherwise" do 18 | expect(described_class.call(authorization:)).to eq({}) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/commands/fever_api/write_mark_item_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe FeverAPI::WriteMarkItem do 4 | context "when as: 'read'" do 5 | it "marks the story as read" do 6 | story = create(:story) 7 | authorization = Authorization.new(story.user) 8 | params = { authorization:, mark: "item", as: "read", id: story.id } 9 | 10 | expect { subject.call(**params) } 11 | .to change_record(story, :is_read).from(false).to(true) 12 | end 13 | end 14 | 15 | context "when as: 'unread'" do 16 | it "marks the story as unread" do 17 | story = create(:story, :read) 18 | authorization = Authorization.new(story.user) 19 | params = { authorization:, mark: "item", as: "unread", id: story.id } 20 | 21 | expect { subject.call(**params) } 22 | .to change_record(story, :is_read).from(true).to(false) 23 | end 24 | end 25 | 26 | context "when as: 'saved'" do 27 | it "marks the story as starred" do 28 | story = create(:story) 29 | authorization = Authorization.new(story.user) 30 | params = { authorization:, mark: "item", as: "saved", id: story.id } 31 | 32 | expect { subject.call(**params) } 33 | .to change_record(story, :is_starred).from(false).to(true) 34 | end 35 | end 36 | 37 | context "when as: 'unsaved'" do 38 | it "marks the story as unstarred" do 39 | story = create(:story, :starred) 40 | authorization = Authorization.new(story.user) 41 | params = { authorization:, mark: "item", as: "unsaved", id: story.id } 42 | 43 | expect { subject.call(**params) } 44 | .to change_record(story, :is_starred).from(true).to(false) 45 | end 46 | end 47 | 48 | it "returns an empty hash when :as is not present" do 49 | authorization = Authorization.new(default_user) 50 | 51 | expect(subject.call(authorization:, mark: "item")).to eq({}) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/commands/story/mark_all_as_read_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe MarkAllAsRead do 4 | it "marks all stories as read" do 5 | stories = create_pair(:story) 6 | 7 | expect { described_class.call(stories.map(&:id)) } 8 | .to change_all_records(stories, :is_read).from(false).to(true) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/commands/story/mark_as_read_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe MarkAsRead do 4 | it "marks a story as read" do 5 | story = create(:story, is_read: false) 6 | 7 | expect { described_class.call(story.id) } 8 | .to change_record(story, :is_read).from(false).to(true) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/commands/story/mark_as_starred_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe MarkAsStarred do 4 | describe "#mark_as_starred" do 5 | it "marks a story as starred" do 6 | story = create(:story) 7 | 8 | expect { described_class.call(story.id) } 9 | .to change_record(story, :is_starred).from(false).to(true) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/commands/story/mark_as_unread_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe MarkAsUnread do 4 | it "marks a story as unread" do 5 | story = create(:story, is_read: true) 6 | 7 | expect { described_class.call(story.id) } 8 | .to change_record(story, :is_read).from(true).to(false) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/commands/story/mark_as_unstarred_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe MarkAsUnstarred do 4 | it "marks a story as unstarred" do 5 | story = create(:story, :starred) 6 | 7 | expect { described_class.call(story.id) } 8 | .to change_record(story, :is_starred).from(true).to(false) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/commands/story/mark_feed_as_read_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe MarkFeedAsRead do 4 | it "marks feed stories as read before timestamp" do 5 | story = create(:story, created_at: 1.week.ago) 6 | before = 1.day.ago 7 | 8 | expect { described_class.call(story.feed_id, before) } 9 | .to change_record(story, :is_read).from(false).to(true) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/commands/story/mark_group_as_read_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe MarkGroupAsRead do 4 | describe "#mark_group_as_read" do 5 | it "marks group as read" do 6 | story = create(:story, :with_group, created_at: 1.week.ago) 7 | timestamp = 1.day.ago 8 | 9 | expect { described_class.call(story.group_id, timestamp) } 10 | .to change_record(story, :is_read).from(false).to(true) 11 | end 12 | 13 | it "does not mark any group as read when group is not provided" do 14 | story = create(:story, :with_group, created_at: 1.week.ago) 15 | timestamp = 1.day.ago 16 | 17 | expect { described_class.call(nil, timestamp) } 18 | .not_to change_record(story, :is_read).from(false) 19 | end 20 | 21 | it "marks as read all feeds when group is 0 (KINDLING_GROUP_ID)" do 22 | story = create(:story, :with_group, created_at: 1.week.ago) 23 | timestamp = 1.day.ago 24 | 25 | expect { described_class.call(0, timestamp) } 26 | .to change_record(story, :is_read).from(false).to(true) 27 | end 28 | 29 | it "marks as read all feeds when group is -1 (SPARKS_GROUP_ID)" do 30 | story = create(:story, :with_group, created_at: 1.week.ago) 31 | timestamp = 1.day.ago 32 | 33 | expect { described_class.call(-1, timestamp) } 34 | .to change_record(story, :is_read).from(false).to(true) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/commands/user/sign_in_user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SignInUser do 4 | it "returns the user if the password is valid" do 5 | result = described_class.call(default_user.username, default_user.password) 6 | 7 | expect(result).to eq(default_user) 8 | end 9 | 10 | it "returns nil if password is invalid" do 11 | create(:user) 12 | 13 | result = described_class.call(default_user.username, "not-the-pw") 14 | 15 | expect(result).to be_nil 16 | end 17 | 18 | it "returns nil if the user does not exist" do 19 | result = described_class.call("not-the-username", "not-the-pw") 20 | 21 | expect(result).to be_nil 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/factories/feeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory(:feed) do 5 | user { default_user } 6 | 7 | sequence(:name, 100) { |n| "Feed #{n}" } 8 | sequence(:url, 100) { |n| "http://exampoo.com/#{n}" } 9 | 10 | trait :with_group do 11 | group 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/factories/groups.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory(:group) do 5 | user { default_user } 6 | sequence(:name, 100) { |n| "Group #{n}" } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/factories/stories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory(:story) do 5 | feed 6 | 7 | sequence(:entry_id, 100) { |n| "entry-#{n}" } 8 | sequence(:published, 100) { |n| n.days.ago } 9 | 10 | trait :read do 11 | is_read { true } 12 | end 13 | 14 | trait :starred do 15 | is_starred { true } 16 | end 17 | 18 | trait :with_group do 19 | feed factory: [:feed, :with_group] 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory(:user) do 5 | sequence(:username, 100) { |n| "user-#{n}" } 6 | password { "super-secret" } 7 | admin { false } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/feeds.opml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | subscriptions title 5 | 6 | 7 | 9 | 12 | 13 | 16 | 17 | 18 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /spec/javascript/spec/spec_helper.js: -------------------------------------------------------------------------------- 1 | if (typeof initMochaPhantomJS === 'function') { 2 | initMochaPhantomJS() 3 | } 4 | 5 | mocha.ui('bdd'); 6 | 7 | chai.should(); 8 | -------------------------------------------------------------------------------- /spec/javascript/support/views/test/index.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Stringer JavaScript Test Suite 5 | 6 | 7 | 8 | <% js_files.each do |js_file| %> 9 | 10 | <% end %> 11 | <% css_files.each do |css_file| %> 12 | 13 | <% end %> 14 | 15 | 16 | <%= render("stories/templates") %> 17 | 18 | 19 |
    20 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /spec/javascript/test_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestController < ApplicationController 4 | skip_before_action :verify_authenticity_token 5 | 6 | def index 7 | authorization.skip 8 | prepend_view_path(test_path("support", "views")) 9 | render(layout: false, locals: { js_files: }) 10 | end 11 | 12 | def spec 13 | authorization.skip 14 | send_file(test_path("spec", "#{params[:splat]}.js")) 15 | end 16 | 17 | def vendor 18 | authorization.skip 19 | 20 | filename = "#{params[:splat]}.#{params[:format]}" 21 | send_file(test_path("support", "vendor", params[:format], filename)) 22 | end 23 | 24 | private 25 | 26 | def test_path(*) 27 | File.expand_path(File.join(__dir__, *)) 28 | end 29 | 30 | def vendor_js_files 31 | [ 32 | "vendor/js/mocha.js", 33 | "vendor/js/sinon.js", 34 | "vendor/js/chai.js", 35 | "vendor/js/chai-changes.js", 36 | "vendor/js/chai-backbone.js", 37 | "vendor/js/sinon-chai.js" 38 | ] 39 | end 40 | 41 | def vendor_css_files 42 | ["vendor/css/mocha.css"] 43 | end 44 | 45 | def js_helper_files 46 | ["spec/spec_helper.js"] 47 | end 48 | 49 | def js_test_files 50 | ["/spec/models/story_spec.js", "/spec/views/story_view_spec.js"] 51 | end 52 | 53 | def js_files 54 | vendor_js_files + js_helper_files + js_test_files 55 | end 56 | 57 | def css_files 58 | vendor_css_files 59 | end 60 | helper_method :css_files 61 | end 62 | -------------------------------------------------------------------------------- /spec/jobs/callable_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe CallableJob do 4 | it "calls the callable" do 5 | callable = ->(*, **) {} 6 | 7 | expect { described_class.perform_now(callable, "foo", bar: "baz") } 8 | .to invoke(:call).on(callable).with("foo", bar: "baz") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/lib/admin_constraint_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe AdminConstraint do 4 | def make_request(session: {}) 5 | request = ActionDispatch::Request.new({}) 6 | request.session = session 7 | request 8 | end 9 | 10 | it "matches when the session user is an admin" do 11 | user = create(:user, admin: true) 12 | request = make_request(session: { user_id: user.id }) 13 | 14 | expect(described_class.new.matches?(request)).to be(true) 15 | end 16 | 17 | it "does not match when the session user is not an admin" do 18 | user = create(:user) 19 | request = make_request(session: { user_id: user.id }) 20 | 21 | expect(described_class.new.matches?(request)).to be(false) 22 | end 23 | 24 | it "does not match when there is no session user" do 25 | request = make_request 26 | 27 | expect(described_class.new.matches?(request)).to be(false) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/models/group_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Group do 4 | describe "#as_fever_json" do 5 | it "returns a hash of the group in fever format" do 6 | group = described_class.new(id: 5, name: "wat group") 7 | 8 | expect(group.as_fever_json).to eq(id: 5, title: "wat group") 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/models/migration_status_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "MigrationStatus" do 4 | it "returns array of strings representing pending migrations" do 5 | migrator = ActiveRecord::Base.connection.pool.migration_context.open 6 | 7 | allow(migrator).to receive(:pending_migrations).and_return( 8 | [ 9 | ActiveRecord::Migration.new("Migration B", 2), 10 | ActiveRecord::Migration.new("Migration C", 3) 11 | ] 12 | ) 13 | allow(ActiveRecord::Migrator).to receive(:new).and_return(migrator) 14 | 15 | expect(MigrationStatus.call).to eq(["Migration B - 2", "Migration C - 3"]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/models/setting/user_signup_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Setting::UserSignup do 4 | describe ".first" do 5 | it "returns the first record" do 6 | setting = described_class.create! 7 | 8 | expect(described_class.first).to eq(setting) 9 | end 10 | 11 | it "creates a record if one does not already exist" do 12 | expect { described_class.first }.to change(described_class, :count).by(1) 13 | end 14 | end 15 | 16 | describe ".enabled?" do 17 | it "returns true when enabled" do 18 | create(:user) 19 | described_class.create!(enabled: true) 20 | 21 | expect(described_class.enabled?).to be(true) 22 | end 23 | 24 | it "returns false when disabled" do 25 | create(:user) 26 | described_class.create!(enabled: false) 27 | 28 | expect(described_class.enabled?).to be(false) 29 | end 30 | 31 | it "returns true when no users exist" do 32 | described_class.create!(enabled: false) 33 | 34 | expect(described_class.enabled?).to be(true) 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe User do 4 | describe "#update_api_key" do 5 | it "updates the api key when the username changed" do 6 | user = create(:user, username: "stringer", password: "super-secret") 7 | user.update!(username: "booger") 8 | 9 | expect(user.api_key).to eq(Digest::MD5.hexdigest("booger:super-secret")) 10 | end 11 | 12 | it "updates the api key when the password changed" do 13 | user = create(:user, username: "stringer", password: "super-secret") 14 | user.update!(password: "new-password") 15 | 16 | expect(user.api_key).to eq(Digest::MD5.hexdigest("stringer:new-password")) 17 | end 18 | 19 | it "does nothing when the username and password have not changed" do 20 | user = create(:user, username: "stringer", password: "super-secret") 21 | user = described_class.find(user.id) 22 | 23 | expect { user.save! }.to not_change(user, :api_key).and not_raise_error 24 | end 25 | 26 | it "raises an error when password and password challenge are blank" do 27 | user = create(:user, username: "stringer", password: "super-secret") 28 | user = described_class.find(user.id) 29 | expected_message = "Cannot change username without providing a password" 30 | 31 | expect { user.update!(username: "booger") } 32 | .to raise_error(ActiveRecord::ActiveRecordError, expected_message) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | ENV["RAILS_ENV"] ||= "test" 5 | 6 | require_relative "support/coverage" 7 | require_relative "../config/environment" 8 | # Prevent database truncation if the environment is production 9 | if Rails.env.production? 10 | abort("The Rails environment is running in production mode!") 11 | end 12 | require "rspec/rails" 13 | 14 | Rails.root.glob("spec/support/*.rb").each { |path| require path } 15 | 16 | # Checks for pending migrations and applies them before tests are run. 17 | begin 18 | ActiveRecord::Migration.maintain_test_schema! 19 | rescue ActiveRecord::PendingMigrationError => e 20 | abort(e.to_s.strip) 21 | end 22 | 23 | RSpec.configure do |config| 24 | config.include(RequestHelpers, type: :request) 25 | config.include(SystemHelpers, type: :system) 26 | config.include(ActiveSupport::Testing::TimeHelpers) 27 | 28 | config.use_transactional_fixtures = true 29 | 30 | config.infer_spec_type_from_file_location! 31 | 32 | # Filter lines from Rails gems in backtraces. 33 | config.filter_rails_from_backtrace! 34 | # arbitrary gems may also be filtered via: 35 | # config.filter_gems_from_backtrace("gem name") 36 | end 37 | -------------------------------------------------------------------------------- /spec/repositories/group_repository_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe GroupRepository do 4 | describe ".list" do 5 | it "lists groups ordered by lower name" do 6 | group1 = create(:group, name: "Zabba") 7 | group2 = create(:group, name: "zlabba") 8 | group3 = create(:group, name: "blabba") 9 | group4 = create(:group, name: "Babba") 10 | expected_groups = [group4, group3, group1, group2] 11 | 12 | expect(described_class.list).to eq(expected_groups) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/repositories/user_repository_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe UserRepository do 4 | describe ".fetch" do 5 | it "returns nil when given id is nil" do 6 | expect(described_class.fetch(nil)).to be_nil 7 | end 8 | 9 | it "returns the user for the given id" do 10 | user = default_user 11 | 12 | expect(described_class.fetch(user.id)).to eq(user) 13 | end 14 | end 15 | 16 | describe ".setup_complete?" do 17 | it "returns false when there are no users" do 18 | expect(described_class.setup_complete?).to be(false) 19 | end 20 | 21 | it "returns true when there is at least one user" do 22 | create(:user) 23 | 24 | expect(described_class.setup_complete?).to be(true) 25 | end 26 | end 27 | 28 | describe ".save" do 29 | it "saves the given user" do 30 | user = build(:user) 31 | 32 | expect { described_class.save(user) } 33 | .to change(user, :persisted?).from(false).to(true) 34 | end 35 | 36 | it "returns the given user" do 37 | user = User.new 38 | 39 | expect(described_class.save(user)).to eq(user) 40 | end 41 | end 42 | 43 | describe ".first" do 44 | it "returns the first user" do 45 | user = default_user 46 | create(:user) 47 | 48 | expect(described_class.first).to eq(user) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/requests/debug_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe DebugController do 4 | describe "#debug" do 5 | def setup 6 | login_as(create(:user, admin: true)) 7 | 8 | expect(MigrationStatus) 9 | .to receive(:call) 10 | .and_return(["Migration B - 2", "Migration C - 3"]) 11 | end 12 | 13 | it "displays an admin settings link" do 14 | setup 15 | 16 | get("/admin/debug") 17 | 18 | expect(rendered).to have_link("Admin Settings", href: settings_path) 19 | end 20 | 21 | it "displays the current Ruby version" do 22 | setup 23 | 24 | get "/admin/debug" 25 | 26 | expect(rendered).to have_css("dd", text: /#{RUBY_VERSION}/) 27 | end 28 | 29 | it "displays the user agent" do 30 | setup 31 | 32 | get("/admin/debug", headers: { "HTTP_USER_AGENT" => "testy" }) 33 | 34 | expect(rendered).to have_css("dd", text: /testy/) 35 | end 36 | 37 | it "displays the jobs count" do 38 | setup 39 | 12.times { GoodJob::Job.create!(scheduled_at: Time.zone.now) } 40 | 41 | get "/admin/debug" 42 | 43 | expect(rendered).to have_css("dd", text: /12/) 44 | end 45 | 46 | it "displays pending migrations" do 47 | setup 48 | 49 | get "/admin/debug" 50 | 51 | expect(rendered).to have_css("li", text: /Migration B - 2/) 52 | .and have_css("li", text: /Migration C - 3/) 53 | end 54 | end 55 | 56 | describe "#heroku" do 57 | it "displays Heroku instructions" do 58 | get("/heroku") 59 | 60 | expect(rendered).to have_text("add an hourly task") 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/requests/exports_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ExportsController do 4 | describe "GET /feeds/export" do 5 | def expected_xml 6 | <<~XML 7 | 8 | 9 | 10 | Feeds from Stringer 11 | 12 | 13 | 14 | XML 15 | end 16 | 17 | it "returns an OPML file" do 18 | login_as(default_user) 19 | 20 | get "/feeds/export" 21 | 22 | expect(response.body).to eq(expected_xml) 23 | end 24 | 25 | it "responds with xml content type" do 26 | login_as(default_user) 27 | 28 | get "/feeds/export" 29 | 30 | expect(response.header["Content-Type"]).to include("application/xml") 31 | end 32 | 33 | it "responds with disposition attachment" do 34 | login_as(default_user) 35 | 36 | get "/feeds/export" 37 | 38 | expected = 39 | "attachment; filename=\"stringer.opml\"; filename*=UTF-8''stringer.opml" 40 | expect(response.header["Content-Disposition"]).to eq(expected) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/requests/imports_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ImportsController do 4 | describe "GET /feeds/import" do 5 | it "displays the import options" do 6 | login_as(default_user) 7 | 8 | get "/feeds/import" 9 | 10 | expect(rendered).to have_field("opml_file") 11 | end 12 | end 13 | 14 | describe "POST /feeds/import" do 15 | opml_file = Rack::Test::UploadedFile.new( 16 | "spec/sample_data/subscriptions.xml", 17 | "application/xml" 18 | ) 19 | 20 | it "parses OPML and starts fetching" do 21 | expect(Feed::ImportFromOpml).to receive(:call).once 22 | login_as(default_user) 23 | 24 | post "/feeds/import", params: { opml_file: } 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/requests/sessions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SessionsController do 4 | describe "#new" do 5 | it "has a password input and login button" do 6 | create(:user) 7 | 8 | get "/login" 9 | 10 | expect(rendered).to have_field("password") 11 | end 12 | end 13 | 14 | describe "#create" do 15 | it "denies access when password is incorrect" do 16 | user = create(:user) 17 | params = { username: user.username, password: "not-the-password" } 18 | 19 | post("/login", params:) 20 | 21 | expect(rendered).to have_css(".error") 22 | end 23 | 24 | it "allows access when password is correct" do 25 | user = default_user 26 | params = { username: user.username, password: user.password } 27 | 28 | post("/login", params:) 29 | 30 | expect(session[:user_id]).to eq(user.id) 31 | end 32 | 33 | it "redirects to the root page" do 34 | user = default_user 35 | params = { username: user.username, password: user.password } 36 | 37 | post("/login", params:) 38 | 39 | expect(URI.parse(response.location).path).to eq("/") 40 | end 41 | 42 | it "redirects to the previous path when present" do 43 | user = default_user 44 | params = { username: user.username, password: user.password } 45 | get("/archive") 46 | 47 | post("/login", params:) 48 | 49 | expect(URI.parse(response.location).path).to eq("/archive") 50 | end 51 | end 52 | 53 | describe "#destroy" do 54 | it "clears the session" do 55 | login_as(default_user) 56 | 57 | get "/logout" 58 | 59 | expect(session[:user_id]).to be_nil 60 | end 61 | 62 | it "redirects to the root page" do 63 | login_as(default_user) 64 | 65 | get "/logout" 66 | 67 | expect(URI.parse(response.location).path).to eq("/") 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/requests/settings_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe SettingsController do 4 | describe "#index" do 5 | it "displays the settings page" do 6 | login_as(create(:user, admin: true)) 7 | 8 | get(settings_path) 9 | 10 | expect(rendered).to have_css("h1", text: "Settings") 11 | .and have_text("User signups are disabled") 12 | end 13 | end 14 | 15 | describe "#update" do 16 | it "allows enabling account creation" do 17 | login_as(create(:user, admin: true)) 18 | 19 | params = { setting: { enabled: "true" } } 20 | put(setting_path(Setting::UserSignup.first), params:) 21 | 22 | expect(Setting::UserSignup.enabled?).to be(true) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/requests/tutorials_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe TutorialsController do 4 | describe "#index" do 5 | context "when a user has not been setup" do 6 | it "displays the tutorial and completes setup" do 7 | login_as(default_user) 8 | 9 | get "/setup/tutorial" 10 | 11 | expect(rendered).to have_css("#mark-all-instruction") 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/axe_core.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "axe-rspec" 4 | 5 | module AccessibilityOverrides 6 | A11Y_SKIP = [ 7 | "aria-required-children", 8 | "color-contrast", 9 | "landmark-one-main", 10 | "page-has-heading-one", 11 | "region" 12 | ].freeze 13 | 14 | def visit(*, accessible: true, a11y_skip: A11Y_SKIP) 15 | page.visit(*) 16 | 17 | yield if block_given? 18 | 19 | check_accessibility(a11y_skip:) if accessible 20 | end 21 | 22 | def click_on(*, a11y_skip: A11Y_SKIP, **) 23 | page.click_on(*, **) 24 | 25 | yield if block_given? 26 | 27 | check_accessibility(a11y_skip:) 28 | end 29 | 30 | def check_accessibility(a11y_skip:) 31 | within_window(current_window) do 32 | expect(page).to have_css("div") 33 | expect(page).to be_axe_clean.skipping(*a11y_skip) 34 | end 35 | end 36 | end 37 | 38 | RSpec.configure do |config| 39 | config.include(AccessibilityOverrides, type: :system) 40 | end 41 | -------------------------------------------------------------------------------- /spec/support/capybara.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "capybara/rails" 4 | 5 | Capybara.enable_aria_label = true 6 | 7 | Selenium::WebDriver.logger.output = Rails.root.join("log/selenium.log") 8 | 9 | RSpec.configure do |config| 10 | config.before(:each, type: :system) do 11 | driven_by(:selenium, using: :firefox) do |driver| 12 | driver.add_preference("browser.download.folderList", 2) 13 | driver.add_preference("browser.download.manager.showWhenStarting", false) 14 | driver.add_preference("browser.download.dir", Downloads::PATH.to_s) 15 | driver.add_preference( 16 | "browser.helperApps.neverAsk.saveToDisk", 17 | "application/xml" 18 | ) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/coverage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | return if ENV["COVERAGE"] == "false" 4 | 5 | require "simplecov" 6 | 7 | if ENV["CI"] 8 | require "coveralls" 9 | SimpleCov.formatter = Coveralls::SimpleCov::Formatter 10 | end 11 | 12 | SimpleCov.start(:rails) do 13 | add_group("Commands", "app/commands") 14 | add_group("Fever API", "app/fever_api") 15 | add_group("Repositories", "app/repositories") 16 | add_group("Tasks", "app/tasks") 17 | add_group("Utils", "app/utils") 18 | enable_coverage :branch 19 | end 20 | SimpleCov.minimum_coverage(line: 100, branch: 100) 21 | -------------------------------------------------------------------------------- /spec/support/downloads.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Downloads 4 | PATH = Rails.root.join("tmp/downloads") 5 | 6 | class << self 7 | def clear 8 | FileUtils.rm_f(downloads) 9 | end 10 | 11 | def content_for(page, filename) 12 | page.document.synchronize(errors: [Errno::ENOENT]) do 13 | File.read(PATH.join(filename)) 14 | end 15 | end 16 | 17 | private 18 | 19 | def downloads 20 | Dir[PATH.join("*")] 21 | end 22 | end 23 | end 24 | 25 | RSpec.configure do |config| 26 | config.after(:each, type: :system) { Downloads.clear } 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "factory_bot" 4 | FactoryBot.find_definitions 5 | 6 | module FactoryCache 7 | def self.user 8 | @user ||= FactoryBot.create(:user) 9 | end 10 | 11 | def self.reset 12 | @user = nil 13 | end 14 | end 15 | 16 | RSpec.configure do |config| 17 | config.include(FactoryBot::Syntax::Methods) 18 | 19 | config.after do 20 | FactoryBot.rewind_sequences 21 | FactoryCache.reset 22 | end 23 | end 24 | 25 | module FactoryBot::Syntax::Methods 26 | def default_user 27 | FactoryCache.user 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/support/feed_server.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FeedServer 4 | attr_writer :response 5 | 6 | def initialize 7 | @server = Capybara::Server.new(method(:response)).boot 8 | end 9 | 10 | def response(_env) 11 | [200, {}, [@response]] 12 | end 13 | 14 | def url 15 | "http://#{@server.host}:#{@server.port}" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/files/subscriptions.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | subscriptions title 5 | 6 | 7 | 9 | 12 | 13 | 16 | 17 | 18 | 23 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /spec/support/generate_xml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GenerateXml 4 | class << self 5 | def call(feed, items) 6 | build_feed(feed, items).to_xml 7 | end 8 | 9 | private 10 | 11 | def build_feed(feed, items) 12 | Nokogiri::XML::Builder.new do |xml| 13 | xml.rss(version: "2.0") do 14 | xml.title(feed.name) 15 | xml.link(feed.url) 16 | items.each { |item| build_item(xml, item) } 17 | end 18 | end 19 | end 20 | 21 | def build_item(xml, item) 22 | xml.item do 23 | xml.title(item.title) 24 | xml.link(item.url) 25 | xml.pubDate(item.published) 26 | xml.content(item.content) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/matchers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Matchers 4 | def change_all_records(records, attribute) 5 | Matchers::ChangeAllRecords.new(records, attribute) 6 | end 7 | 8 | def change_record(record, attribute) 9 | Matchers::ChangeRecord.new(record, attribute) 10 | end 11 | 12 | def delete_record(record) 13 | Matchers::DeleteRecord.new(record) 14 | end 15 | 16 | def invoke(expected_method) 17 | Matchers::Invoke.new(expected_method) 18 | end 19 | end 20 | 21 | RSpec.configure { |config| config.include(Matchers) } 22 | 23 | RSpec::Matchers.define_negated_matcher(:not_change, :change) 24 | RSpec::Matchers.define_negated_matcher(:not_change_record, :change_record) 25 | RSpec::Matchers.define_negated_matcher(:not_delete_record, :delete_record) 26 | RSpec::Matchers.define_negated_matcher(:not_raise_error, :raise_error) 27 | 28 | Dir[File.join(__dir__, "./matchers/*.rb")].each { |path| require path } 29 | -------------------------------------------------------------------------------- /spec/support/request_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RequestHelpers 4 | def login_as(user) 5 | post("/login", params: { username: user.username, password: user.password }) 6 | end 7 | 8 | def rendered 9 | Capybara.string(response.body) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/system_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SystemHelpers 4 | def login_as(user) 5 | visit(login_path) 6 | fill_in("Username", with: user.username) 7 | fill_in("Password", with: user.password) 8 | click_on("Login") 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/webmock.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "webmock/rspec" 4 | 5 | WebMock.disable_net_connect!( 6 | allow_localhost: true, 7 | allow: [/geckodriver/, /chromedriver/] 8 | ) 9 | -------------------------------------------------------------------------------- /spec/support/with_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class WithModel::Model 4 | # Workaround for https://github.com/Casecommons/with_model/issues/35 5 | def cleanup_descendants_tracking 6 | cache_classes = Rails.application.config.cache_classes 7 | if defined?(ActiveSupport::DescendantsTracker) && !cache_classes 8 | ActiveSupport::DescendantsTracker.clear([@model]) 9 | elsif @model.superclass.respond_to?(:direct_descendants) 10 | @model.superclass.subclasses.delete(@model) 11 | end 12 | end 13 | end 14 | 15 | RSpec.configure { |config| config.extend(WithModel) } 16 | -------------------------------------------------------------------------------- /spec/system/account_setup_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "account setup" do 4 | def fill_in_fields(username:) 5 | fill_in("Username", with: username) 6 | fill_in("Password", with: "my-password") 7 | fill_in("Confirm", with: "my-password") 8 | click_on("Next") 9 | end 10 | 11 | it "allows a user to sign up" do 12 | visit "/" 13 | 14 | fill_in_fields(username: "my-username") 15 | 16 | expect(page).to have_text("Logged in as my-username") 17 | end 18 | 19 | it "allows a second user to sign up" do 20 | Setting::UserSignup.create!(enabled: true) 21 | create(:user) 22 | 23 | visit "/" 24 | 25 | expect(page).to have_link("sign up") 26 | end 27 | 28 | it "does not allow a second user to signup when not enabled" do 29 | create(:user) 30 | 31 | visit "/" 32 | 33 | expect(page).to have_no_link("sign up") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/system/application_settings_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "application settings" do 4 | it "allows enabling account creation" do 5 | login_as(create(:user, admin: true)) 6 | visit(settings_path) 7 | 8 | within("form", text: "User signups are disabled") { click_on("Enable") } 9 | 10 | expect(page).to have_content("User signups are enabled") 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/system/export_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "exporting feeds" do 4 | it "allows exporting feeds" do 5 | login_as(default_user) 6 | feed = create(:feed, :with_group) 7 | 8 | click_on "Export" 9 | 10 | xml = Capybara.string(Downloads.content_for(page, "stringer.opml")) 11 | expect(xml).to have_css("outline[title='#{feed.name}']") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/system/feeds_index_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "feeds/index" do 4 | it "displays a list of feeds" do 5 | login_as(default_user) 6 | create_pair(:feed) 7 | 8 | visit "/feeds" 9 | 10 | expect(page).to have_css("li.feed", count: 2) 11 | end 12 | 13 | it "displays message to add feeds if there are none" do 14 | login_as(default_user) 15 | 16 | visit "/feeds" 17 | 18 | expect(page).to have_content("Hey, you should add some feeds") 19 | end 20 | 21 | it "allows the user to delete a feed" do 22 | login_as(default_user) 23 | create(:feed) 24 | 25 | visit("/feeds") 26 | click_on "Delete" 27 | 28 | expect(page).to have_content("Feed deleted") 29 | end 30 | 31 | it "allows the user to edit a feed" do 32 | login_as(default_user) 33 | feed = create(:feed) 34 | 35 | visit "/feeds" 36 | click_on "Edit" 37 | 38 | expect(page).to have_field("Feed Name", with: feed.name) 39 | end 40 | 41 | it "links to the feed" do 42 | login_as(default_user) 43 | feed = create(:feed) 44 | 45 | visit "/feeds" 46 | 47 | expect(page).to have_link(href: feed.url) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/system/good_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "admin/good_job" do 4 | it "displays the GoodJob dashboard" do 5 | login_as(create(:user, admin: true)) 6 | a11y_skip = [ 7 | "aria-required-children", 8 | "color-contrast", 9 | "landmark-unique", 10 | "landmark-one-main", 11 | "page-has-heading-one", 12 | "region" 13 | ] 14 | visit(good_job_path, a11y_skip:) 15 | 16 | expect(page).to have_link("Scheduled").and have_link("Queued") 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/system/import_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "importing feeds" do 4 | it "allows importing feeds" do 5 | login_as(default_user) 6 | visit(feeds_import_path) 7 | file_path = Rails.root.join("spec/fixtures/feeds.opml") 8 | 9 | attach_file("opml_file", file_path, visible: false) 10 | 11 | expect(page).to have_content("We're getting you some stories to read") 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/system/js_tests_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "JS tests" do 4 | it "passes the mocha tests" do 5 | login_as(default_user) 6 | 7 | visit "/test" 8 | 9 | expect(page).to have_content("failures: 0") 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/system/profile_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "profile page" do 4 | before do 5 | login_as(default_user) 6 | visit(edit_profile_path) 7 | end 8 | 9 | it "allows the user to edit their username" do 10 | fill_in_username_fields(default_user.password) 11 | click_on("Update username") 12 | 13 | expect(page).to have_text("Logged in as new_username") 14 | end 15 | 16 | def fill_in_username_fields(existing_password) 17 | within_fieldset("Change Username") do 18 | fill_in("Username", with: "new_username") 19 | fill_in("Existing password", with: existing_password) 20 | end 21 | end 22 | 23 | it "allows the user to edit their password" do 24 | fill_in_password_fields(default_user.password, "new_password") 25 | click_on("Update password") 26 | 27 | expect(page).to have_text("Password updated") 28 | end 29 | 30 | def fill_in_password_fields(existing_password, new_password) 31 | within_fieldset("Change Password") do 32 | fill_in("Existing password", with: existing_password) 33 | fill_in("New password", with: new_password) 34 | fill_in("Password confirmation", with: new_password) 35 | end 36 | end 37 | 38 | it "allows the user to edit their feed order" do 39 | select("Oldest first", from: "Stories feed order") 40 | click_on("Update") 41 | 42 | expect(default_user.reload).to be_stories_order_asc 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/tasks/remove_old_stories_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RemoveOldStories do 4 | def create_stories 5 | stories = double("stories") 6 | allow(stories).to receive(:delete_all) 7 | stories 8 | end 9 | 10 | it "passes along the number of days to the story repository query" do 11 | allow(described_class).to receive(:pruned_feeds).and_return([]) 12 | 13 | expect(StoryRepository).to receive(:unstarred_read_stories_older_than) 14 | .with(7).and_return(create_stories) 15 | 16 | described_class.call(7) 17 | end 18 | 19 | it "requests deletion of all old stories" do 20 | allow(described_class).to receive(:pruned_feeds).and_return([]) 21 | mocked_stories = create_stories 22 | allow(StoryRepository) 23 | .to receive(:unstarred_read_stories_older_than) { mocked_stories } 24 | 25 | expect(mocked_stories).to receive(:delete_all) 26 | 27 | described_class.call(11) 28 | end 29 | 30 | it "fetches affected feeds by id" do 31 | stories = create_list(:story, 3, :read, created_at: 1.month.ago) 32 | 33 | described_class.call(13) 34 | 35 | expect(Story.where(id: stories.map(&:id))).to be_empty 36 | end 37 | 38 | it "updates last_fetched on affected feeds" do 39 | feeds = [double("feed a"), double("feed b")] 40 | allow(described_class).to receive(:pruned_feeds) { feeds } 41 | allow(described_class).to receive(:old_stories) { create_stories } 42 | 43 | expect(FeedRepository) 44 | .to receive(:update_last_fetched).with(feeds.first, anything) 45 | expect(FeedRepository) 46 | .to receive(:update_last_fetched).with(feeds.last, anything) 47 | 48 | described_class.call(13) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/utils/content_sanitizer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ContentSanitizer do 4 | it "handles tag properly" do 5 | result = described_class.call("WM_ERROR asdf") 6 | 7 | expect(result).to eq("WM_ERROR asdf") 8 | end 9 | 10 | it "handles
    tag properly" do 11 | result = described_class.call("
    some code
    ") 12 | 13 | expect(result).to eq("
    some code
    ") 14 | end 15 | 16 | it "handles unprintable characters" do 17 | result = described_class.call("n\u2028\u2029") 18 | 19 | expect(result).to eq("n") 20 | end 21 | 22 | it "preserves line endings" do 23 | result = described_class.call("test\r\ncase") 24 | 25 | expect(result).to eq("test\r\ncase") 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/utils/i18n_support_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe "i18n", type: :request do 4 | it "loads default locale when no locale was set" do 5 | allow(UserRepository).to receive(:setup_complete?).and_return(false) 6 | ENV["LOCALE"] = nil 7 | get "/" 8 | 9 | expect(I18n.locale.to_s).to eq("en") 10 | expect(I18n.locale.to_s).not_to be_nil 11 | end 12 | 13 | it "loads default locale was locale was set" do 14 | allow(UserRepository).to receive(:setup_complete?).and_return(false) 15 | ENV["LOCALE"] = "en" 16 | get "/" 17 | 18 | expect(I18n.locale.to_s).to eq("en") 19 | expect(I18n.t("layout.title")).to eq("stringer | your rss buddy") 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/tmp/.keep -------------------------------------------------------------------------------- /tmp/pids/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/tmp/pids/.keep -------------------------------------------------------------------------------- /vendor/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stringer-rss/stringer/8f2f0a85ec9483aba6c2aad28365d0486f8f405c/vendor/.keep -------------------------------------------------------------------------------- /vendor/assets/javascripts/jquery-visible-min.js: -------------------------------------------------------------------------------- 1 | /*! jQuery visible 1.0.0 teamdf.com/jquery-plugins | teamdf.com/jquery-plugins/license */ 2 | (function(c){c.fn.visible=function(e){var a=c(this),b=c(window),f=b.scrollTop();b=f+b.height();var d=a.offset().top;a=d+a.height();var g=e===true?a:d;return(e===true?d:a)<=b&&g>=f}})(jQuery); --------------------------------------------------------------------------------