├── .ruby-version ├── public ├── cache │ └── .gitkeep ├── apple-touch-icon.png ├── apple-touch-icon-144.png ├── .well-known │ └── security.txt ├── ads.txt ├── favicon.ico ├── touch-icon.png ├── touch-icon-144.png ├── touch-icon-192.png ├── 422.html ├── 504.html ├── 500.html ├── 502.html └── opensearch.xml ├── vendor ├── javascript │ └── .keep └── assets │ └── stylesheets │ └── .gitkeep ├── .rspec ├── hatchbox ├── transport ├── virtual_aliases ├── 20auto-upgrades ├── Caddyfile.post ├── opendkim.m4 ├── post-deploy ├── root-deploy.path ├── logrotate.conf ├── aliases ├── root-deploy.service ├── opendkim.service ├── bash_aliases └── Caddyfile ├── .git-blame-ignore-revs ├── .github ├── workflows │ └── check │ │ ├── master.key │ │ └── credentials.yml.enc ├── pull_request_template.md └── ISSUE_TEMPLATE │ └── bug-or-feature-request.md ├── Procfile.dev ├── app ├── helpers │ ├── suggestions_helper.rb │ ├── cabinet_helper.rb │ └── stories_helper.rb ├── views │ ├── tags │ │ ├── new.html.erb │ │ ├── edit.html.erb │ │ └── _multi_tag_tip.html.erb │ ├── categories │ │ ├── edit.html.erb │ │ ├── new.html.erb │ │ ├── _multi_category_tip.html.erb │ │ └── _form.html.erb │ ├── users │ │ ├── _dev_flag_warning.html.erb │ │ ├── not_found.html.erb │ │ ├── list.html.erb │ │ └── _flag_warning.html.erb │ ├── home │ │ ├── _active.html.erb │ │ ├── _recent.html.erb │ │ ├── _newest_by_user.html.erb │ │ ├── _multi_tag.html.erb │ │ ├── _top.html.erb │ │ ├── _category.html.erb │ │ ├── _for_origin.html.erb │ │ ├── _single_tag.html.erb │ │ └── _for_domain.html.erb │ ├── email_message_mailer │ │ └── notify.text.erb │ ├── stories │ │ ├── _subnav.html.erb │ │ ├── _missing.html.erb │ │ └── new.html.erb │ ├── about │ │ ├── 404.html.erb │ │ ├── _subnav.html.erb │ │ └── privacy.html.erb │ ├── email_reply_mailer │ │ ├── mention.text.erb │ │ └── reply.text.erb │ ├── comments │ │ ├── _preview.html.erb │ │ ├── user_threads.html.erb │ │ ├── _postedreply.html.erb │ │ ├── _too_deep.html.erb │ │ └── index.rss.builder │ ├── ban_notification_mailer │ │ └── notify.text.erb │ ├── mod │ │ ├── index.html.erb │ │ ├── flagged_stories.html.erb │ │ ├── flagged_comments.html.erb │ │ ├── _subnav.html.erb │ │ └── reparents │ │ │ └── new.html.erb │ ├── saved │ │ └── _subnav.html.erb │ ├── helpers │ │ └── _link_post.html.erb │ ├── signup │ │ ├── invite.html.erb │ │ └── index.html.erb │ ├── password_reset_mailer │ │ └── password_reset_link.text.erb │ ├── invitation_mailer │ │ └── invitation.text.erb │ ├── mod_notes │ │ ├── index.html.erb │ │ └── _table.html.erb │ ├── login │ │ ├── forgot_password.html.erb │ │ ├── twofa.html.erb │ │ └── set_new_password.html.erb │ ├── hats │ │ ├── edit.html.erb │ │ └── doff.html.erb │ ├── origins │ │ └── for_domain.html.erb │ ├── suggestions │ │ └── new.html.erb │ ├── invitation_request_mailer │ │ └── invitation_request.text.erb │ ├── messages │ │ └── _subnav.html.erb │ ├── notifications │ │ └── _message.html.erb │ └── settings │ │ ├── twofa_verify.html.erb │ │ ├── mastodon_authentication.html.erb │ │ ├── twofa.html.erb │ │ └── twofa_enroll.html.erb ├── assets │ ├── images │ │ ├── select2.png │ │ └── merge.svg │ └── stylesheets │ │ ├── dark-system.css │ │ ├── light-system.css │ │ ├── system-high.css │ │ ├── system-normal.css │ │ └── system-system.css ├── controllers │ ├── cabinet_controller.rb │ ├── mod │ │ └── moderator_controller.rb │ ├── stats_controller.rb │ ├── jobs_mod_controller.rb │ ├── inbox_controller.rb │ ├── story_urls_controller.rb │ ├── concerns │ │ └── story_finder.rb │ ├── csp_controller.rb │ └── domains_ban_controller.rb ├── models │ ├── tag_filter.rb │ ├── suggested_tagging.rb │ ├── tagging.rb │ ├── suggested_title.rb │ ├── application_record.rb │ ├── saved_story.rb │ ├── concerns │ │ └── token.rb │ ├── story_repository.rb │ ├── hidden_story.rb │ ├── replying_comment.rb │ ├── inactive_user.rb │ ├── invitation.rb │ ├── short_id.rb │ ├── story_text.rb │ └── category.rb ├── mailboxes │ ├── backstop_mailbox.rb │ └── application_mailbox.rb ├── mailers │ ├── application_mailer.rb │ ├── password_reset_mailer.rb │ ├── invitation_mailer.rb │ ├── email_message_mailer.rb │ ├── invitation_request_mailer.rb │ └── ban_notification_mailer.rb └── jobs │ ├── restic_job.rb │ ├── application_job.rb │ └── stats_graphs_job.rb ├── docs ├── docker_ps.jpg ├── vim_result.jpg ├── server_output.jpg ├── credentials_error.jpg ├── credentials_error.png └── foreign_key_error.jpg ├── test └── run_specs_test.rb ├── bin ├── rake ├── importmap ├── jobs ├── rails ├── dev ├── faker ├── puma ├── racc ├── rotp ├── thor ├── byebug ├── ldiff ├── listen ├── oauth ├── pumactl ├── rackup ├── rspec ├── ascii85 ├── rubocop ├── htmldiff ├── nokogiri ├── pdf_text ├── pdf_object ├── ruby-parse ├── stackprof ├── standardrb ├── ruby-rewrite ├── commonmarker ├── pdf_callbacks ├── ruby-memory-profiler ├── stackprof-gprof2dot.py ├── stackprof-flamegraph.pl ├── safe_yaml └── update ├── script ├── expire_page_cache ├── lobsters-ingress └── lobsters-cron ├── spec ├── factories │ ├── hidden_story.rb │ ├── notification.rb │ ├── vote.rb │ ├── category.rb │ ├── origin.rb │ ├── invitation.rb │ ├── comment.rb │ ├── tag.rb │ ├── invitation_request.rb │ ├── hat.rb │ ├── hat_request.rb │ ├── message.rb │ ├── story.rb │ └── domain.rb ├── fixtures │ ├── story_pages │ │ └── titled.pdf │ └── inbound_emails │ │ ├── 1.eml │ │ ├── 3.eml │ │ └── 4.eml ├── support │ ├── factory_bot.rb │ ├── vcr.rb │ └── authentication_helper.rb ├── slow │ ├── README.md │ └── fake_data_spec.rb ├── mailers │ └── previews │ │ └── email_reply_preview.rb ├── models │ ├── comment_stat_spec.rb │ ├── story_text_spec.rb │ ├── hat_request_spec.rb │ ├── hat_spec.rb │ ├── mastodon_app_spec.rb │ └── invitation_spec.rb ├── requests │ ├── cabinet_spec.rb │ ├── signup_spec.rb │ ├── authenticatable_spec.rb │ └── mod │ │ └── reparents_spec.rb ├── extras │ ├── html_encoder_spec.rb │ └── routes_spec.rb ├── routing │ ├── origin_spec.rb │ └── domain_spec.rb ├── helpers │ ├── cabinet_helper_spec.rb │ └── interval_helper_spec.rb ├── controllers │ ├── messages_controller_spec.rb │ └── comments_controller_spec.rb ├── jobs │ └── notify_message_job_spec.rb └── features │ ├── doff_hat_spec.rb │ └── hats_spec.rb ├── config ├── initializers │ ├── mini_profiler.rb │ ├── console_methods.rb │ ├── query_log_tags.rb │ ├── mime_types.rb │ ├── journaldutrader.io.rb │ ├── cookies_serializer.rb │ ├── application_controller_renderer.rb │ ├── email.rb │ ├── backtrace_silencers.rb │ ├── session_store.rb │ ├── assets.rb │ ├── wrap_parameters.rb │ ├── inflections.rb │ ├── scenic.rb │ ├── 00_zeitwerk.rb │ └── filter_parameter_logging.rb ├── boot.rb ├── spring.rb ├── environment.rb ├── storage.yml ├── cable.yml ├── cache.yml ├── queue.yml ├── importmap.rb ├── credentials.yml.enc.sample └── locales │ └── en.yml ├── db └── migrate │ ├── 20140121063641_pushover_sound.rb │ ├── 20170607153224_add_stories_user_index.rb │ ├── 20140221164400_inactive_tags.rb │ ├── 20150224035617_hotness_mod_to_float.rb │ ├── 20170522144135_remove_comments_dragon.rb │ ├── 20140113153413_add_account_deletion.rb │ ├── 20170119192703_add_dragons.rb │ ├── 20180503150810_add_hat_to_message.rb │ ├── 20140825225915_add_tag_hotness_mod.rb │ ├── 20150106195555_add_story_unavailable.rb │ ├── 20230923195457_index_story_titles.rb │ ├── 20120701181319_invitation_memo.rb │ ├── 20140701153554_add_user_weblog_url.rb │ ├── 20140901013149_drop_weblogs.rb │ ├── 20200811022248_unique_category_names.rb │ ├── 20140106205200_remove_tag_filtered_by_default.rb │ ├── 20150313040930_add_user_avatar_pref.rb │ ├── 20171018034551_drop_default_tag_name.rb │ ├── 20220207033514_stories_expired_to_deleted.rb │ ├── 20120816203248_keystore_bigint.rb │ ├── 20150730215915_add_submitter_is_author.rb │ ├── 20180411131217_tag_moderation_log.rb │ ├── 20151015143959_moderations_from_group.rb │ ├── 20191010172004_add_index_for_moderations_associated_model.rb │ ├── 20200210155624_limit_new_user_tags.rb │ ├── 20240813153242_add_hidden_story_created_at.rb │ ├── 20150730225352_add_user_setting_show_preview.rb │ ├── 20140408160306_add_story_merging.rb │ ├── 20231023155620_add_user_setting_show_email.rb │ ├── 20120906183346_default_filtered_tags.rb │ ├── 20150127180326_add_story_twitter_id.rb │ ├── 20171025072230_modlog_hat_use.rb │ ├── 20200820170829_add_category_id_to_moderations.rb │ ├── 20240815145726_add_last_read_line_to_newest.rb │ ├── 20131228175805_add_comment_by_email_flag.rb │ ├── 20170413161450_add_indexes.rb │ ├── 20180131170318_update_replying_comments_to_version_2.rb │ ├── 20180131203555_fix_doffed_at.rb │ ├── 20180201024840_update_replying_comments_to_version_3.rb │ ├── 20180201041055_update_replying_comments_to_version_4.rb │ ├── 20180201172618_update_replying_comments_to_version_5.rb │ ├── 20180201184612_update_replying_comments_to_version_6.rb │ ├── 20180830114325_update_replying_comments_to_version_7.rb │ ├── 20190529133507_update_replying_comments_to_version_8.rb │ ├── 20240812221847_update_replying_comments_for_hiding.rb │ ├── 20151207180050_add_user_setting_post_threads.rb │ ├── 20180705143850_add_author_downvote_index.rb │ ├── 20120918152116_private_rss_feed.rb │ ├── 20121004153529_add_story_text.rb │ ├── 20200519000845_index_users_on_email.rb │ ├── 20180124143340_add_doffed_at_to_hat.rb │ ├── 20240608224626_add_indexes_on_foreign_keys.rb │ ├── 20140112192936_add_ban_reason.rb │ ├── 20140219183804_change_mailing_list_enabled.rb │ ├── 20250605141223_remove_keybase.rb │ ├── 20151015005101_add_suggested_taggings.rb │ ├── 20240207151341_add_story_mastodon_id.rb │ ├── 20120704025956_user_filter.rb │ ├── 20160704022756_change_story_columns.rb │ ├── 20250516035743_expand_ban_reason.rb │ ├── 20120712174445_add_comment_deleted.rb │ ├── 20151015011231_add_suggested_titles.rb │ ├── 20240315143804_coalesce_vote_reason.rb │ ├── 20250302102016_add_edit_last_read_newest.rb │ ├── 20191227232955_create_domains.rb │ ├── 20160406160019_add_hat_requests.rb │ ├── 20241016194903_add_index_banned_by_user_id_foreign_key_in_origins.rb │ ├── 20120706221602_more_indexes.rb │ ├── 20181202194809_add_follow_story_for_submitter.rb │ ├── 20170713195446_add_saved_stories.rb │ ├── 20250206172739_fix_prefers_contrast.rb │ ├── 20250630123615_moderation_enlarge_action.rb │ ├── 20160515162433_add_disabled_invites_to_users.rb │ ├── 20141114184921_add_hats.rb │ ├── 20120704004020_fix_up_messages.rb │ ├── 20170607170604_add_fulltext_indicies.rb │ ├── 20180501031849_add_used_at_to_invitation.rb │ ├── 20200607192351_story_index_for_comments.rb │ ├── 20121112165212_add_tag_media_types.rb │ ├── 20250210214901_fix_last_x_at_timestamps.rb │ ├── 20240822174547_add_short_id_hats.rb │ ├── 20120701160006_reply_notifications.rb │ ├── 20120919195401_private_tags.rb │ ├── 20130526164230_change_tables_to_utf8mb4.rb │ ├── 20230424222719_create_mastodon_apps.rb │ ├── 20120910172514_mention_notification_options.rb │ ├── 20250203223624_count_merged_stories.rb │ ├── 20120704013019_pm_notification_options.rb │ ├── 20240208024935_remove_twitter.rb │ ├── 20130622021035_add_user_list_id.rb │ ├── 20180130235553_add_read_notification_support.rb │ ├── 20220806200248_set_is_tracker_as_banned_to_domains.rb │ ├── 20150115172138_drop_extra_pushover_fields.rb │ ├── 20180711123439_create_mod_notes.rb │ ├── 20191029021735_fix_database_consistency_with_validators.rb │ ├── 20200210125425_add_domain_banning.rb │ ├── 20131018201413_add_invitation_requests.rb │ ├── 20220331165136_message_author_nullable.rb │ ├── 20200828015742_fix_comment_flags_default.rb │ ├── 20120902143549_add_moderation_log.rb │ ├── 20240717155412_hydrate_links.rb │ ├── 20170225201811_delete_old_settings.rb │ ├── 20240907171330_fix_links_in_invite_notes.rb │ ├── 20150211170052_move_hidden_votes_to_hidden_story.rb │ ├── 20240717144412_create_links.rb │ ├── 20180506045709_timestamp_votes.rb │ ├── 20250428193204_typeid_fix_token_name.rb │ ├── 20241104215430_add_comment_stats.rb │ ├── 20230823150648_materialize_confidence_order.rb │ ├── 20120701154453_create_invitations.rb │ ├── 20120703184957_integrated_story_hotness.rb │ ├── 20140101202252_add_karma_to_users.rb │ ├── 20120705145520_move_markdown_into_sql.rb │ ├── 20140109034338_move_comment_counts_to_story.rb │ ├── 20140804005415_add_weblogs.rb │ ├── 20241106160424_tidy_duplicate_origins.rb │ ├── 20250628172500_create_notifications.rb │ ├── 20240920115957_create_action_mailbox_tables.action_mailbox.rb │ ├── 20250124063340_add_last_comment_at_to_stories.rb │ ├── 20250424165830_add_typeid.rb │ ├── 20220128133226_fix_story_mod_notes.rb │ ├── 20240909220644_recalculate_comment_confidences.rb │ └── 20200812024241_create_story_texts.rb ├── config.ru ├── Makefile ├── Rakefile ├── extras ├── html_encoder.rb └── pushover.rb ├── .custom_cops.yml ├── SECURITY.md ├── docker-compose.yaml ├── lib ├── time_series.rb ├── tasks │ └── privacy_wipe.rake └── time_ago_in_words.rb └── Dockerfile.dev /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.3 2 | -------------------------------------------------------------------------------- /public/cache/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/javascript/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | touch-icon.png -------------------------------------------------------------------------------- /hatchbox/transport: -------------------------------------------------------------------------------- 1 | lobste.rs lobsters-ingress: 2 | -------------------------------------------------------------------------------- /public/apple-touch-icon-144.png: -------------------------------------------------------------------------------- 1 | touch-icon-144.png -------------------------------------------------------------------------------- /hatchbox/virtual_aliases: -------------------------------------------------------------------------------- 1 | @lobste.rs deploy@lobste.rs 2 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | c3f8625788245129903f31622bb452957223fc44 2 | -------------------------------------------------------------------------------- /.github/workflows/check/master.key: -------------------------------------------------------------------------------- 1 | 2649985e7283928a3b8a5c230e327bc6 -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bundle exec rails server 2 | job: bin/jobs 3 | -------------------------------------------------------------------------------- /public/.well-known/security.txt: -------------------------------------------------------------------------------- 1 | Contact: mailto:peter@push.cx 2 | -------------------------------------------------------------------------------- /app/helpers/suggestions_helper.rb: -------------------------------------------------------------------------------- 1 | module SuggestionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/views/tags/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'form', locals: { tag: @tag } %> 2 | -------------------------------------------------------------------------------- /docs/docker_ps.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/docs/docker_ps.jpg -------------------------------------------------------------------------------- /docs/vim_result.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/docs/vim_result.jpg -------------------------------------------------------------------------------- /public/ads.txt: -------------------------------------------------------------------------------- 1 | We don't have ads. 2 | 3 | I created this file to stop getting 404s for it. 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/public/favicon.ico -------------------------------------------------------------------------------- /app/views/categories/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'form', locals: { category: @category } %> 2 | -------------------------------------------------------------------------------- /app/views/categories/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'form', locals: { category: @category } %> 2 | -------------------------------------------------------------------------------- /docs/server_output.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/docs/server_output.jpg -------------------------------------------------------------------------------- /public/touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/public/touch-icon.png -------------------------------------------------------------------------------- /public/touch-icon-144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/public/touch-icon-144.png -------------------------------------------------------------------------------- /public/touch-icon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/public/touch-icon-192.png -------------------------------------------------------------------------------- /docs/credentials_error.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/docs/credentials_error.jpg -------------------------------------------------------------------------------- /docs/credentials_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/docs/credentials_error.png -------------------------------------------------------------------------------- /docs/foreign_key_error.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/docs/foreign_key_error.jpg -------------------------------------------------------------------------------- /hatchbox/20auto-upgrades: -------------------------------------------------------------------------------- 1 | APT::Periodic::Update-Package-Lists "1"; 2 | APT::Periodic::Unattended-Upgrade "1"; 3 | -------------------------------------------------------------------------------- /test/run_specs_test.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rspec/core" 4 | RSpec::Core::Runner.run(["spec"]) 5 | -------------------------------------------------------------------------------- /app/assets/images/select2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/app/assets/images/select2.png -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /bin/importmap: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/application" 4 | require "importmap/commands" 5 | -------------------------------------------------------------------------------- /hatchbox/Caddyfile.post: -------------------------------------------------------------------------------- 1 | log disk-log { 2 | output file /home/deploy/lobsters/shared/log/caddy.log 3 | format json 4 | } 5 | -------------------------------------------------------------------------------- /script/expire_page_cache: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | find /home/deploy/lobsters/current/public/cache/ -type f -not -mmin 2 -delete 4 | -------------------------------------------------------------------------------- /spec/factories/hidden_story.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :hidden_story do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/factories/notification.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :notification do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/fixtures/story_pages/titled.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twodayslate/lobsters/master/spec/fixtures/story_pages/titled.pdf -------------------------------------------------------------------------------- /app/controllers/cabinet_controller.rb: -------------------------------------------------------------------------------- 1 | class CabinetController < ApplicationController 2 | def index 3 | render 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/tag_filter.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class TagFilter < ApplicationRecord 4 | belongs_to :tag 5 | belongs_to :user 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | RSpec.configure do |config| 4 | config.include FactoryBot::Syntax::Methods 5 | end 6 | -------------------------------------------------------------------------------- /hatchbox/opendkim.m4: -------------------------------------------------------------------------------- 1 | INPUT_MAIL_FILTER(`opendkim', 2 | `S=local:/var/spool/postfix/opendkim/opendkim.socket, F=, T=S:4m;R:4m;E:10m')dnl 3 | -------------------------------------------------------------------------------- /bin/jobs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require_relative "../config/environment" 4 | require "solid_queue/cli" 5 | 6 | SolidQueue::Cli.start(ARGV) 7 | -------------------------------------------------------------------------------- /app/mailboxes/backstop_mailbox.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class BackstopMailbox < ApplicationMailbox 4 | def process 5 | bounced! 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/factories/vote.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :vote do 5 | association(:user) 6 | vote { 1 } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/slow/README.md: -------------------------------------------------------------------------------- 1 | Tests in this directory do not run as part of the regular test suite with 2 | `bundle exec rspec`. They are excluded by config.exclude 3 | -------------------------------------------------------------------------------- /app/controllers/mod/moderator_controller.rb: -------------------------------------------------------------------------------- 1 | class Mod::ModeratorController < ApplicationController 2 | before_action :require_logged_in_moderator 3 | end 4 | -------------------------------------------------------------------------------- /app/views/tags/edit.html.erb: -------------------------------------------------------------------------------- 1 |

<%= link_to "Filter By Tag #{@tag.tag}", tag_path(@tag) %>

2 | 3 | <%= render partial: 'form', locals: { tag: @tag } %> 4 | -------------------------------------------------------------------------------- /app/views/users/_dev_flag_warning.html.erb: -------------------------------------------------------------------------------- 1 |
2 | Skipped slow queries for flag warning in mode <%= Rails.env %>. 3 |
4 | -------------------------------------------------------------------------------- /app/controllers/stats_controller.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class StatsController < ApplicationController 4 | def index 5 | @title = "Stats" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/tags/_multi_tag_tip.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 |

3 | Tip: read stories across multiple tags with /t/tag1,tag2 4 |

5 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /app/views/home/_active.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 | <% render partial: 'stories/subnav' %> 3 | 4 |

5 | Stories with active discussions. 6 |

7 | -------------------------------------------------------------------------------- /config/initializers/mini_profiler.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | Rack::MiniProfiler.config.disable_caching = false 4 | Rack::MiniProfiler.config.position = "bottom-left" 5 | -------------------------------------------------------------------------------- /spec/factories/category.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :category do 5 | sequence(:category) { |n| "category-#{n}" } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/home/_recent.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 |

3 | The newest stories that have not yet reached the front page. 4 |

5 | 6 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 4 | 5 | require "bundler/setup" # Set up gems listed in the Gemfile. 6 | -------------------------------------------------------------------------------- /config/spring.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | %w[ 4 | .ruby-version 5 | .rbenv-vars 6 | tmp/restart.txt 7 | tmp/caching-dev.txt 8 | ].each { |path| Spring.watch(path) } 9 | -------------------------------------------------------------------------------- /spec/support/vcr.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | VCR.configure do |config| 4 | config.cassette_library_dir = "fixtures/vcr_cassettes" 5 | config.hook_into :webmock 6 | end 7 | -------------------------------------------------------------------------------- /app/models/suggested_tagging.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class SuggestedTagging < ApplicationRecord 4 | belongs_to :story 5 | belongs_to :tag 6 | belongs_to :user 7 | end 8 | -------------------------------------------------------------------------------- /app/models/tagging.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class Tagging < ApplicationRecord 4 | belongs_to :tag, inverse_of: :taggings 5 | belongs_to :story, inverse_of: :taggings 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20140121063641_pushover_sound.rb: -------------------------------------------------------------------------------- 1 | class PushoverSound < ActiveRecord::Migration 2 | def change 3 | add_column :users, :pushover_sound, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20170607153224_add_stories_user_index.rb: -------------------------------------------------------------------------------- 1 | class AddStoriesUserIndex < ActiveRecord::Migration 2 | def change 3 | add_index "stories", ["user_id"] 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/email_message_mailer/notify.text.erb: -------------------------------------------------------------------------------- 1 | <%= word_wrap(@message.plaintext_body, :line_width => 72).gsub(/\n/, "\n ") %> 2 | 3 | Reply at <%= Routes.message_url @message %> 4 | -------------------------------------------------------------------------------- /db/migrate/20140221164400_inactive_tags.rb: -------------------------------------------------------------------------------- 1 | class InactiveTags < ActiveRecord::Migration 2 | def change 3 | add_column :tags, :inactive, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150224035617_hotness_mod_to_float.rb: -------------------------------------------------------------------------------- 1 | class HotnessModToFloat < ActiveRecord::Migration 2 | def change 3 | change_column :tags, :hotness_mod, :float 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20170522144135_remove_comments_dragon.rb: -------------------------------------------------------------------------------- 1 | class RemoveCommentsDragon < ActiveRecord::Migration 2 | def change 3 | remove_column :comments, :is_dragon 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /hatchbox/post-deploy: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | find /home/deploy/lobsters/current/public/cache/ -type f -delete 4 | 5 | # see hatchbox/root-deploy 6 | touch /home/deploy/lobsters/current 7 | -------------------------------------------------------------------------------- /app/views/categories/_multi_category_tip.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 |

3 | Tip: read stories across multiple categories with /categories/foo,bar 4 |

5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # Load the Rails application. 4 | require_relative "application" 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | local: 2 | service: Disk 3 | root: <%= Rails.root.join("storage") %> 4 | 5 | production: 6 | service: Disk 7 | root: /home/deploy/lobsters/shared/shared/storage 8 | -------------------------------------------------------------------------------- /db/migrate/20140113153413_add_account_deletion.rb: -------------------------------------------------------------------------------- 1 | class AddAccountDeletion < ActiveRecord::Migration 2 | def change 3 | add_column :users, :deleted_at, :datetime 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20170119192703_add_dragons.rb: -------------------------------------------------------------------------------- 1 | class AddDragons < ActiveRecord::Migration 2 | def change 3 | add_column :comments, :is_dragon, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180503150810_add_hat_to_message.rb: -------------------------------------------------------------------------------- 1 | class AddHatToMessage < ActiveRecord::Migration[5.1] 2 | def change 3 | add_reference :messages, :hat, index: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/dark-system.css: -------------------------------------------------------------------------------- 1 | /* dark color scheme (system contrast) */ 2 | 3 | @import url("dark-normal.css"); /* default */ 4 | @import url("dark-high.css") (prefers-contrast: more); 5 | -------------------------------------------------------------------------------- /db/migrate/20140825225915_add_tag_hotness_mod.rb: -------------------------------------------------------------------------------- 1 | class AddTagHotnessMod < ActiveRecord::Migration 2 | def change 3 | add_column :tags, :hotness_mod, :integer, default: 0 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150106195555_add_story_unavailable.rb: -------------------------------------------------------------------------------- 1 | class AddStoryUnavailable < ActiveRecord::Migration 2 | def change 3 | add_column :stories, :unavailable_at, :datetime 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20230923195457_index_story_titles.rb: -------------------------------------------------------------------------------- 1 | class IndexStoryTitles < ActiveRecord::Migration[7.0] 2 | def change 3 | add_index :story_texts, [:title], type: :fulltext 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/assets/stylesheets/light-system.css: -------------------------------------------------------------------------------- 1 | /* light color scheme (system contrast) */ 2 | 3 | @import url("light-normal.css"); /* default */ 4 | @import url("light-high.css") (prefers-contrast: more); 5 | -------------------------------------------------------------------------------- /app/assets/stylesheets/system-high.css: -------------------------------------------------------------------------------- 1 | /* system color scheme (high contrast) */ 2 | 3 | @import url("light-high.css"); /* default */ 4 | @import url("dark-high.css") (prefers-color-scheme: dark); 5 | -------------------------------------------------------------------------------- /app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class ApplicationMailer < ActionMailer::Base 4 | default from: "#{Rails.application.name} " 5 | end 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require ::File.expand_path("../config/environment", __FILE__) 6 | run Rails.application 7 | -------------------------------------------------------------------------------- /db/migrate/20120701181319_invitation_memo.rb: -------------------------------------------------------------------------------- 1 | class InvitationMemo < ActiveRecord::Migration 2 | def up 3 | add_column :invitations, :memo, :text 4 | end 5 | 6 | def down 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20140701153554_add_user_weblog_url.rb: -------------------------------------------------------------------------------- 1 | class AddUserWeblogUrl < ActiveRecord::Migration 2 | def change 3 | add_column :users, :weblog_feed_url, :string, length: 500 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20140901013149_drop_weblogs.rb: -------------------------------------------------------------------------------- 1 | class DropWeblogs < ActiveRecord::Migration 2 | def change 3 | drop_table :weblogs 4 | remove_column :users, :weblog_feed_url 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20200811022248_unique_category_names.rb: -------------------------------------------------------------------------------- 1 | class UniqueCategoryNames < ActiveRecord::Migration[5.2] 2 | def change 3 | add_index :categories, :category, unique: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/console_methods.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | module Rails 4 | module ConsoleMethods 5 | def admin 6 | User.find_by! username: "pushcx" 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20140106205200_remove_tag_filtered_by_default.rb: -------------------------------------------------------------------------------- 1 | class RemoveTagFilteredByDefault < ActiveRecord::Migration 2 | def change 3 | remove_column :tags, :filtered_by_default 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150313040930_add_user_avatar_pref.rb: -------------------------------------------------------------------------------- 1 | class AddUserAvatarPref < ActiveRecord::Migration 2 | def change 3 | add_column :users, :show_avatars, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20171018034551_drop_default_tag_name.rb: -------------------------------------------------------------------------------- 1 | class DropDefaultTagName < ActiveRecord::Migration[5.1] 2 | def change 3 | change_column_default(:tags, :tag, from: "", to: nil) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/mailers/previews/email_reply_preview.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # Preview all emails at http://localhost:3000/rails/mailers/email_reply 4 | class EmailReplyPreview < ActionMailer::Preview 5 | end 6 | -------------------------------------------------------------------------------- /spec/models/comment_stat_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe CommentStat, type: :model do 6 | pending "add some examples to (or delete) #{__FILE__}" 7 | end 8 | -------------------------------------------------------------------------------- /app/assets/stylesheets/system-normal.css: -------------------------------------------------------------------------------- 1 | /* system color scheme (normal contrast) */ 2 | 3 | @import url("light-normal.css"); /* default */ 4 | @import url("dark-normal.css") (prefers-color-scheme: dark); 5 | -------------------------------------------------------------------------------- /db/migrate/20220207033514_stories_expired_to_deleted.rb: -------------------------------------------------------------------------------- 1 | class StoriesExpiredToDeleted < ActiveRecord::Migration[6.1] 2 | def change 3 | rename_column :stories, :is_expired, :is_deleted 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/stories/_subnav.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 | <% content_for :subnav do %> 3 | <%= link_to_different_page 'Newest', newest_path %> 4 | <%= link_to_different_page 'Top', top_path %> 5 | <% end %> 6 | -------------------------------------------------------------------------------- /config/initializers/query_log_tags.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # documentation: https://github.com/rails/rails/issues/46103 4 | Rails.application.config.active_record.query_log_tags = [:controller, :action, :job] 5 | -------------------------------------------------------------------------------- /db/migrate/20120816203248_keystore_bigint.rb: -------------------------------------------------------------------------------- 1 | class KeystoreBigint < ActiveRecord::Migration 2 | def up 3 | change_column :keystores, :value, :integer, limit: 8 4 | end 5 | 6 | def down 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20150730215915_add_submitter_is_author.rb: -------------------------------------------------------------------------------- 1 | class AddSubmitterIsAuthor < ActiveRecord::Migration 2 | def change 3 | add_column :stories, :user_is_author, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180411131217_tag_moderation_log.rb: -------------------------------------------------------------------------------- 1 | class TagModerationLog < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :moderations, :tag_id, :integer, null: true, default: nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/suggested_title.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class SuggestedTitle < ApplicationRecord 4 | belongs_to :story 5 | belongs_to :user 6 | 7 | validates :title, length: {maximum: 150}, presence: true 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20151015143959_moderations_from_group.rb: -------------------------------------------------------------------------------- 1 | class ModerationsFromGroup < ActiveRecord::Migration 2 | def change 3 | add_column :moderations, :is_from_suggestions, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/requests/cabinet_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "Cabinets", type: :request do 4 | describe "GET /index" do 5 | pending "add some examples (or delete) #{__FILE__}" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20191010172004_add_index_for_moderations_associated_model.rb: -------------------------------------------------------------------------------- 1 | class AddIndexForModerationsAssociatedModel < ActiveRecord::Migration[5.2] 2 | def change 3 | add_index :moderations, :user_id 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20200210155624_limit_new_user_tags.rb: -------------------------------------------------------------------------------- 1 | class LimitNewUserTags < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :tags, :permit_by_new_users, :boolean, null: false, default: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240813153242_add_hidden_story_created_at.rb: -------------------------------------------------------------------------------- 1 | class AddHiddenStoryCreatedAt < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :hidden_stories, :created_at, :datetime, null: true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new mime types for use in respond_to blocks: 6 | # Mime::Type.register "text/richtext", :rtf 7 | -------------------------------------------------------------------------------- /db/migrate/20150730225352_add_user_setting_show_preview.rb: -------------------------------------------------------------------------------- 1 | class AddUserSettingShowPreview < ActiveRecord::Migration 2 | def change 3 | add_column :users, :show_story_previews, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/factories/origin.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :origin do 5 | association(:domain) 6 | 7 | sequence(:identifier) { |n| Faker::Internet.unique.username } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20140408160306_add_story_merging.rb: -------------------------------------------------------------------------------- 1 | class AddStoryMerging < ActiveRecord::Migration 2 | def change 3 | add_column :stories, :merged_story_id, :integer 4 | add_index "stories", ["merged_story_id"] 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20231023155620_add_user_setting_show_email.rb: -------------------------------------------------------------------------------- 1 | class AddUserSettingShowEmail < ActiveRecord::Migration[7.0] 2 | def change 3 | add_column :users, :show_email, :boolean, default: false, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20120906183346_default_filtered_tags.rb: -------------------------------------------------------------------------------- 1 | class DefaultFilteredTags < ActiveRecord::Migration 2 | def up 3 | add_column :tags, :filtered_by_default, :boolean, default: false 4 | end 5 | 6 | def down 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20150127180326_add_story_twitter_id.rb: -------------------------------------------------------------------------------- 1 | class AddStoryTwitterId < ActiveRecord::Migration 2 | def change 3 | add_column :stories, :twitter_id, :string, limit: 20 4 | add_index :stories, [:twitter_id] 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20171025072230_modlog_hat_use.rb: -------------------------------------------------------------------------------- 1 | class ModlogHatUse < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :hats, :modlog_use, :boolean, default: false 4 | add_index :moderations, :created_at 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20200820170829_add_category_id_to_moderations.rb: -------------------------------------------------------------------------------- 1 | class AddCategoryIdToModerations < ActiveRecord::Migration[6.0] 2 | def change 3 | add_column :moderations, :category_id, :bigint, null: true, default: nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240815145726_add_last_read_line_to_newest.rb: -------------------------------------------------------------------------------- 1 | class AddLastReadLineToNewest < ActiveRecord::Migration[7.1] 2 | def change 3 | add_column :users, :last_read_newest, :datetime, null: true, default: nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20131228175805_add_comment_by_email_flag.rb: -------------------------------------------------------------------------------- 1 | class AddCommentByEmailFlag < ActiveRecord::Migration 2 | def up 3 | add_column :comments, :is_from_email, :boolean, default: false 4 | end 5 | 6 | def down 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20170413161450_add_indexes.rb: -------------------------------------------------------------------------------- 1 | class AddIndexes < ActiveRecord::Migration 2 | def change 3 | add_index :votes, [:comment_id] 4 | add_index :comments, [:user_id] 5 | add_index :stories, [:created_at] 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20180131170318_update_replying_comments_to_version_2.rb: -------------------------------------------------------------------------------- 1 | class UpdateReplyingCommentsToVersion2 < ActiveRecord::Migration[5.0] 2 | def change 3 | update_view :replying_comments, version: 2, revert_to_version: 1 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180131203555_fix_doffed_at.rb: -------------------------------------------------------------------------------- 1 | class FixDoffedAt < ActiveRecord::Migration[5.1] 2 | def change 3 | change_column_default :hats, :doffed_at, nil 4 | Hat.where("doffed_at = ?", 0).update_all(doffed_at: nil) 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20180201024840_update_replying_comments_to_version_3.rb: -------------------------------------------------------------------------------- 1 | class UpdateReplyingCommentsToVersion3 < ActiveRecord::Migration[5.0] 2 | def change 3 | update_view :replying_comments, version: 3, revert_to_version: 2 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180201041055_update_replying_comments_to_version_4.rb: -------------------------------------------------------------------------------- 1 | class UpdateReplyingCommentsToVersion4 < ActiveRecord::Migration[5.0] 2 | def change 3 | update_view :replying_comments, version: 4, revert_to_version: 3 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180201172618_update_replying_comments_to_version_5.rb: -------------------------------------------------------------------------------- 1 | class UpdateReplyingCommentsToVersion5 < ActiveRecord::Migration[5.0] 2 | def change 3 | update_view :replying_comments, version: 5, revert_to_version: 4 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180201184612_update_replying_comments_to_version_6.rb: -------------------------------------------------------------------------------- 1 | class UpdateReplyingCommentsToVersion6 < ActiveRecord::Migration[5.0] 2 | def change 3 | update_view :replying_comments, version: 6, revert_to_version: 5 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20180830114325_update_replying_comments_to_version_7.rb: -------------------------------------------------------------------------------- 1 | class UpdateReplyingCommentsToVersion7 < ActiveRecord::Migration[5.0] 2 | def change 3 | update_view :replying_comments, version: 7, revert_to_version: 6 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20190529133507_update_replying_comments_to_version_8.rb: -------------------------------------------------------------------------------- 1 | class UpdateReplyingCommentsToVersion8 < ActiveRecord::Migration[5.2] 2 | def change 3 | update_view :replying_comments, version: 8, revert_to_version: 7 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20240812221847_update_replying_comments_for_hiding.rb: -------------------------------------------------------------------------------- 1 | class UpdateReplyingCommentsForHiding < ActiveRecord::Migration[7.1] 2 | def change 3 | replace_view :replying_comments, version: 10, revert_to_version: 9 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/home/_newest_by_user.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (newest_by_user:) -%> 2 | <% render partial: 'stories/subnav' %> 3 | 4 |

5 | Newest stories submitted by 6 | <%= link_to newest_by_user.username, user_path(newest_by_user) %>. 7 |

8 | -------------------------------------------------------------------------------- /db/migrate/20151207180050_add_user_setting_post_threads.rb: -------------------------------------------------------------------------------- 1 | class AddUserSettingPostThreads < ActiveRecord::Migration 2 | def change 3 | add_column "users", "show_submitted_story_threads", :boolean, 4 | default: true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20180705143850_add_author_downvote_index.rb: -------------------------------------------------------------------------------- 1 | class AddAuthorDownvoteIndex < ActiveRecord::Migration[5.2] 2 | def change 3 | add_index :comments, [:user_id, :story_id, :downvotes, :created_at], name: "downvote_index" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/mailboxes/application_mailbox.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class ApplicationMailbox < ActionMailbox::Base 4 | # routing /something/i => :somewhere 5 | routing(/^#{Rails.application.shortname}-/ => :inbox) 6 | 7 | routing all: :backstop 8 | end 9 | -------------------------------------------------------------------------------- /app/views/about/404.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

404

3 | 4 |
5 |

6 | The resource you requested was not found, or the story has been deleted. 7 |

8 |
9 |
10 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: lobsters_production 11 | -------------------------------------------------------------------------------- /db/migrate/20120918152116_private_rss_feed.rb: -------------------------------------------------------------------------------- 1 | class PrivateRssFeed < ActiveRecord::Migration 2 | def up 3 | add_column :users, :rss_token, :string 4 | end 5 | 6 | def down 7 | remove_column :users, :rss_token 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20121004153529_add_story_text.rb: -------------------------------------------------------------------------------- 1 | class AddStoryText < ActiveRecord::Migration 2 | def up 3 | add_column :stories, :story_cache, :text 4 | end 5 | 6 | def down 7 | remove_column :stories, :story_cache 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/invitation.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :invitation do 5 | association(:user) 6 | sequence(:email) { |n| "user-#{n}@example.com" } 7 | memo { "some text for memo" } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/users/not_found.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

3 | If this user used to exist, they changed their username. 4 | Check the moderation log. 5 |

6 |
7 | -------------------------------------------------------------------------------- /db/migrate/20200519000845_index_users_on_email.rb: -------------------------------------------------------------------------------- 1 | class IndexUsersOnEmail < ActiveRecord::Migration[5.2] 2 | def change 3 | # postgresql users: you may want https://stackoverflow.com/a/32136337 4 | add_index :users, :email, unique: true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /hatchbox/root-deploy.path: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=root-deploy path hook 3 | 4 | [Path] 5 | PathModified=/home/deploy/lobsters/current 6 | PathChanged=/home/deploy/lobsters/current 7 | Unit=root-deploy.service 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lobsters HTTP 422 status: client error 5 | 6 | 7 |

Lobsters HTTP 422 status: client error

8 |

The request you made was invalid.

9 | 10 | 11 | -------------------------------------------------------------------------------- /spec/factories/comment.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :comment do 5 | association(:user) 6 | association(:story) 7 | sequence(:comment) { |n| "comment text #{n}" } 8 | created_at { Time.current } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/jobs_mod_controller.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Rails/ApplicationController 2 | class JobsModController < ActionController::Base 3 | include Authenticatable 4 | 5 | before_action :require_logged_in_moderator 6 | end 7 | # rubocop:enable Rails/ApplicationController 8 | -------------------------------------------------------------------------------- /app/jobs/restic_job.rb: -------------------------------------------------------------------------------- 1 | class ResticJob < ApplicationJob 2 | queue_as :default 3 | 4 | def perform(*args) 5 | shared = "/home/deploy/lobsters/shared" 6 | system("source #{shared}/etc/restic-env ; restic backup --no-scan #{shared}/etc #{shared}/log") 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/email_reply_mailer/mention.text.erb: -------------------------------------------------------------------------------- 1 | <%= @comment.user.username %> mentioned you in a comment: 2 | 3 | <%= word_wrap(@comment.plaintext_comment, :line_width => 72).gsub(/\n/, "\n ") %> 4 | 5 | Reply to this email or visit <%= Routes.comment_short_id_url @comment %> 6 | -------------------------------------------------------------------------------- /spec/factories/tag.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :tag do 5 | association(:category) 6 | sequence(:tag) { |n| "tag-#{n}" } 7 | sequence(:description) { |n| "tag #{n}" } 8 | permit_by_new_users { true } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint test 2 | 3 | test: 4 | bundle exec rspec 5 | brakeman -q 6 | 7 | lint: 8 | bundle exec standardrb --fix-unsafely 9 | 10 | docker-serve: 11 | export RUBY_VERSION=`cat .ruby-version` && \ 12 | docker compose up --build 13 | 14 | all: lint test 15 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path("../config/application", __FILE__) 5 | 6 | Lobsters::Application.load_tasks 7 | -------------------------------------------------------------------------------- /app/views/home/_multi_tag.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (tags:) -%> 2 |
3 | Stories tagged as
4 | 5 | <% tags.each do |tag| %> 6 | <%= tag_link(tag) %> 7 | <%= tag.description %> 8 |
9 | <% end %> 10 |
11 | 12 | -------------------------------------------------------------------------------- /db/migrate/20180124143340_add_doffed_at_to_hat.rb: -------------------------------------------------------------------------------- 1 | class AddDoffedAtToHat < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :hats, :doffed_at, :datetime, null: true, default: nil 4 | change_column :hats, :hat, :string, null: false, default: nil 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20240608224626_add_indexes_on_foreign_keys.rb: -------------------------------------------------------------------------------- 1 | class AddIndexesOnForeignKeys < ActiveRecord::Migration[7.1] 2 | def change 3 | add_index :moderations, :category_id 4 | add_index :messages, :author_user_id 5 | add_index :domains, :banned_by_user_id 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/email_reply_mailer/reply.text.erb: -------------------------------------------------------------------------------- 1 | <%= @comment.user.username %> has replied to <%= @replied_to %>: 2 | 3 | <%= word_wrap(@comment.plaintext_comment, :line_width => 72).gsub(/\n/, "\n ") %> 4 | 5 | Reply to this email or continue at <%= Routes.comment_short_id_url @comment %> 6 | -------------------------------------------------------------------------------- /db/migrate/20140112192936_add_ban_reason.rb: -------------------------------------------------------------------------------- 1 | class AddBanReason < ActiveRecord::Migration 2 | def change 3 | add_column :users, :banned_at, :datetime 4 | add_column :users, :banned_by_user_id, :integer 5 | add_column :users, :banned_reason, :string, limit: 200 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20140219183804_change_mailing_list_enabled.rb: -------------------------------------------------------------------------------- 1 | class ChangeMailingListEnabled < ActiveRecord::Migration 2 | def change 3 | rename_column :users, :mailing_list_enabled, :mailing_list_mode 4 | change_column :users, :mailing_list_mode, :integer, default: 0 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20250605141223_remove_keybase.rb: -------------------------------------------------------------------------------- 1 | class RemoveKeybase < ActiveRecord::Migration[8.0] 2 | def change 3 | User.where("settings like '%keybase_signatures%'").find_each do |user| 4 | user.settings.delete :keybase_signatures 5 | user.save! 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/views/home/_top.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () %> 2 | 3 |

4 | You can edit the URL to change the time period like /5d or /2y; 5 | known units are: <%= IntervalHelper::TIME_INTERVALS.map { |k,v| "#{k}#{v[1..].downcase}" }.join(", ").html_safe %>. 6 |

7 | -------------------------------------------------------------------------------- /db/migrate/20151015005101_add_suggested_taggings.rb: -------------------------------------------------------------------------------- 1 | class AddSuggestedTaggings < ActiveRecord::Migration 2 | def change 3 | create_table :suggested_taggings do |t| 4 | t.integer :story_id 5 | t.integer :tag_id 6 | t.integer :user_id 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20240207151341_add_story_mastodon_id.rb: -------------------------------------------------------------------------------- 1 | class AddStoryMastodonId < ActiveRecord::Migration[7.1] 2 | def change 3 | change_table :stories, bulk: true do |t| 4 | t.column :mastodon_id, :string, limit: 25, null: true 5 | t.index [:mastodon_id] 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20120704025956_user_filter.rb: -------------------------------------------------------------------------------- 1 | class UserFilter < ActiveRecord::Migration 2 | def up 3 | create_table :tag_filters do |t| 4 | t.timestamps null: false 5 | t.integer :user_id 6 | t.integer :tag_id 7 | end 8 | end 9 | 10 | def down 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20160704022756_change_story_columns.rb: -------------------------------------------------------------------------------- 1 | class ChangeStoryColumns < ActiveRecord::Migration 2 | def change 3 | change_column :stories, :is_moderated, :boolean, default: false, null: false 4 | change_column :stories, :is_expired, :boolean, default: false, null: false 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20250516035743_expand_ban_reason.rb: -------------------------------------------------------------------------------- 1 | class ExpandBanReason < ActiveRecord::Migration[8.0] 2 | def up 3 | change_column :users, :banned_reason, :string, limit: 256 4 | end 5 | 6 | def down 7 | change_column :users, :banned_reason, :string, limit: 200 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/initializers/journaldutrader.io.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | if Rails.application.domain == "journaldutrader.io" || Rails.application.name == "Sitename" 4 | raise "Journal du Trader, take my email address out of /etc/aliases, I'm sick of getting bounces from your misconfigured mail server." 5 | end 6 | -------------------------------------------------------------------------------- /spec/factories/invitation_request.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :invitation_request do 5 | name { "pete smith" } 6 | sequence(:email) { |n| "user-#{n}@example.com" } 7 | memo { "https://memo.example" } 8 | ip_address { "1.1.1.1" } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | 6 | # https://stackoverflow.com/questions/50026344/composing-activerecord-scopes-with-selects 7 | scope :select_fix, -> { select(arel_table.project(Arel.star)) } 8 | end 9 | -------------------------------------------------------------------------------- /db/migrate/20120712174445_add_comment_deleted.rb: -------------------------------------------------------------------------------- 1 | class AddCommentDeleted < ActiveRecord::Migration 2 | def up 3 | add_column "comments", "is_deleted", :boolean, default: false 4 | add_column "comments", "is_moderated", :boolean, default: false 5 | end 6 | 7 | def down 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20151015011231_add_suggested_titles.rb: -------------------------------------------------------------------------------- 1 | class AddSuggestedTitles < ActiveRecord::Migration 2 | def change 3 | create_table "suggested_titles" do |t| 4 | t.integer :story_id 5 | t.integer :user_id 6 | t.string :title, limit: 150, null: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20240315143804_coalesce_vote_reason.rb: -------------------------------------------------------------------------------- 1 | class CoalesceVoteReason < ActiveRecord::Migration[7.1] 2 | def up 3 | Vote.where(reason: nil).update_all(reason: "") 4 | change_column :votes, :reason, :string, limit: 1, null: false, default: "" 5 | end 6 | 7 | def down 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20250302102016_add_edit_last_read_newest.rb: -------------------------------------------------------------------------------- 1 | class AddEditLastReadNewest < ActiveRecord::Migration[8.0] 2 | def change 3 | rename_column :users, :last_read_newest, :last_read_newest_story 4 | add_column :users, :last_read_newest_comment, :datetime, null: true, default: nil 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/factories/hat.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :hat do 5 | association(:user) 6 | sequence(:hat) { |n| Faker::Lorem.sentence(word_count: 2)[..-2] } 7 | association(:granted_by_user, factory: :user) 8 | link { "http://example.com" } 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/views/about/_subnav.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 | <% content_for :subnav do %> 3 | <%= link_to_different_page 'About', about_path %> 4 | <%= link_to_different_page 'Chat', chat_path %> 5 | <%= link_to_different_page 'Stats', stats_path %> 6 | <%= link_to_different_page 'Hats', hats_path %> 7 | <% end %> 8 | -------------------------------------------------------------------------------- /app/views/comments/_preview.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (comment:, show_tree_lines: false) -%> 2 |
3 | <% if comment&.valid? %> 4 | <%= render partial: "comment", locals: { 5 | comment: comment, 6 | show_tree_lines: show_tree_lines, 7 | } %> 8 | <% end %> 9 |
10 | -------------------------------------------------------------------------------- /db/migrate/20191227232955_create_domains.rb: -------------------------------------------------------------------------------- 1 | class CreateDomains < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :domains do |t| 4 | t.string :domain, unique: true 5 | t.boolean :is_tracker, default: false, null: false 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/factories/hat_request.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :hat_request do 5 | association(:user) 6 | hat { "foobar hat" } 7 | link { "https://lobste.rs" } 8 | sequence(:comment) { |n| "comment text #{n}" } 9 | created_at { Time.current } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/models/story_text_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe StoryText, type: :model do 6 | it "truncates the story cache field" do 7 | s = StoryText.new 8 | s.body = "Z" * 2**24 9 | expect(s.body.length).to eq(2**24 - 1) # mediumtext 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /app/views/comments/user_threads.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'messages/subnav' %> 2 | 3 | <%= possible_flag_warning(@showing_user, @user) %> 4 | 5 |
    6 |
  1. 7 | <%= render partial: 'threads', locals: { thread: @threads } %> 8 |
  2. 9 |
10 | -------------------------------------------------------------------------------- /db/migrate/20160406160019_add_hat_requests.rb: -------------------------------------------------------------------------------- 1 | class AddHatRequests < ActiveRecord::Migration 2 | def change 3 | create_table :hat_requests do |t| 4 | t.timestamps 5 | t.integer :user_id 6 | t.string :hat 7 | t.string :link 8 | t.text :comment 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Specify a serializer for the signed and encrypted cookie jars. 6 | # Valid options are :json, :marshal, and :hybrid. 7 | Rails.application.config.action_dispatch.cookies_serializer = :json 8 | -------------------------------------------------------------------------------- /db/migrate/20241016194903_add_index_banned_by_user_id_foreign_key_in_origins.rb: -------------------------------------------------------------------------------- 1 | class AddIndexBannedByUserIdForeignKeyInOrigins < ActiveRecord::Migration[7.2] 2 | def up 3 | add_index :origins, :banned_by_user_id 4 | end 5 | 6 | def down 7 | remove_index :origins, :banned_by_user_id 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/views/about/privacy.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

3 | This is the Internet, you have no privacy. 4 |

5 | 6 |

7 | (Unless you are under 13, in which case you are not allowed here.) 8 |

9 |
10 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # ActiveSupport::Reloader.to_prepare do 6 | # ApplicationController.renderer.defaults.merge!( 7 | # http_host: 'example.org', 8 | # https: false 9 | # ) 10 | # end 11 | -------------------------------------------------------------------------------- /db/migrate/20120706221602_more_indexes.rb: -------------------------------------------------------------------------------- 1 | class MoreIndexes < ActiveRecord::Migration 2 | def up 3 | add_index :stories, ["is_expired", "is_moderated"], 4 | name: "is_idxes" 5 | add_index :tag_filters, ["user_id", "tag_id"], 6 | name: "user_tag_idx" 7 | end 8 | 9 | def down 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20181202194809_add_follow_story_for_submitter.rb: -------------------------------------------------------------------------------- 1 | class AddFollowStoryForSubmitter < ActiveRecord::Migration[5.2] 2 | def up 3 | add_column :stories, :user_is_following, :boolean, default: false, null: false 4 | end 5 | 6 | def down 7 | remove_column :stories, :user_is_following 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20170713195446_add_saved_stories.rb: -------------------------------------------------------------------------------- 1 | class AddSavedStories < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :saved_stories do |t| 4 | t.timestamps 5 | t.integer :user_id 6 | t.integer :story_id 7 | t.index ["user_id", "story_id"], unique: true 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/mailers/password_reset_mailer.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class PasswordResetMailer < ApplicationMailer 4 | def password_reset_link(user, ip) 5 | @user = user 6 | @ip = ip 7 | 8 | mail( 9 | to: user.email, 10 | subject: "[#{Rails.application.name}] Reset your password" 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20250206172739_fix_prefers_contrast.rb: -------------------------------------------------------------------------------- 1 | class FixPrefersContrast < ActiveRecord::Migration[8.0] 2 | def change 3 | User.find_each do |u| 4 | next if ["system", "normal", "high"].include?(u.prefers_contrast) 5 | u.prefers_contrast = "system" 6 | u.save! validate: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20250630123615_moderation_enlarge_action.rb: -------------------------------------------------------------------------------- 1 | class ModerationEnlargeAction < ActiveRecord::Migration[8.0] 2 | def up 3 | change_column :moderations, :action, :text, size: :long, null: false 4 | end 5 | 6 | def down 7 | change_column :moderations, :action, :text, size: :medium, null: false 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/slow/fake_data_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | Rails.application.load_tasks 6 | 7 | describe "fake_data" do 8 | before { Rails.application.load_seed } 9 | 10 | # basic smoke test, task shouldn't throw exceptions 11 | it "runs" do 12 | FakeDataGenerator.new.generate 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20160515162433_add_disabled_invites_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddDisabledInvitesToUsers < ActiveRecord::Migration 2 | def change 3 | add_column :users, :disabled_invite_at, :datetime 4 | add_column :users, :disabled_invite_by_user_id, :integer 5 | add_column :users, :disabled_invite_reason, :string, {limit: 200} 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/users/list.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

3 | <%= @title %> (<%= @user_count %>) 4 |

5 | 6 | 14 |
15 | -------------------------------------------------------------------------------- /config/cache.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | database: cache 3 | store_options: 4 | # Cap age of oldest cache entry to fulfill retention policies 5 | max_age: <%= 2.weeks.to_i %> 6 | max_size: <%= 2.gigabytes %> 7 | namespace: <%= Rails.env %> 8 | 9 | development: 10 | <<: *default 11 | 12 | production: 13 | <<: *default 14 | -------------------------------------------------------------------------------- /app/views/ban_notification_mailer/notify.text.erb: -------------------------------------------------------------------------------- 1 | You have been banned from <%= Rails.application.name %> by <%= @banner.username %> for: 2 | 3 | <%= word_wrap(@reason, :line_width => 72).gsub(/\n/, "\n ") %> 4 | 5 | You are no longer allowed to login to the site. If you wish, you can 6 | discuss this ban with the moderator by replying to this e-mail. 7 | -------------------------------------------------------------------------------- /app/views/mod/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'subnav', locals: { periods: @periods } %> 2 | 3 | 4 |
5 | <%= render partial: 'moderations/table', locals: { moderations: @moderations } %> 6 |
7 | 8 |
9 |

Also: jobs dashboard.

10 |
11 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | if ! gem list foreman -i > /dev/null; then 3 | echo "Foreman gem not installed. Installing..." 4 | bundle install 5 | fi 6 | 7 | 8 | # Default to port 3000 if not specified 9 | export PORT="${PORT:-3000}" 10 | 11 | echo "Starting application with Foreman..." 12 | 13 | bundle exec foreman start -f Procfile.dev "$@" 14 | -------------------------------------------------------------------------------- /app/jobs/stats_graphs_job.rb: -------------------------------------------------------------------------------- 1 | class StatsGraphsJob < ApplicationJob 2 | queue_as :default 3 | 4 | def perform(*args) 5 | Stats.fill_users_graph_cache 6 | Stats.fill_active_users_graph_cache 7 | Stats.fill_stories_graph_cache 8 | Stats.fill_comments_graph_cache 9 | Stats.fill_votes_graph_cache 10 | nil 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/views/saved/_subnav.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 | <% content_for :subnav do %> 3 | <%= link_to_different_page 'Saved', saved_path %> 4 | <%= link_to_different_page 'Upvoted Stories', upvoted_stories_path %> 5 | <%= link_to_different_page 'Upvoted Comments', upvoted_comments_path %> 6 | <%= link_to_different_page 'Hidden', hidden_path %> 7 | <% end %> 8 | -------------------------------------------------------------------------------- /db/migrate/20141114184921_add_hats.rb: -------------------------------------------------------------------------------- 1 | class AddHats < ActiveRecord::Migration 2 | def change 3 | create_table :hats do |t| 4 | t.timestamps 5 | t.integer :user_id 6 | t.integer :granted_by_user_id 7 | t.string :hat 8 | t.string :link 9 | end 10 | 11 | add_column :comments, :hat_id, :integer 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/factories/message.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :message do 5 | association(:recipient, factory: :user) 6 | association(:author, factory: :user) 7 | sequence(:subject) { |n| "message subject #{n}" } 8 | sequence(:body) { |n| "message body #{n}" } 9 | 10 | has_been_read { false } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/views/comments/_postedreply.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (comment:, show_tree_lines: true) -%> 2 |
  • 3 | <%= render "comments/comment", :comment => comment, :show_tree_lines => show_tree_lines %> 4 | <% if comment.parent_comment %> 5 |
    6 | <% end %> 7 |
      8 |
    1. 9 | -------------------------------------------------------------------------------- /app/mailers/invitation_mailer.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class InvitationMailer < ApplicationMailer 4 | def invitation(invitation) 5 | @invitation = invitation 6 | 7 | mail( 8 | to: invitation.email, 9 | subject: "[#{Rails.application.name}] You are invited to join " << 10 | Rails.application.name 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20120704004020_fix_up_messages.rb: -------------------------------------------------------------------------------- 1 | class FixUpMessages < ActiveRecord::Migration 2 | def up 3 | rename_column :messages, :random_hash, :short_id 4 | 5 | add_column :messages, :deleted_by_author, :boolean, default: false 6 | add_column :messages, :deleted_by_recipient, :boolean, default: false 7 | end 8 | 9 | def down 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20170607170604_add_fulltext_indicies.rb: -------------------------------------------------------------------------------- 1 | class AddFulltextIndicies < ActiveRecord::Migration 2 | def change 3 | add_index(:stories, :title, type: :fulltext) 4 | add_index(:stories, :description, type: :fulltext) 5 | add_index(:stories, :story_cache, type: :fulltext) 6 | 7 | add_index(:comments, :comment, type: :fulltext) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20180501031849_add_used_at_to_invitation.rb: -------------------------------------------------------------------------------- 1 | class AddUsedAtToInvitation < ActiveRecord::Migration[5.1] 2 | def change 3 | change_column :invitations, :user_id, :integer, null: false 4 | add_column :invitations, :used_at, :datetime, null: true, default: nil 5 | add_column :invitations, :new_user_id, :integer, null: true, default: nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200607192351_story_index_for_comments.rb: -------------------------------------------------------------------------------- 1 | class StoryIndexForComments < ActiveRecord::Migration[5.2] 2 | def change 3 | remove_index :stories, name: "index_stories_on_is_expired" 4 | remove_index :stories, name: "index_stories_on_is_moderated" 5 | remove_index :stories, name: "is_idxes" 6 | add_index :stories, [:id, :is_expired] 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /hatchbox/logrotate.conf: -------------------------------------------------------------------------------- 1 | /home/deploy/*/shared/log/*.log { 2 | compresscmd /bin/bzip2 3 | compressext .bz2 4 | compressoptions -9 5 | copytruncate 6 | create 7 | dateext 8 | delaycompress 9 | maxsize 5G 10 | missingok 11 | norenamecopy 12 | notifempty 13 | rotate 26 14 | shred 15 | shredcycles 1 16 | su deploy deploy 17 | weekly 18 | } 19 | -------------------------------------------------------------------------------- /app/views/helpers/_link_post.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (button_label:, link:, class_name: "", confirm: nil) -%> 2 | <%= form_tag url_for(link), class: "link_post" do %> 3 | <%# redundant class on the submit_tag because the form_tag isn't printed if nested in another form %> 4 | <%= submit_tag button_label, class: "link_post #{class_name}", data: { confirm: confirm } %> 5 | <% end %> 6 | -------------------------------------------------------------------------------- /app/views/mod/flagged_stories.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'subnav', locals: { periods: @periods } %> 2 | 3 | <% if @stories.present? %> 4 |
        5 | <%= render :partial => "stories/listdetail", 6 | :collection => @stories, 7 | :as => :story %> 8 |
      9 | <% else %> 10 |
      🦞
      11 | <% end %> 12 | -------------------------------------------------------------------------------- /extras/html_encoder.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "cgi" 4 | 5 | module HtmlEncoder 6 | HTML_ENTITIES = HTMLEntities.new 7 | 8 | class << self 9 | def encode(string, type = :decimal) 10 | HTML_ENTITIES.encode(string, type) 11 | end 12 | 13 | def decode(encoded_string) 14 | CGI.unescape_html(encoded_string) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/saved_story.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class SavedStory < ApplicationRecord 4 | belongs_to :user 5 | belongs_to :story 6 | 7 | scope :by, ->(user) { where(user: user) } 8 | 9 | include Token 10 | 11 | def self.save_story_for_user(story_id, user_id) 12 | SavedStory.where(user_id: user_id, story_id: story_id).first_or_initialize.save! 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/queue.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | dispatchers: 3 | - polling_interval: 1 4 | batch_size: 500 5 | workers: 6 | - queues: "*" 7 | threads: 3 8 | processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> 9 | polling_interval: 0.1 10 | 11 | development: 12 | <<: *default 13 | 14 | test: 15 | <<: *default 16 | 17 | production: 18 | <<: *default 19 | -------------------------------------------------------------------------------- /script/lobsters-ingress: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | cd /home/deploy/lobsters/current 6 | 7 | source /home/deploy/.asdf/asdf.sh 8 | eval "$(/home/deploy/.asdf/bin/asdf vars)" 9 | 10 | # INGRESS_PASSWORD is in a hatchbox env var 11 | /home/deploy/.asdf/shims/bundle exec rails action_mailbox:ingress:postfix URL=https://lobste.rs/rails/action_mailbox/relay/inbound_emails 12 | -------------------------------------------------------------------------------- /spec/extras/html_encoder_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe HtmlEncoder do 6 | it "encode all non-ascii characters" do 7 | expect(subject.encode("")).to eq "<Héllø>" 8 | end 9 | 10 | it "decode all entities" do 11 | expect(subject.decode("<Héllø>")).to eq "" 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/home/_category.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (categories:) -%> 2 |
      3 | Stories with tags in the 4 | <% categories.each do |c| %> 5 | <%= link_to c.category, category_path(c) %> 6 | <% end %> 7 | <%= "category".pluralize(categories) %> 8 | 9 | <%= render(partial: 'categories/multi_category_tip') if categories.length == 1 %> 10 |
      11 | -------------------------------------------------------------------------------- /db/migrate/20121112165212_add_tag_media_types.rb: -------------------------------------------------------------------------------- 1 | class AddTagMediaTypes < ActiveRecord::Migration 2 | def up 3 | add_column :tags, :is_media, :boolean, default: false 4 | 5 | ["pdf", "video"].each do |t| 6 | if (tag = Tag.where(tag: t).first) 7 | tag.is_media = true 8 | tag.save 9 | end 10 | end 11 | end 12 | 13 | def down 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20250210214901_fix_last_x_at_timestamps.rb: -------------------------------------------------------------------------------- 1 | class FixLastXAtTimestamps < ActiveRecord::Migration[8.0] 2 | def change 3 | Story.update_all("last_comment_at = (select max(last_edited_at) from comments where story_id = stories.id)") 4 | Comment.update_all("last_reply_at = (select max(created_at) from comments as c_inner where c_inner.parent_comment_id = comments.id)") 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/factories/story.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :story do 5 | association(:user) 6 | sequence(:title) { |n| "story title #{n}" } 7 | sequence(:url) { |n| "http://example.com/#{n}" } 8 | tags { Tag.where(tag: "placeholder") } 9 | 10 | trait :deleted do 11 | is_deleted { true } 12 | editor { user } 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.custom_cops.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - ./lib/custom_cops/inherits_moderator_controller.rb 3 | # to use additional cops, list individual ruby files here 4 | # (listing directories or glob patterns doesn't seem to work) 5 | # ex: 6 | # - ./lib/custom_cops/.rb 7 | 8 | CustomCops/InheritsModeratorController: 9 | Enabled: true 10 | Include: 11 | - "app/controllers/mod/**/*.rb" 12 | -------------------------------------------------------------------------------- /db/migrate/20240822174547_add_short_id_hats.rb: -------------------------------------------------------------------------------- 1 | class AddShortIdHats < ActiveRecord::Migration[7.1] 2 | def up 3 | add_column :hats, :short_id, :string 4 | Hat.find_each do |hat| 5 | hat.assign_short_id 6 | hat.save! 7 | end 8 | change_column :hats, :short_id, :string, null: false 9 | end 10 | 11 | def down 12 | remove_column :hats, :short_id 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/mailers/email_message_mailer.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class EmailMessageMailer < ApplicationMailer 4 | def notify(message, user) 5 | @message = message 6 | @user = user 7 | 8 | mail( 9 | to: user.email, 10 | subject: "[#{Rails.application.name}] Private Message from " \ 11 | "#{message.author_username}: #{message.subject}" 12 | ) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20120701160006_reply_notifications.rb: -------------------------------------------------------------------------------- 1 | class ReplyNotifications < ActiveRecord::Migration 2 | def up 3 | add_column :users, :email_replies, :boolean, default: false 4 | add_column :users, :pushover_replies, :boolean, default: false 5 | add_column :users, :pushover_user_key, :string 6 | add_column :users, :pushover_device, :string 7 | end 8 | 9 | def down 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20120919195401_private_tags.rb: -------------------------------------------------------------------------------- 1 | class PrivateTags < ActiveRecord::Migration 2 | def up 3 | add_column :tags, :privileged, :boolean, default: false 4 | 5 | # All existing tags should be public by default 6 | Tag.all.each do |t| 7 | t.privileged = false 8 | t.save 9 | end 10 | end 11 | 12 | def down 13 | remove_column :tags, :privileged 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/assets/images/merge.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/views/signup/invite.html.erb: -------------------------------------------------------------------------------- 1 |

      2 | Now that you are a member you can invite people you think would be a good fit. 3 | This is available anytime from your settings page. 4 |

      5 | 6 | <%= render :partial => "users/invitationform", 7 | :locals => { :return_home => true } %> 8 | 9 |

      or

      10 | 11 |

      12 | <%= button_to "Skip", "/", :method => :get %> 13 |

      14 | -------------------------------------------------------------------------------- /db/migrate/20130526164230_change_tables_to_utf8mb4.rb: -------------------------------------------------------------------------------- 1 | class ChangeTablesToUtf8mb4 < ActiveRecord::Migration 2 | def up 3 | return if !/Mysql/.match?(connection.adapter_name) 4 | 5 | ["comments", "invitations", "messages", "moderations", "stories", "users"].each do |t| 6 | execute("alter table #{t} convert to character set utf8mb4") 7 | end 8 | end 9 | 10 | def down 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20230424222719_create_mastodon_apps.rb: -------------------------------------------------------------------------------- 1 | class CreateMastodonApps < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :mastodon_apps do |t| 4 | t.string :name, null: false 5 | t.string :client_id, null: false 6 | t.string :client_secret, null: false 7 | 8 | t.timestamps 9 | end 10 | 11 | add_index :mastodon_apps, :name, unique: true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/controllers/inbox_controller.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class InboxController < ApplicationController 4 | before_action :require_logged_in_user 5 | 6 | def index 7 | if @user.unread_replies_count > 0 8 | redirect_to replies_unread_path 9 | elsif @user.unread_message_count > 0 10 | redirect_to messages_path 11 | else 12 | redirect_to replies_path 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/password_reset_mailer/password_reset_link.text.erb: -------------------------------------------------------------------------------- 1 | Hello <%= @user.email %>, 2 | 3 | Someone at <%= @ip %> requested to reset your account password 4 | on <%= Rails.application.name %>. If you submitted this request, visit the link below to 5 | set a new password. If not, you can disregard this e-mail. 6 | 7 | <%= set_new_password_url username: @user.username, password_reset_token: @user.password_reset_token %> 8 | -------------------------------------------------------------------------------- /db/migrate/20120910172514_mention_notification_options.rb: -------------------------------------------------------------------------------- 1 | class MentionNotificationOptions < ActiveRecord::Migration 2 | def up 3 | add_column :users, :email_mentions, :boolean, default: false 4 | add_column :users, :pushover_mentions, :boolean, default: false 5 | end 6 | 7 | def down 8 | remove_column :users, :pushover_mentions 9 | remove_column :users, :email_mentions 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/assets/stylesheets/system-system.css: -------------------------------------------------------------------------------- 1 | /* system color scheme (system contrast) */ 2 | 3 | @import url("light-normal.css"); /* default */ 4 | @import url("light-high.css") (not (prefers-color-scheme: dark)) and (prefers-contrast: more); 5 | @import url("dark-normal.css") (prefers-color-scheme: dark) and (not (prefers-contrast: more)); 6 | @import url("dark-high.css") (prefers-color-scheme: dark) and (prefers-contrast: more); 7 | -------------------------------------------------------------------------------- /db/migrate/20250203223624_count_merged_stories.rb: -------------------------------------------------------------------------------- 1 | class CountMergedStories < ActiveRecord::Migration[8.0] 2 | def up 3 | add_column :stories, :stories_count, :integer, null: false, default: 0 4 | Story.update_all("stories_count = (select count(*) from stories as s_inner where s_inner.merged_story_id = stories.id)") 5 | end 6 | 7 | def down 8 | remove_column :stories, :stories_count 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20120704013019_pm_notification_options.rb: -------------------------------------------------------------------------------- 1 | class PmNotificationOptions < ActiveRecord::Migration 2 | def up 3 | change_table :messages do |t| 4 | t.change :has_been_read, :boolean, default: false 5 | end 6 | 7 | add_column :users, :email_messages, :boolean, default: true 8 | add_column :users, :pushover_messages, :boolean, default: true 9 | end 10 | 11 | def down 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | This repo exists to public the source to https://lobste.rs 6 | 7 | While we occasionally answer questions, we don't have a formal support 8 | policy for sites using the codebase. 9 | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | Please email peter@push.cx. I'll try to respond promptly. 14 | If you've found an issue, I'll help ensure you get credit for it. 15 | -------------------------------------------------------------------------------- /app/mailers/invitation_request_mailer.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class InvitationRequestMailer < ApplicationMailer 4 | def invitation_request(invitation_request) 5 | @invitation_request = invitation_request 6 | 7 | mail( 8 | to: invitation_request.email, 9 | subject: "[#{Rails.application.name}] Confirm your invitation " \ 10 | "request to " << Rails.application.name 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/categories/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (category:) -%> 2 | <%= form_with model: category, url: category.persisted? ? update_category_path : categories_path, method: :post do |f| %> 3 |
      4 | <%= f.label :category, 'Name', class: 'required' %> 5 | <%= f.text_field :category %> 6 |
      7 | 8 | <%= f.submit category.persisted? ? 'Update Category' : 'Create Category' %> 9 | <% end %> 10 | -------------------------------------------------------------------------------- /app/views/invitation_mailer/invitation.text.erb: -------------------------------------------------------------------------------- 1 | Hello <%= @invitation.email %>, 2 | 3 | The user <%= @invitation.user.username %> has invited you to <%= Rails.application.name %> (<%= Rails.application.root_url %>)<%= @invitation.memo.present? ? raw("\n\n #{@invitation.memo}") : "" %> 4 | 5 | To accept this invitation and create an account, visit the URL below: 6 | 7 | <%= Rails.application.root_url %>invitations/<%= @invitation.code %> 8 | -------------------------------------------------------------------------------- /db/migrate/20240208024935_remove_twitter.rb: -------------------------------------------------------------------------------- 1 | class RemoveTwitter < ActiveRecord::Migration[7.1] 2 | def change 3 | User.where("settings like '%twitter_%'").find_each do |user| 4 | user.settings.delete :twitter_oauth_token 5 | user.settings.delete :twitter_oauth_token_secret 6 | user.settings.delete :twitter_username 7 | user.save! 8 | end 9 | remove_index :stories, :twitter_id 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20130622021035_add_user_list_id.rb: -------------------------------------------------------------------------------- 1 | class AddUserListId < ActiveRecord::Migration 2 | def up 3 | add_column :users, :mailing_list_token, :string 4 | add_column :users, :mailing_list_enabled, :boolean, default: false 5 | 6 | add_index "users", ["mailing_list_enabled"] 7 | end 8 | 9 | def down 10 | remove_column :users, :mailing_list_token 11 | remove_column :users, :mailing_list_enabled 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20180130235553_add_read_notification_support.rb: -------------------------------------------------------------------------------- 1 | class AddReadNotificationSupport < ActiveRecord::Migration[5.1] 2 | def change 3 | create_table :read_ribbons do |t| 4 | t.boolean :is_following, default: true 5 | t.timestamps 6 | end 7 | 8 | add_reference :read_ribbons, :user, index: true 9 | add_reference :read_ribbons, :story, index: true 10 | 11 | create_view :replying_comments 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /db/migrate/20220806200248_set_is_tracker_as_banned_to_domains.rb: -------------------------------------------------------------------------------- 1 | class SetIsTrackerAsBannedToDomains < ActiveRecord::Migration[7.0] 2 | def up 3 | banned_by_user = User.find_by(username: "pushcx") 4 | Domain.where(is_tracker: true).each do |domain| 5 | domain.ban_by_user_for_reason!(banned_by_user, "Used for link shortening and ad tracking") 6 | end 7 | 8 | remove_column :domains, :is_tracker, :boolean 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /hatchbox/aliases: -------------------------------------------------------------------------------- 1 | # See man 5 aliases for format 2 | 3 | # do not include 'lobsters' here - main.cf has ingress configured for mailing list 4 | 5 | # system 6 | root: ADMIN_EMAIL 7 | abuse: root 8 | admin: root 9 | contact: root 10 | deploy: root 11 | help: root 12 | postmaster: root 13 | security: root 14 | support: root 15 | webmaster: root 16 | 17 | # personal 18 | peter: ADMIN_EMAIL 19 | pushcx: ADMIN_EMAIL 20 | 21 | nobody: /dev/null 22 | -------------------------------------------------------------------------------- /app/helpers/cabinet_helper.rb: -------------------------------------------------------------------------------- 1 | module CabinetHelper 2 | def debug_render *args 3 | capture do 4 | concat content_tag(:details, 5 | content_tag(:summary, "header") + 6 | content_tag(:pre, word_wrap("render #{args.inspect}")), 7 | style: "margin-left: 20px") 8 | concat render(*args) 9 | end 10 | end 11 | 12 | def as_user user 13 | @user = user 14 | yield 15 | @user = nil 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/views/mod/flagged_comments.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'subnav', locals: { periods: @periods } %> 2 | 3 | <% if @comments.present? %> 4 |
        5 | <% @comments.each do |comment| %> 6 |
      1. 7 | <%= render "comments/comment", :comment => comment, :show_story => true, force_open: true %> 8 |
      2. 9 | <% end %> 10 |
      11 | <% else %> 12 |
      🦞
      13 | <% end %> 14 | -------------------------------------------------------------------------------- /app/models/concerns/token.rb: -------------------------------------------------------------------------------- 1 | module Token 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | after_initialize do 6 | self.token ||= TypeID.new(self.class.to_s.parameterize) if new_record? || attributes.include?(:token) 7 | end 8 | 9 | validates :token, presence: true 10 | end 11 | 12 | def token=(new) 13 | raise ArgumentError, "token already set, don't alter it" unless token.nil? 14 | super 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/initializers/email.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | if Rails.env.production? 4 | ActionMailer::Base.smtp_settings = { 5 | address: ENV.fetch("SMTP_HOST", "127.0.0.1"), 6 | port: Integer(ENV.fetch("SMTP_PORT", 25)), 7 | domain: Rails.application.domain, 8 | enable_starttls_auto: (ENV["SMTP_STARTTLS_AUTO"] == "true"), 9 | user_name: ENV.fetch("SMTP_USERNAME", nil), 10 | password: ENV.fetch("SMTP_PASSWORD", nil) 11 | } 12 | end 13 | -------------------------------------------------------------------------------- /db/migrate/20150115172138_drop_extra_pushover_fields.rb: -------------------------------------------------------------------------------- 1 | class DropExtraPushoverFields < ActiveRecord::Migration 2 | # extra pushover data is now stored in the subscription, we don't need it 3 | # 4 | # user keys to subscription keys can be migrated by using 5 | # https://pushover.net/api/subscriptions#migration 6 | 7 | def change 8 | remove_column :users, :pushover_device 9 | remove_column :users, :pushover_sound 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /hatchbox/root-deploy.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=root-deploy service 3 | After=caddy.service 4 | PartOf=caddy.service 5 | 6 | [Service] 7 | Type=oneshot 8 | ExecStart=/home/deploy/lobsters/current/hatchbox/root-deploy 9 | ExecStartPost=/bin/echo "Ran root-deploy hook" 10 | RemainAfterExit=no 11 | # Do not be considered 'running' after start; path units can only start 12 | # services, not restart. 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /db/migrate/20180711123439_create_mod_notes.rb: -------------------------------------------------------------------------------- 1 | class CreateModNotes < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :mod_notes do |t| 4 | t.integer :moderator_user_id, null: false 5 | t.integer :user_id, null: false 6 | t.text :note, null: false 7 | t.text :markeddown_note, null: false 8 | t.datetime :created_at, null: false 9 | end 10 | 11 | add_index :mod_notes, [:id, :user_id] 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/views/mod_notes/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= render partial: 'subnav', locals: { periods: @periods } %> 2 | 3 | <%= form_with url: mod_notes_path, method: :get do |f| %> 4 |
      5 | 6 | 7 | <%= f.submit 'Filter' %> 8 |
      9 | <% end %> 10 | 11 | <%= render partial: 'table', locals: { 12 | mod_notes: @notes 13 | } %> 14 | -------------------------------------------------------------------------------- /db/migrate/20191029021735_fix_database_consistency_with_validators.rb: -------------------------------------------------------------------------------- 1 | class FixDatabaseConsistencyWithValidators < ActiveRecord::Migration[5.2] 2 | def change 3 | change_column_null :hat_requests, :hat, false 4 | change_column_null :hat_requests, :link, false 5 | change_column_null :hat_requests, :comment, false 6 | change_column_null :invitation_requests, :name, false 7 | change_column_null :invitation_requests, :email, false 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/mailers/ban_notification_mailer.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class BanNotificationMailer < ApplicationMailer 4 | def notify(user, banner, reason) 5 | @banner = banner 6 | @reason = reason 7 | 8 | mail( 9 | from: "#{@banner.username} ", 10 | replyto: "#{@banner.username} <#{@banner.email}>", 11 | to: user.email, 12 | subject: "[#{Rails.application.name}] You have been banned" 13 | ) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/comments/_too_deep.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 |
      3 | Max reply depth reached. 4 | Perhaps blog about this and submit it? 5 | 6 |

      7 | 8 | (We have a max reply depth for technical reasons. 9 | But also, at this depth, previous discussions have always gone off the rails 10 | in topic or tone.) 11 |
      12 | -------------------------------------------------------------------------------- /db/migrate/20200210125425_add_domain_banning.rb: -------------------------------------------------------------------------------- 1 | class AddDomainBanning < ActiveRecord::Migration[5.2] 2 | def change 3 | add_column :domains, :banned_at, :datetime, null: true, default: nil 4 | add_column :domains, :banned_by_user_id, :integer, null: true, default: nil 5 | add_column :domains, :banned_reason, :string, limit: 200 6 | add_column :moderations, :domain_id, :integer, null: true, default: nil 7 | add_index :moderations, :domain_id 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20131018201413_add_invitation_requests.rb: -------------------------------------------------------------------------------- 1 | class AddInvitationRequests < ActiveRecord::Migration 2 | def up 3 | create_table :invitation_requests do |t| 4 | t.string :code 5 | t.boolean :is_verified, default: false 6 | t.string :email 7 | t.string :name 8 | t.text :memo 9 | t.string :ip_address 10 | t.timestamps null: false 11 | end 12 | end 13 | 14 | def down 15 | drop_table :invitation_requests 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/views/login/forgot_password.html.erb: -------------------------------------------------------------------------------- 1 |

      2 | If you've forgotten your password, enter your e-mail address or username 3 | below and instructions will be e-mailed to you. 4 |

      5 | 6 | <%= form_with url: reset_password_path do |f| %> 7 |
      8 | <%= f.label :email, "E-mail or Username" %> 9 | <%= f.text_field :email, :size => 30, :autofocus => "autofocus", :inputmode => "email" %> 10 |
      11 | 12 | <%= f.submit "Reset Password" %> 13 | <% end %> 14 | -------------------------------------------------------------------------------- /db/migrate/20220331165136_message_author_nullable.rb: -------------------------------------------------------------------------------- 1 | class MessageAuthorNullable < ActiveRecord::Migration[6.1] 2 | def up 3 | change_column_null :messages, :author_user_id, true 4 | Story.connection.execute("UPDATE messages SET author_user_id = NULL WHERE author_user_id = 0") 5 | end 6 | 7 | def down 8 | Story.connection.execute("UPDATE messages SET author_user_id = 0 WHERE author_user_id IS NULL") 9 | change_column_null :messages, :author_user_id, false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /public/504.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lobsters HTTP 504 status: puma timeout 5 | 6 | 7 |

      Lobsters HTTP 504 status: server error

      8 |

      9 | The puma worker timed out responding to your request. 10 |

      11 |

      12 | If this persists, please come to the 13 | IRC channel 14 | (webchat). 15 |

      16 | 17 | 18 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # You can add backtrace silencers for libraries that you're using but don't 6 | # wish to see in your backtraces. 7 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 8 | 9 | # You can also remove all the silencers if you're trying to debug a problem 10 | # that might stem from framework code. 11 | # Rails.backtrace_cleaner.remove_silencers! 12 | -------------------------------------------------------------------------------- /db/migrate/20200828015742_fix_comment_flags_default.rb: -------------------------------------------------------------------------------- 1 | class FixCommentFlagsDefault < ActiveRecord::Migration[6.0] 2 | def change 3 | change_column :comments, :flags, :integer, default: 0, null: false, unsigned: true 4 | ActiveRecord::Base.connection.execute <<~SQL 5 | update comments 6 | set flags = coalesce(( 7 | select count(vote) 8 | from votes 9 | where comment_id = comments.id 10 | and vote = -1 11 | ), 0) 12 | SQL 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /hatchbox/opendkim.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=OpenDKIM Milter 3 | Documentation=man:opendkim(8) man:opendkim.conf(5) man:opendkim-lua(3) man:opendkim-genkey(8) man:opendkim-genzone(8) man:opendkim-testkey(8) http://www.opendkim.org/docs.html 4 | After=network.target nss-lookup.target 5 | 6 | [Service] 7 | User=opendkim 8 | Group=opendkim 9 | Type=forking 10 | ExecStart=/usr/sbin/opendkim 11 | ExecReload=/bin/kill -USR1 $MAINPID 12 | Restart=on-failure 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /db/migrate/20120902143549_add_moderation_log.rb: -------------------------------------------------------------------------------- 1 | class AddModerationLog < ActiveRecord::Migration 2 | def up 3 | add_column "users", "is_moderator", :boolean, default: false 4 | 5 | create_table "moderations" do |t| 6 | t.timestamps null: false 7 | t.integer "moderator_user_id" 8 | t.integer "story_id" 9 | t.integer "comment_id" 10 | t.integer "user_id" 11 | t.text "action" 12 | t.text "reason" 13 | end 14 | end 15 | 16 | def down 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /hatchbox/bash_aliases: -------------------------------------------------------------------------------- 1 | export EDITOR=vim 2 | export HISTFILESIZE=$HISTSIZE 3 | export HISTIGNORE=[fb]g 4 | export HISTSIZE=100000 5 | export PAGER="less -R" 6 | 7 | alias be="bundle exec" 8 | alias c="curl" 9 | alias cl="clear" 10 | alias con="cd ~deploy/lobsters/current; bundle exec rails console" 11 | alias db="cd ~deploy/lobsters/current; bundle exec rails db -p" 12 | alias e="exa" 13 | alias et="exa -snew" 14 | alias fd="fdfind" 15 | alias gerp="grep" 16 | alias g="git" 17 | alias vi="vim" 18 | 19 | set -o vi 20 | -------------------------------------------------------------------------------- /spec/routing/origin_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe "origin routing" do 6 | it "routes a single origin" do 7 | assert_recognizes({controller: "home", action: "for_origin", identifier: "github.com/alice"}, "/origins/github.com/alice") 8 | end 9 | 10 | it "redirects attempts to load an identifier from a domain url", type: :request do 11 | get "/domains/github.com/alice" 12 | expect(response).to redirect_to("/origins/github.com/alice") 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # match this in caddy config for bypassing the file cache 6 | Lobsters::Application.config.session_store :cookie_store, 7 | key: "lobster_trap", 8 | expire_after: 1.month, 9 | httponly: true, 10 | same_site: :strict 11 | # :secure commented out because it's redundant with config.force_ssl 12 | # https://api.rubyonrails.org/v5.2.8.1/classes/ActionDispatch/SSL.html 13 | # secure: true 14 | -------------------------------------------------------------------------------- /db/migrate/20240717155412_hydrate_links.rb: -------------------------------------------------------------------------------- 1 | class HydrateLinks < ActiveRecord::Migration[7.1] 2 | def up 3 | Comment.where("comment like '%https://%' or comment like '%http://%'").find_each do |c| 4 | Link.recreate_from_comment! c 5 | end 6 | Story.where("id >= 40621").where("url is not null or (description like '%https://%' or description like '%http://%')").find_each do |s| 7 | Link.recreate_from_story! s 8 | end 9 | end 10 | 11 | def down 12 | Link.delete_all 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/helpers/stories_helper.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | module StoriesHelper 4 | def show_guidelines?(user) 5 | if !user 6 | return true 7 | end 8 | 9 | if user.stories_submitted_count <= 5 10 | return true 11 | end 12 | 13 | if Moderation.joins(:story) 14 | .where( 15 | "stories.user_id = ? AND moderations.created_at > ?", 16 | user.id, 17 | 5.days.ago 18 | ).exists? 19 | return true 20 | end 21 | 22 | false 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/views/login/twofa.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with url: twofa_login_url do |f| %> 2 |

      3 | Enter the current TOTP code from your TOTP application: 4 |

      5 | 6 |
      7 | <%= f.label :totp_code, "TOTP Code" %> 8 | <%= f.text_field :totp_code, 9 | :inputmode => "numeric", 10 | :pattern => "[0-9]+", 11 | :autocomplete => "one-time-code", 12 | :autofocus => true, 13 | :class => "totp_code" %> 14 |
      15 | 16 | <%= f.submit "Login" %> 17 | <% end %> 18 | 19 | -------------------------------------------------------------------------------- /db/migrate/20170225201811_delete_old_settings.rb: -------------------------------------------------------------------------------- 1 | class DeleteOldSettings < ActiveRecord::Migration 2 | def change 3 | [ 4 | :email_notifications, 5 | :email_replies, 6 | :pushover_replies, 7 | :pushover_user_key, 8 | :email_messages, 9 | :pushover_messages, 10 | :email_mentions, 11 | :show_avatars, 12 | :show_story_previews, 13 | :show_submitted_story_threads 14 | ].each do |col| 15 | remove_column :users, "old_#{col}" 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/20240907171330_fix_links_in_invite_notes.rb: -------------------------------------------------------------------------------- 1 | class FixLinksInInviteNotes < ActiveRecord::Migration[7.1] 2 | def up 3 | ModNote.where("note LIKE '%another user tried to redeem%'").find_each do |mod_note| 4 | mod_note.note = mod_note.note.gsub(/attempted redeemer: \[(.*)\].*\)/) do |match| 5 | "attempted redeemer: [#{$1}](https://#{Rails.application.domain}/~#{$1})" 6 | end 7 | mod_note.save! 8 | end 9 | end 10 | 11 | def down 12 | # time to roll forward 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/helpers/cabinet_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | # Specs in this file have access to a helper object that includes 4 | # the CabinetHelper. For example: 5 | # 6 | # describe CabinetHelper do 7 | # describe "string concat" do 8 | # it "concats two strings with spaces" do 9 | # expect(helper.concat_strings("this","that")).to eq("this that") 10 | # end 11 | # end 12 | # end 13 | RSpec.describe CabinetHelper, type: :helper do 14 | pending "add some examples to (or delete) #{__FILE__}" 15 | end 16 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lobsters HTTP 500 status: server error 5 | 6 | 7 |

      Lobsters HTTP 500 status: server error

      8 |

      The page you requested cannot be displayed.

      9 |

      The site operators have been notified of this error. You're welcome to and encouraged to also report it in the IRC channel (also accessible via webchat).

      10 | 11 | 12 | -------------------------------------------------------------------------------- /app/views/hats/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with model: @hat, url: update_in_place_hat_path(@hat), method: :post do |f| %> 2 |
      3 | <%= f.label :user_id, "User" %> 4 | <%= styled_user_link @hat.user %> 5 | 6 | <%= f.label :hat, "Hat" %> 7 | <%= f.text_field :hat %> 8 |
      9 | 10 |
      11 | <%= f.submit "Edit In-Place", formaction: update_in_place_hat_path(@hat) %> 12 | <%= f.submit "Doff & Create", formaction: update_by_recreating_hat_path(@hat) %> 13 |
      14 | <% end %> 15 | -------------------------------------------------------------------------------- /spec/factories/domain.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | FactoryBot.define do 4 | factory :domain do 5 | sequence(:domain) { |n| Faker::Internet.domain_name } 6 | 7 | trait(:banned) do 8 | banned_by_user { association(:user) } 9 | banned_at { Time.current } 10 | banned_reason { "some reason" } 11 | end 12 | 13 | trait(:github_with_selector) do 14 | domain { "github.com" } 15 | selector { "\\Ahttps?://github.com/+([^/]+).*\\z" } 16 | replacement { "github.com/\\1" } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/views/hats/doff.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with url: doff_by_user_hat_path(@hat) do |f| %> 2 |
      3 | <%= f.label :user_id, "User" %> 4 | <%= styled_user_link @hat.user %> 5 | 6 | <%= f.label :hat, "Hat" %> 7 | <%= @hat.to_html_label %> 8 | 9 | <%= f.label :reason, "Reason" %> 10 | 11 | <%= f.text_field :reason %> 12 | 13 | Short public explanation, like "Left project" 14 | 15 | 16 |
      17 | 18 | <%= submit_tag "Doff Hat" %> 19 | <% end %> 20 | -------------------------------------------------------------------------------- /hatchbox/Caddyfile: -------------------------------------------------------------------------------- 1 | # Must manually copy this into the 'Caddyfile' text area on hatchbox's web panel 2 | # and click 'Update Caddy'. 3 | # 4 | # The regular app deploy does not update Caddy config. 5 | import /home/deploy/lobsters/current/hatchbox/Caddyfile.pre* 6 | import /home/deploy/lobsters/shared/etc/Caddyfile.pre* 7 | 8 | # {encode} 9 | %{encode} 10 | 11 | # {file_server} 12 | %{file_server} 13 | 14 | # {default} 15 | %{default} 16 | 17 | import /home/deploy/lobsters/current/hatchbox/Caddyfile.post* 18 | import /home/deploy/lobsters/shared/etc/Caddyfile.post* 19 | -------------------------------------------------------------------------------- /db/migrate/20150211170052_move_hidden_votes_to_hidden_story.rb: -------------------------------------------------------------------------------- 1 | class MoveHiddenVotesToHiddenStory < ActiveRecord::Migration 2 | def up 3 | create_table :hidden_stories do |t| 4 | t.integer :user_id 5 | t.integer :story_id 6 | end 7 | 8 | add_index "hidden_stories", ["user_id", "story_id"], unique: true 9 | 10 | Vote.where(vote: 0).each do |v| 11 | hs = HiddenStory.new 12 | hs.user_id = v.user_id 13 | hs.story_id = v.story_id 14 | hs.save! 15 | end 16 | 17 | Vote.where(vote: 0).delete_all 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /db/migrate/20240717144412_create_links.rb: -------------------------------------------------------------------------------- 1 | class CreateLinks < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :links do |t| 4 | t.string :url, null: false 5 | t.string :normalized_url, null: false 6 | t.index :normalized_url 7 | t.string :title, null: true 8 | 9 | t.bigint :from_story_id, null: true, index: true 10 | t.bigint :from_comment_id, null: true, index: true 11 | t.bigint :to_story_id, null: true, index: true 12 | t.bigint :to_comment_id, null: true, index: true 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/origins/for_domain.html.erb: -------------------------------------------------------------------------------- 1 |

      2 | Domain <%= link_to @domain.domain, @domain %> has <%= "#{number_with_delimiter @origins.count} origin".pluralize(@origins.count) %> 3 |

      4 | 5 |
        6 | <% @origins.each do |origin| %> 7 |
      • 8 | <%= link_to origin.identifier, origin_path(origin) %> <%= "#{origin.stories_count} story".pluralize(origin.stories_count) %> 9 | <% if origin.banned? %> 10 | banned 11 | <% end %> 12 | <%= link_to 'Edit', edit_origin_path(origin) if @user&.is_moderator? %> 13 |
      • 14 | <% end %> 15 |
      16 | -------------------------------------------------------------------------------- /script/lobsters-cron: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | err=0 4 | report() { 5 | err=1 6 | echo -n "error at line ${BASH_LINENO[0]}, in call to " 7 | sed -n ${BASH_LINENO[0]}p $0 8 | } >&2 9 | trap report ERR 10 | 11 | cd /home/deploy/lobsters/current 12 | source /home/deploy/.asdf/asdf.sh 13 | eval "$(/home/deploy/.asdf/bin/asdf vars)" 14 | 15 | if [ -f public/maintenance.html ]; 16 | then 17 | exit 0 18 | fi 19 | 20 | bundle exec ruby script/mail_new_activity.rb & 21 | bundle exec ruby script/mastodon_post.rb & 22 | bundle exec ruby script/send_new_webmentions & 23 | 24 | exit $err 25 | -------------------------------------------------------------------------------- /db/migrate/20180506045709_timestamp_votes.rb: -------------------------------------------------------------------------------- 1 | class TimestampVotes < ActiveRecord::Migration[5.1] 2 | def change 3 | add_column :votes, :updated_at, :datetime, null: true 4 | ActiveRecord::Base.connection.execute("update votes, comments set votes.updated_at = comments.created_at where comments.id = votes.comment_id and votes.updated_at is null") 5 | ActiveRecord::Base.connection.execute("update votes, stories set votes.updated_at = stories.created_at where stories.id = votes.story_id and votes.updated_at is null") 6 | change_column_null :votes, :updated_at, false 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # typed: false 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 | 11 | # Precompile additional assets. 12 | # application.js, application.css, and all non-JS/CSS in the app/assets 13 | # folder are already added. 14 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 15 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # This file contains settings for ActionController::ParamsWrapper which 6 | # is enabled by default. 7 | 8 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 9 | ActiveSupport.on_load(:action_controller) do 10 | wrap_parameters format: [:json] 11 | end 12 | 13 | # To enable root element in JSON for ActiveRecord objects. 14 | # ActiveSupport.on_load(:active_record) do 15 | # self.include_root_in_json = true 16 | # end 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /spec/models/hat_request_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe HatRequest do 6 | it "has a limit on the hat field" do 7 | hat_request = build(:hat_request, hat: "a" * 256) 8 | expect(hat_request).to_not be_valid 9 | end 10 | 11 | it "has a limit on the link field" do 12 | hat_request = build(:hat_request, link: "a" * 256) 13 | expect(hat_request).to_not be_valid 14 | end 15 | 16 | it "has a limit on the comment field" do 17 | hat_request = build(:hat_request, comment: "a" * 65_536) 18 | expect(hat_request).to_not be_valid 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /config/importmap.rb: -------------------------------------------------------------------------------- 1 | # Pin npm packages by running ./bin/importmap 2 | 3 | pin "application" 4 | pin_all_from "app/javascript" 5 | # pin_all_from "vendor/javascripts" 6 | 7 | pin "autosize", to: "autosize.js" 8 | 9 | pin "TomSelect", to: "TomSelect_base.js" 10 | pin "TomSelect_caret_position", to: "TomSelect_caret_position.js" 11 | pin "TomSelect_input_autogrow", to: "TomSelect_input_autogrow.js" 12 | pin "TomSelect_remove_button", to: "TomSelect_remove_button.js" 13 | 14 | pin "tom-select", to: "./vendor/assets/stylesheets/tom-select.css" 15 | pin "tom-remove", to: "./vendor/assets/stylesheets/TomSelect_remove_button.css" 16 | -------------------------------------------------------------------------------- /db/migrate/20250428193204_typeid_fix_token_name.rb: -------------------------------------------------------------------------------- 1 | class TypeidFixTokenName < ActiveRecord::Migration[8.0] 2 | disable_ddl_transaction! 3 | 4 | def change 5 | [ 6 | Category, 7 | Comment, 8 | Domain, 9 | Hat, 10 | HatRequest, 11 | HiddenStory, 12 | Invitation, 13 | InvitationRequest, 14 | Link, 15 | Message, 16 | Moderation, 17 | ModNote, 18 | Origin, 19 | SavedStory, 20 | Story, 21 | Tag, 22 | User 23 | ].each do |model| 24 | rename_column model.table_name, :slug, :token 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | 2 | services: 3 | app: 4 | stdin_open: true 5 | tty: true 6 | build: 7 | context: . 8 | dockerfile: Dockerfile.dev 9 | args: 10 | RUBY_VERSION: ${RUBY_VERSION} 11 | volumes: 12 | - .:/lobsters 13 | ports: 14 | - "3000:3000" 15 | depends_on: 16 | - db 17 | db: 18 | image: "docker.io/library/mariadb:11" 19 | restart: always 20 | environment: 21 | MARIADB_ROOT_PASSWORD: localdev 22 | ports: 23 | - 127.0.0.1:3306:3306 24 | volumes: 25 | - db_data:/var/lib/mysql 26 | 27 | volumes: 28 | db_data: {} 29 | -------------------------------------------------------------------------------- /spec/requests/signup_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe "signup", type: :request do 6 | let!(:inactive_user) { create(:user, :inactive) } 7 | let(:sender) { create(:user) } 8 | let(:invitation) { create(:invitation, user: sender) } 9 | 10 | describe "tattling on logged-in users who visit invite URLs" do 11 | before { sign_in sender } 12 | 13 | it "creates a ModNote" do 14 | expect { 15 | get "/invitations/#{invitation.code}" 16 | }.to change { ModNote.count }.by(2) # one on inviter, one on invitee (though redundnant here) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /app/models/story_repository.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class StoryRepository 4 | def initialize(user = nil, params = {}) 5 | @user = user 6 | @params = params 7 | end 8 | 9 | def categories(cats) 10 | tagged_story_ids = Tagging.select(:story_id).where(tag_id: Tag.where(category: cats).select(:id)) 11 | 12 | Story.base(@user).positive_ranked.where(id: tagged_story_ids).order(created_at: :desc) 13 | end 14 | 15 | def top(length) 16 | top = Story.base(@user).where("created_at >= (NOW() - INTERVAL " \ 17 | "#{length[:dur]} #{length[:intv].upcase})") 18 | top.order(score: :desc) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/migrate/20241104215430_add_comment_stats.rb: -------------------------------------------------------------------------------- 1 | class AddCommentStats < ActiveRecord::Migration[7.2] 2 | def up 3 | create_table :comment_stats do |t| 4 | t.date :date, null: false 5 | t.integer :average, null: false 6 | end 7 | add_index :comment_stats, :date, unique: true 8 | 9 | Comment.connection.execute <<~SQL 10 | insert low_priority into comment_stats (`date`, `average`) 11 | select date(created_at - interval 5 hour) as date, avg(score) from comments group by date(created_at - interval 5 hour) 12 | SQL 13 | end 14 | 15 | def down 16 | drop_table :comment_stats 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/20230823150648_materialize_confidence_order.rb: -------------------------------------------------------------------------------- 1 | class MaterializeConfidenceOrder < ActiveRecord::Migration[7.0] 2 | def up 3 | add_column :comments, :confidence_order, "binary(3)", null: true, after: :story_id 4 | ActiveRecord::Base.connection.execute <<~SQL 5 | UPDATE comments 6 | SET confidence_order = 7 | concat(lpad(char(65536 - floor(((confidence - -0.2) * 65535) / 1.2) using binary), 2, '0'), char(id & 0xff using binary)); 8 | SQL 9 | change_column_null :comments, :confidence_order, false 10 | end 11 | 12 | def down 13 | remove_column :stories, :confidence_order 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/views/users/_flag_warning.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (showing_user:, interval:) -%> 2 |
      3 | Your comments have been heavily flagged across several stories in the last <%= interval[:human] %>. 4 | Review <%= link_to 'your standing', user_standing_path(@user.username) %> for context on how unusual this is. 5 | Reconsider your behavior or take a break. 6 |

      7 | 8 | You could also talk to <%= link_to 'a mod', moderators_path %> about what went wrong, or 9 | delete your account from the bottom of your <%= link_to 'settings', settings_path %> if you prefer to leave. 10 |
      11 | -------------------------------------------------------------------------------- /app/views/login/set_new_password.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_with url: set_new_password_path, autocomplete: 'off' do |f| %> 2 | <%= errors_for(@reset_user) %> 3 | 4 | <%= f.hidden_field "password_reset_token", :value => params[:password_reset_token] %> 5 | 6 |
      7 | <%= f.label :username, "Username" %> 8 | <%= @reset_user.username %> 9 | 10 | <%= f.label :password, "New Password" %> 11 | <%= f.password_field :password %> 12 | 13 | <%= f.label :password_confirmation, "(Again)" %> 14 | <%= f.password_field :password_confirmation %> 15 |
      16 | 17 | <%= f.submit "Set New Password" %> 18 | <% end %> 19 | -------------------------------------------------------------------------------- /app/views/mod/_subnav.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (periods:) -%> 2 | <% content_for :subnav do %> 3 | 4 | <%= link_to_different_page 'Dashboard', mod_path %> 5 | <%= link_to_different_page 'Notes', mod_notes_path(period: '2w') %> 6 | F-Stories: <% periods.each do |p| %><%= link_to_different_page(p, mod_flagged_stories_path(period: p)) %> <% end %> 7 | F-Comments: <% periods.each do |p| %><%= link_to_different_page(p, mod_flagged_comments_path(period: p)) %> <% end %> 8 | Commenters: <% %w{1m 2m 3m 6m}.each do |p| %><%= link_to_different_page(p, mod_commenters_path(period: p)) %> <% end %> 9 | <% end %> 10 | -------------------------------------------------------------------------------- /app/views/suggestions/new.html.erb: -------------------------------------------------------------------------------- 1 |
      2 |
      3 | <%= form_with model: @story, url: story_suggestions_path(@story.short_id), method: :post, html: { id: 'edit_story' } do |f| %> 4 | <%= render :partial => "stories/form", :locals => { :story => @story, 5 | :f => f, :suggesting => true } %> 6 | 7 |

      8 | 9 |
      10 |
      11 | <%= f.submit "Suggest Changes" %> 12 |  or cancel 13 |
      14 |
      15 | <% end %> 16 |
      17 |
      18 | 19 | -------------------------------------------------------------------------------- /app/views/invitation_request_mailer/invitation_request.text.erb: -------------------------------------------------------------------------------- 1 | Hello <%= @invitation_request.email %>, 2 | 3 | Someone at <%= @invitation_request.ip_address %> has submitted an invitation request to 4 | <%= Rails.application.name %>. 5 | 6 | Name: <%= @invitation_request.name %> 7 | E-mail: <%= @invitation_request.email %> (won't be displayed to other users) 8 | Memo: <%= @invitation_request.memo %> 9 | 10 | If this is you, visit the URL below to confirm your request and 11 | display it to other logged-in users. 12 | 13 | <%= Rails.application.root_url %>invitations/confirm/<%= @invitation_request.code %> 14 | 15 | If this is not you, you can delete this message. 16 | -------------------------------------------------------------------------------- /lib/time_series.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class TimeSeries < SVG::Graph::TimeSeries 4 | include ActionView::Helpers::NumberHelper 5 | 6 | # these two methods are a patch on the gem's lack of time zone awareness 7 | def format x, y, description 8 | [ 9 | Time.at(x).utc.strftime(popup_format), 10 | number_with_delimiter(y), 11 | description 12 | ].compact.join(", ") 13 | end 14 | 15 | def get_x_labels 16 | get_x_values.collect { |v| Time.at(v).utc.strftime(x_label_format) } 17 | end 18 | 19 | # improves y axis labels with commas 20 | def get_y_labels 21 | get_y_values.collect { |v| number_with_delimiter(v.to_i) } 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /db/migrate/20120701154453_create_invitations.rb: -------------------------------------------------------------------------------- 1 | # If you are reading this file because Rails complains that there isn't a 2 | # version on this migration, stop. 3 | # 4 | # Create your database with `rails db:schema:load`, not by running all these. 5 | # We have migrations to migrate live databases, not create them, and we do not 6 | # want a PR to 'fix' migrations. 7 | class CreateInvitations < ActiveRecord::Migration 8 | def change 9 | create_table :invitations do |t| 10 | t.integer :user_id 11 | t.string :email 12 | t.string :code 13 | t.timestamps null: false 14 | end 15 | 16 | add_column :users, :invited_by_user_id, :integer 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/20120703184957_integrated_story_hotness.rb: -------------------------------------------------------------------------------- 1 | class IntegratedStoryHotness < ActiveRecord::Migration 2 | def up 3 | add_column :stories, :hotness, :decimal, precision: 20, scale: 10 4 | add_column :comments, :confidence, :decimal, precision: 20, scale: 19 5 | 6 | add_index :stories, ["hotness"], name: "hotness_idx" 7 | add_index :comments, ["confidence"], name: "confidence_idx" 8 | 9 | Comment.all.each do |c| 10 | c.give_upvote_or_downvote_and_recalculate_confidence!(0, 0) 11 | end 12 | 13 | Story.all.each do |s| 14 | s.give_upvote_or_downvote_and_recalculate_hotness!(0, 0) 15 | end 16 | end 17 | 18 | def down 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/fixtures/inbound_emails/1.eml: -------------------------------------------------------------------------------- 1 | Date: Tue, 16 Jul 2013 10:37:11 -0500 2 | From: joshua stein 3 | To: ##SHORTNAME##-##MAILING_LIST_TOKEN##@lobste.rs 4 | Subject: Re: Lobsters by mail [announce] 5 | Message-ID: <20130716103519.78e3be260a@5d7607215bd9704> 6 | References: 7 | 8 | MIME-Version: 1.0 9 | Content-Type: text/plain; charset=us-ascii 10 | Content-Disposition: inline 11 | In-Reply-To: 12 | X-No-Archive: Yes 13 | 14 | It hasn't decreased any measurable amount but since the traffic to 15 | the site is increasing a bit each week, it's hard to tell. 16 | -------------------------------------------------------------------------------- /app/views/signup/index.html.erb: -------------------------------------------------------------------------------- 1 |
      2 | <% if Rails.application.open_signups? %> 3 |

      4 | Not a user yet? Sign up here. 5 |

      6 | <% else %> 7 |

      8 | Signup is currently by invitation only to combat spam and increase 9 | accountability. If you know <%= link_to 'a current user', users_tree_path %> 10 | of the site, ask them for an invitation, or 11 | <% if Rails.application.allow_invitation_requests? %> 12 | request one publicly. 13 | <% else %> 14 | request one in chat. 15 | <% end %> 16 |

      17 | <% end %> 18 |
      19 | -------------------------------------------------------------------------------- /spec/controllers/messages_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe MessagesController do 6 | include ActiveJob::TestHelper 7 | 8 | after do 9 | clear_enqueued_jobs 10 | end 11 | 12 | let(:recipient) { create(:user) } 13 | let(:sender) { create(:user) } 14 | 15 | describe "POST create" do 16 | it "schedules a notification job" do 17 | stub_login_as sender 18 | post :create, params: {message: {recipient_username: recipient.username, subject: "hello", body: "secret message"}} 19 | expect(response.status).to eq(302) 20 | expect(NotifyMessageJob).to have_been_enqueued.exactly(:once) 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/authentication_helper.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | module AuthenticationHelper 4 | module ControllerHelper 5 | def stub_login_as user 6 | session[:u] = user.session_token 7 | end 8 | end 9 | 10 | module FeatureHelper 11 | def stub_login_as user 12 | # feature specs don't have access to the session store 13 | visit "/login" 14 | fill_in "E-mail or Username", with: user.email 15 | fill_in "Password", with: user.password 16 | click_button "Login" 17 | end 18 | end 19 | 20 | module RequestHelper 21 | def sign_in user 22 | post "/login", params: {email: user.email, password: user.password} 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/controllers/comments_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe CommentsController do 6 | include ActiveJob::TestHelper 7 | 8 | after do 9 | clear_enqueued_jobs 10 | end 11 | 12 | let(:author) { create(:user) } 13 | let(:story) { create(:story, user: author) } 14 | let(:reader) { create(:user) } 15 | 16 | describe "POST create" do 17 | it "schedules a notification job" do 18 | stub_login_as reader 19 | post :create, params: {story_id: story.short_id, comment: "great story!"} 20 | expect(response.status).to eq(302) 21 | expect(NotifyCommentJob).to have_been_enqueued.exactly(:once) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/controllers/story_urls_controller.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class StoryUrlsController < ApplicationController 4 | def all 5 | url = params.require(:url) 6 | 7 | respond_to do |format| 8 | format.json { 9 | render json: Story.find_similar_by_url(url).for_presentation 10 | } 11 | end 12 | end 13 | 14 | def latest 15 | url = params.require(:url) 16 | 17 | similar_stories = Story.find_similar_by_url(url) 18 | if similar_stories.any? 19 | redirect_to Routes.title_path similar_stories.first 20 | elsif @user 21 | redirect_to new_story_path, url: url 22 | else 23 | raise ActiveRecord::RecordNotFound 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /app/models/hidden_story.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class HiddenStory < ApplicationRecord 4 | belongs_to :user 5 | belongs_to :story 6 | 7 | scope :by, ->(user) { where(user: user) } 8 | 9 | include Token 10 | 11 | def self.hide_story_for_user(story, user) 12 | HiddenStory.where(story: story, user: user).first_or_initialize.save! 13 | story.update_score_and_recalculate!(0, 0) 14 | ReadRibbon.hide_replies_for(story.id, user.id) 15 | end 16 | 17 | def self.unhide_story_for_user(story, user) 18 | HiddenStory.where(story: story, user: user).delete_all 19 | story.update_score_and_recalculate!(0, 0) 20 | ReadRibbon.unhide_replies_for(story.id, user.id) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /db/migrate/20140101202252_add_karma_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddKarmaToUsers < ActiveRecord::Migration 2 | def up 3 | add_column :users, :karma, :integer, default: 0, null: false 4 | 5 | Keystore.transaction do 6 | User.lock(true).select(:id).each do |u| 7 | u.update_column :karma, Keystore.value_for("user:#{u.id}:karma").to_i 8 | end 9 | 10 | Keystore.where(Keystore.arel_table[:key].matches("user:%:karma")).delete_all 11 | end 12 | end 13 | 14 | def down 15 | Keystore.transaction do 16 | User.select(:id, :karma).each do |u| 17 | Keystore.put("user:#{u.id}:karma", u.karma) 18 | end 19 | end 20 | 21 | remove_column :users, :karma 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | # Use an official Ruby image as a base 2 | ARG RUBY_VERSION 3 | FROM ruby:${RUBY_VERSION} 4 | 5 | # Install dependencies 6 | RUN apt-get update -qq && apt-get install -y \ 7 | build-essential \ 8 | libjemalloc2 \ 9 | libvips \ 10 | mariadb-client \ 11 | vim 12 | 13 | # Set working directory 14 | WORKDIR /lobsters 15 | 16 | # Install Bundler 17 | RUN gem install bundler 18 | 19 | # Copy Gemfile and Gemfile.lock 20 | COPY Gemfile Gemfile.lock ./ 21 | 22 | # Install gems 23 | RUN bundle install 24 | 25 | 26 | ENV DATABASE_HOST db 27 | 28 | # Expose port for Rails server 29 | EXPOSE 3000 30 | 31 | # Command to start the Rails server 32 | CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"] 33 | -------------------------------------------------------------------------------- /app/views/mod_notes/_table.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (mod_notes:) -%> 2 | 3 | 4 | 5 | 6 | 7 | <% mod_notes.each do |note| %> 8 | 9 | 18 | 19 | 20 | <% end %> 21 |
      Mod → User/WhenNote
      10 | <% if note.moderator != InactiveUser.inactive_user %> 11 | <%= note.moderator.username %> 12 | → 13 | <% end %> 14 | <%= link_to note.user.username, user_path(note.user) %> 15 |
      16 | <%= raw note.created_at.strftime("%Y-%m-%d %H:%M %z") %> 17 |
      <%= raw note.markeddown_note %>
      22 | -------------------------------------------------------------------------------- /db/migrate/20120705145520_move_markdown_into_sql.rb: -------------------------------------------------------------------------------- 1 | class MoveMarkdownIntoSql < ActiveRecord::Migration 2 | def up 3 | add_column :comments, :markeddown_comment, :text 4 | add_column :stories, :markeddown_description, :text 5 | 6 | Comment.all.each do |c| 7 | c.markeddown_comment = c.generated_markeddown_comment 8 | Comment.record_timestamps = false 9 | c.save(validate: false) 10 | end 11 | 12 | Story.all.each do |s| 13 | if s.description.present? 14 | s.markeddown_description = s.generated_markeddown_description 15 | Story.record_timestamps = false 16 | s.save(validate: false) 17 | end 18 | end 19 | end 20 | 21 | def down 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/tasks/privacy_wipe.rake: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | desc "Wipe private data if site is changing hands" 4 | task privacy_wipe: :environment do 5 | fail "Refusing to wipe. Read and edit this task if your site is really changing hands" 6 | 7 | # It'll be really easy for this rarely-used code to slip out-of-sync, 8 | # you MUST review how users are banned/deleted before you run this. 9 | # At the least, check User#delete! and LoginController. 10 | # User.where.not(deleted_at: nil) 11 | # .update_all("password_digest = '*', email = concat(username, '@lobsters.example')") 12 | 13 | # wipe all moderator notes: 14 | # ModNote.delete_all 15 | 16 | # wipe all private messages: 17 | # Message.delete_all 18 | end 19 | -------------------------------------------------------------------------------- /app/controllers/concerns/story_finder.rb: -------------------------------------------------------------------------------- 1 | module StoryFinder 2 | extend ActiveSupport::Concern 3 | 4 | def find_story 5 | story = Story.find_by(short_id: params[:story_id] || params[:id]) 6 | # convenience to use PK (from external queries) without generally permitting enumeration: 7 | story ||= Story.find(params[:id]) if @user&.is_admin? 8 | 9 | if @user && story 10 | story.current_vote = Vote.find_by( 11 | user: @user, 12 | story: story.id, 13 | comment: nil 14 | ).try(:vote) 15 | end 16 | 17 | story 18 | end 19 | 20 | def find_story! 21 | @story = find_story 22 | if !@story 23 | raise ActiveRecord::RecordNotFound 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /public/502.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Lobsters HTTP 502 status: puma timeout 5 | 6 | 7 |

      Lobsters HTTP 502 status: server error

      8 |

      9 | nginx tried to pass your request to puma but failed. 10 | This probably means we're in the middle of a deploy. 11 | If this persists for more than 120 seconds, please come hassle us in the 12 | IRC channel 13 | (webchat). 14 |

      15 | 16 |

      17 | It is safe to reload until the page loads and resubmit a POST request, 18 | you won't double-post a comment or anything. 19 |

      20 | 21 | 22 | -------------------------------------------------------------------------------- /spec/fixtures/inbound_emails/3.eml: -------------------------------------------------------------------------------- 1 | Date: Tue, 16 Jul 2013 10:37:11 -0500 2 | From: joshua stein 3 | To: ##SHORTNAME##-##MAILING_LIST_TOKEN##@lobste.rs 4 | Subject: Re: Lobsters by mail [announce] 5 | Message-ID: <20130716103519.78e3be260a@5d7607215bd9704> 6 | References: 7 | 8 | MIME-Version: 1.0 9 | Content-Type: text/plain; charset=us-ascii 10 | Content-Disposition: inline 11 | In-Reply-To: 12 | X-No-Archive: Yes 13 | 14 | It hasn't decreased any measurable amount but since the traffic to 15 | the site is increasing a bit each week, it's hard to tell. 16 | 17 | -- 18 | this is my signature 19 | -------------------------------------------------------------------------------- /db/migrate/20140109034338_move_comment_counts_to_story.rb: -------------------------------------------------------------------------------- 1 | class MoveCommentCountsToStory < ActiveRecord::Migration 2 | def up 3 | add_column :stories, :comments_count, :integer, default: 0, 4 | null: false 5 | 6 | Keystore.transaction do 7 | Story.lock(true).select(:id).each do |s| 8 | s.update_comments_count! 9 | end 10 | 11 | Keystore.where( 12 | Keystore.arel_table[:key].matches("story:%:comment_count") 13 | ).delete_all 14 | end 15 | end 16 | 17 | def down 18 | Keystore.transaction do 19 | Story.select(:id).each do |s| 20 | s.update_comments_count! 21 | end 22 | end 23 | 24 | remove_column :stories, :comments_count 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/jobs/notify_message_job_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe NotifyMessageJob, type: :job do 4 | describe "message notifications" do 5 | it "creates & sends a message notification" do 6 | recipient = build(:user) 7 | recipient.settings["email_messages"] = true 8 | recipient.save! 9 | message = create(:message, recipient: recipient) 10 | NotifyMessageJob.perform_now(message) 11 | expect(recipient.notifications.count).to eq(1) 12 | expect(recipient.notifications.first.notifiable).to eq(message) 13 | expect(sent_emails.size).to eq(1) 14 | expect(sent_emails[0].subject).to match(/Private Message from #{message.author_username}/) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20140804005415_add_weblogs.rb: -------------------------------------------------------------------------------- 1 | class AddWeblogs < ActiveRecord::Migration 2 | def change 3 | create_table :weblogs do |t| 4 | t.timestamps 5 | t.integer :user_id 6 | t.string :title, limit: 512 7 | t.string :url, limit: 512 8 | t.string :site_title, limit: 512 9 | t.string :site_url, limit: 512 10 | t.text :content, limit: 16777215 # mediumtext 11 | t.text :tags 12 | t.string :uuid 13 | end 14 | 15 | # why can't the charset be specified in the create_table? 16 | execute("ALTER TABLE weblogs MODIFY `uuid` varchar(200) CHARACTER SET utf8") 17 | 18 | add_index "weblogs", ["user_id", "uuid"], name: "user_and_uuid", 19 | unique: true 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/models/replying_comment.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class ReplyingComment < ApplicationRecord 4 | attribute :is_unread, :boolean 5 | 6 | belongs_to :comment 7 | 8 | scope :for_user, ->(user_id) { 9 | where(user_id: user_id) 10 | .order(comment_created_at: :desc) 11 | .preload(comment: [:hat, {story: :user}, :user]) 12 | } 13 | scope :unread_replies_for, ->(user_id) { for_user(user_id).where(is_unread: true) } 14 | scope :comment_replies_for, 15 | ->(user_id) { for_user(user_id).where.not(parent_comment_id: nil) } 16 | scope :story_replies_for, ->(user_id) { for_user(user_id).where(parent_comment_id: nil) } 17 | 18 | protected 19 | 20 | # This is a view, not a real table 21 | def readonly? 22 | true 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/views/messages/_subnav.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: () -%> 2 | <% content_for :subnav do %> 3 | <%= link_to_different_page('Unread', replies_unread_path) %> 4 | <%= link_to_different_page('All', replies_path) %> 5 | <%= link_to_different_page('Comments', replies_comments_path) %> 6 | <%= link_to_different_page('Stories', replies_stories_path) %> 7 | | 8 | <%= link_to_different_page 'Messages', messages_path %> 9 | <%= link_to_different_page 'Sent', messages_sent_path %> 10 | <% if controller_name == 'notifications' || @user&.is_moderator? %> 11 | | 12 | <%= link_to_different_page 'All Notifications', notifications_path %> 13 | <%= link_to_different_page 'Unread Notifications', notifications_unread_path %> 14 | <% end %> 15 | <% end %> 16 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # typed: false 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) do |inflect| 17 | # inflect.acronym "RESTful" 18 | # end 19 | -------------------------------------------------------------------------------- /app/controllers/csp_controller.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class CspController < ApplicationController 4 | skip_before_action :verify_authenticity_token 5 | skip_before_action :authenticate_user 6 | 7 | def violation_report 8 | body = request.body.read 9 | json = JSON.parse(body) 10 | 11 | return head :bad_request unless 12 | request.content_type == "application/csp-report" && json&.is_a?(Hash) && json["csp-report"].is_a?(Hash) 13 | 14 | report = json["csp-report"] 15 | Telebugs.context :report, report 16 | Telebugs.message body, fingerprint: ["csp-violation", report.dig("blocked-uri"), report.dig("effective-directive")] 17 | 18 | head :ok 19 | rescue JSON::ParserError 20 | head :bad_request 21 | nil 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /app/views/stories/_missing.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (story:, moderation: nil) -%> 2 |

      3 | <% if !@user %> 4 | <% 5 | # Logged-out visitors don't get details so we don't make a convenient 6 | # public monument to content that was so bad or bizarre we removed it. 7 | %> 8 | Story was removed. 9 | <% elsif story.is_moderated? %> 10 | <% if moderation %> 11 | Story removed by moderator: <%= moderation.reason %> 12 | <% else %> 13 | Story removed by moderator but no log message was found. 14 | Please report this bug. 15 | <% end %> 16 | <% else %> 17 | Story was removed by submitter<% if @user %> <%= link_to story.user.username, user_path(story.user) %><% end %>. 18 | <% end %> 19 |

      20 | -------------------------------------------------------------------------------- /public/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Lobsters 4 | Computing-focused community centered around link aggregation and discussion 5 | 6 | 7 | https://lobste.rs/favicon.ico 8 | en 9 | https://lobste.rs/search 10 | 11 | -------------------------------------------------------------------------------- /db/migrate/20241106160424_tidy_duplicate_origins.rb: -------------------------------------------------------------------------------- 1 | class TidyDuplicateOrigins < ActiveRecord::Migration[7.2] 2 | def up 3 | Origin.select(:identifier).group(:identifier).having("count(*) > 1").pluck(:identifier).each do |identifier| 4 | primary, *dupes = Origin.where(identifier: identifier).to_a 5 | 6 | # reassign to ensure correct Domain is associated 7 | primary.identifier = identifier 8 | 9 | # move Stories from dupes and destroy them 10 | dupes.each do |dupe| 11 | dupe.stories.update_all(origin_id: primary.id) 12 | dupe.destroy! 13 | end 14 | end 15 | 16 | add_index :origins, :identifier, unique: true 17 | end 18 | 19 | def down 20 | raise ActiveRecord::IrreversibleMigration 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-or-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug or feature request 3 | about: All-purpose issue 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 17 | -------------------------------------------------------------------------------- /db/migrate/20250628172500_create_notifications.rb: -------------------------------------------------------------------------------- 1 | class CreateNotifications < ActiveRecord::Migration[8.0] 2 | def change 3 | create_table :notifications, id: {type: :bigint, unsigned: true} do |t| 4 | # We shouldn't need an index on the user_id alone because the unique index below provides user_id in the leftmost column 5 | t.references :user, null: false, foreign_key: true, type: :bigint, unsigned: true, index: false 6 | t.references :notifiable, polymorphic: true, null: false, type: :bigint, unsigned: true 7 | t.datetime :read_at 8 | t.string :token, null: false 9 | 10 | t.timestamps 11 | 12 | t.index [:user_id, :notifiable_type, :notifiable_id], unique: true 13 | t.index [:token], unique: true 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/views/notifications/_message.html.erb: -------------------------------------------------------------------------------- 1 |
      2 |
      3 |
      4 | 13 |
      14 | <%= message.subject %> 15 |
      16 |
      17 | <%= message.body %> 18 |
      19 |
      20 |
      21 | -------------------------------------------------------------------------------- /spec/features/doff_hat_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | RSpec.feature "Doffing Hats" do 6 | let(:user) { create(:user) } 7 | let(:hat) { create(:hat, user: user) } 8 | 9 | before(:each) { stub_login_as user } 10 | 11 | scenario "doffing hat with reason" do 12 | hat.reload 13 | visit user_path(user) 14 | expect(page).to have_selector(:link_or_button, "Doff") 15 | 16 | doffing_reason = "Left project" 17 | click_on "Doff" 18 | visit "/hats/#{hat.short_id}/doff" 19 | fill_in "reason", with: doffing_reason 20 | click_on "Doff Hat" 21 | expect(page).to have_content("doffed") 22 | 23 | mod = Moderation.last 24 | expect(mod.action).to start_with "Doffed hat" 25 | expect(mod.reason).to eq(doffing_reason) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/views/home/_for_origin.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (origin:, stories:) -%> 2 |

      3 | <%= origin.identifier %> (part of <%= link_to origin.domain.domain, origin.domain %>): 4 | <%= "#{number_with_delimiter origin.stories_count} story".pluralize(origin.stories_count) %> from 5 | <%= "#{number_with_delimiter origin.n_submitters} submitter".pluralize(origin.n_submitters) %> 6 | 7 | <%= link_to 'Edit', edit_origin_path(origin) if @user&.is_moderator? %> 8 |

      9 | 10 | 11 | 12 | <% if origin.banned? %> 13 |

      14 | Banned 15 | <%= how_long_ago_label(origin.banned_at) %> 16 | <% if origin.banned_by_user %> 17 | by <%= link_to origin.banned_by_user.try(:username), origin.banned_by_user %>: 18 | <%= origin.banned_reason %> 19 | <% end %> 20 |

      21 | <% end %> 22 | 23 | -------------------------------------------------------------------------------- /db/migrate/20240920115957_create_action_mailbox_tables.action_mailbox.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from action_mailbox (originally 20180917164000) 2 | class CreateActionMailboxTables < ActiveRecord::Migration[6.0] 3 | def change 4 | create_table :action_mailbox_inbound_emails, id: primary_key_type do |t| 5 | t.integer :status, default: 0, null: false 6 | t.string :message_id, null: false 7 | t.string :message_checksum, null: false 8 | 9 | t.timestamps 10 | 11 | t.index [:message_id, :message_checksum], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true 12 | end 13 | end 14 | 15 | private 16 | 17 | def primary_key_type 18 | config = Rails.configuration.generators 19 | config.options[config.orm][:primary_key_type] || :primary_key 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/initializers/scenic.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | scenic_multidb_adapter = Class.new do 4 | def initialize 5 | @mysql = Scenic::Adapters::MySQL.new 6 | @sqlite = Scenic::Adapters::Sqlite.new 7 | end 8 | 9 | def method_missing(...) 10 | adapter.__send__(...) 11 | end 12 | 13 | def respond_to_missing? name, include_private 14 | adapter.respond_to? name 15 | end 16 | 17 | private 18 | 19 | def adapter 20 | case ActiveRecord::Base.connection 21 | when ActiveRecord::ConnectionAdapters::TrilogyAdapter 22 | @mysql 23 | when ActiveRecord::ConnectionAdapters::SQLite3Adapter 24 | @sqlite 25 | else 26 | raise "Unsupported adapter" 27 | end 28 | end 29 | end 30 | 31 | Scenic.configure do |config| 32 | config.database = scenic_multidb_adapter.new 33 | end 34 | -------------------------------------------------------------------------------- /spec/models/hat_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe Hat do 6 | it "has a hat field" do 7 | hat = Hat.new(hat: nil) 8 | expect(hat).to_not be_valid 9 | end 10 | 11 | it "has a limit on the hat field" do 12 | hat = build(:hat, hat: "a" * 256) 13 | expect(hat).to_not be_valid 14 | end 15 | 16 | it "has a limit on the link field" do 17 | hat = build(:hat, link: "a" * 256) 18 | expect(hat).to_not be_valid 19 | end 20 | 21 | it "santizes email addresses" do 22 | hat = build(:hat, link: "foo@bar.com") 23 | expect(hat.sanitized_link).to eq("bar.com") 24 | end 25 | 26 | it "doesn't sanitize links that aren't email addressees" do 27 | hat = build(:hat, link: "google.com") 28 | expect(hat.sanitized_link).to eq("google.com") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /app/views/home/_single_tag.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (tag:, related:) -%> 2 |
      3 |

      4 | Stories tagged as 5 | <%= tag_link(tag) %> 6 | <%= tag.description %> 7 |
      8 | <% if tag.hotness_mod != 0.0 %> 9 | Hotness modifier <%= tag.hotness_mod %> 10 | (<%= tag.hotness_mod > 0 ? 'raises' : 'lowers' %> a story's rank). 11 | <% end %> 12 | <% if tag.privileged %> 13 | Only mods can use this tag. 14 | <% elsif !tag.permit_by_new_users %> 15 | Tag is not permitted on stories submitted by new users. 16 | <% end %> 17 |

      18 | 19 | 20 |

      21 | Most often also tagged with 22 | <% related.each do |tag| %> 23 | <%= tag_link(tag) %> 24 | <% end %> 25 |

      26 |
      27 | 28 | -------------------------------------------------------------------------------- /bin/faker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'faker' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("faker", "faker") 28 | -------------------------------------------------------------------------------- /bin/puma: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'puma' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("puma", "puma") 28 | -------------------------------------------------------------------------------- /bin/racc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'racc' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("racc", "racc") 28 | -------------------------------------------------------------------------------- /bin/rotp: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rotp' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rotp", "rotp") 28 | -------------------------------------------------------------------------------- /bin/thor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'thor' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("thor", "thor") 28 | -------------------------------------------------------------------------------- /app/views/stories/new.html.erb: -------------------------------------------------------------------------------- 1 |
      2 |
      3 | <%= form_with model: @story do |f| %> 4 | <%= render :partial => "stories/form", :locals => { :story => @story, :f => f } %> 5 | 6 |

      7 | 8 |
      9 |
      10 | <%= render :partial => "global/markdownhelp", 11 | :locals => { allow_images: @story.can_have_images? } %> 12 | 13 | <%= f.submit "Submit" %> 14 | 15 |   16 | <%= f.submit "Preview", class: 'story-preview', name: 'preview' %> 17 |
      18 |
      19 | <% end %> 20 |
      21 |
      22 | 23 |
      24 | <% if @story.previewing && @story.valid? %> 25 | <%= render :template => "stories/show" %> 26 | <% end %> 27 |
      28 | -------------------------------------------------------------------------------- /bin/byebug: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'byebug' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("byebug", "byebug") 28 | -------------------------------------------------------------------------------- /bin/ldiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ldiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("diff-lcs", "ldiff") 28 | -------------------------------------------------------------------------------- /bin/listen: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'listen' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("listen", "listen") 28 | -------------------------------------------------------------------------------- /bin/oauth: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'oauth' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("oauth-tty", "oauth") 28 | -------------------------------------------------------------------------------- /bin/pumactl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'pumactl' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("puma", "pumactl") 28 | -------------------------------------------------------------------------------- /bin/rackup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rackup' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rack", "rackup") 28 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rspec-core", "rspec") 28 | -------------------------------------------------------------------------------- /app/views/settings/twofa_verify.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :subnav do %> 2 | Back to Settings 3 | <% end %> 4 | 5 |
      6 | <%= form_with url: twofa_update_url do |f| %> 7 |

      8 | To enable Two-Factor Authentication on your account using your new TOTP 9 | secret, enter the six-digit code from your TOTP application: 10 |

      11 | 12 |
      13 | <%= f.label :totp_code, "TOTP Code:", :class => "required" %> 14 | <%= f.text_field :totp_code, 15 | :inputmode => "numeric", 16 | :pattern => "[0-9]+", 17 | :size => 10, 18 | :autocomplete => "one-time-code", 19 | :autofocus => true, 20 | :class => "totp_code" %> 21 |
      22 | 23 |

      24 | <%= f.submit "Verify and Enable" %> 25 |

      26 | <% end %> 27 |
      28 | -------------------------------------------------------------------------------- /bin/ascii85: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ascii85' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("Ascii85", "ascii85") 28 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rubocop' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rubocop", "rubocop") 28 | -------------------------------------------------------------------------------- /db/migrate/20250124063340_add_last_comment_at_to_stories.rb: -------------------------------------------------------------------------------- 1 | class AddLastCommentAtToStories < ActiveRecord::Migration[8.0] 2 | def up 3 | add_column :stories, :last_comment_at, :datetime 4 | add_index :stories, :last_comment_at 5 | 6 | # Add SQL to populate the field for all old stories 7 | # that have comments. This is a backfill operation. 8 | ActiveRecord::Base.connection.execute <<~SQL 9 | UPDATE stories 10 | JOIN ( 11 | SELECT story_id, MAX(created_at) AS max_created_at 12 | FROM comments 13 | GROUP BY story_id 14 | ) subquery 15 | ON stories.id = subquery.story_id 16 | SET stories.last_comment_at = subquery.max_created_at; 17 | SQL 18 | end 19 | 20 | def down 21 | remove_index :stories, :last_comment_at 22 | remove_column :stories, :last_comment_at 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/features/hats_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | RSpec.feature "Viewing Hats page", type: :feature do 6 | feature "when logged out" do 7 | scenario "cannot see a user's email" do 8 | hat = create(:hat, link: "foo@bar.com") 9 | user_with_hat = create(:user, hats: [hat]) 10 | 11 | visit "/hats" 12 | 13 | expect(page).to have_content("bar.com") 14 | expect(page).to_not have_content(user_with_hat.hats.first.link) 15 | end 16 | end 17 | 18 | feature "when logged in" do 19 | scenario "can see a user's hats" do 20 | hat = create(:hat, link: "foo@bar.com") 21 | user_with_hat = create(:user, hats: [hat]) 22 | stub_login_as user_with_hat 23 | 24 | visit "/hats" 25 | 26 | expect(page).to have_content(user_with_hat.hats.first.link) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /bin/htmldiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'htmldiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("diff-lcs", "htmldiff") 28 | -------------------------------------------------------------------------------- /bin/nokogiri: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'nokogiri' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("nokogiri", "nokogiri") 28 | -------------------------------------------------------------------------------- /bin/pdf_text: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'pdf_text' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("pdf-reader", "pdf_text") 28 | -------------------------------------------------------------------------------- /app/models/inactive_user.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | module InactiveUser 4 | def self.inactive_user 5 | @inactive_user ||= User.find_by!(username: "inactive-user") 6 | end 7 | 8 | def self.disown! comment_or_story 9 | author = comment_or_story.user 10 | comment_or_story.update_column(:user_id, inactive_user.id) 11 | refresh_counts! author 12 | end 13 | 14 | def self.disown_all_by_author! author 15 | # leave attribution on deleted stuff, which is generally very relevant to mods 16 | # when looking back at returning users 17 | author.stories.not_deleted(nil).update_all(user_id: inactive_user.id) 18 | author.comments.active.update_all(user_id: inactive_user.id) 19 | refresh_counts! author 20 | end 21 | 22 | def self.refresh_counts! user 23 | user&.refresh_counts! 24 | inactive_user.refresh_counts! 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /bin/pdf_object: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'pdf_object' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("pdf-reader", "pdf_object") 28 | -------------------------------------------------------------------------------- /bin/ruby-parse: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-parse' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("parser", "ruby-parse") 28 | -------------------------------------------------------------------------------- /bin/stackprof: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'stackprof' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("stackprof", "stackprof") 28 | -------------------------------------------------------------------------------- /bin/standardrb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'standardrb' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("standard", "standardrb") 28 | -------------------------------------------------------------------------------- /bin/ruby-rewrite: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-rewrite' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("parser", "ruby-rewrite") 28 | -------------------------------------------------------------------------------- /bin/commonmarker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'commonmarker' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("commonmarker", "commonmarker") 28 | -------------------------------------------------------------------------------- /bin/pdf_callbacks: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'pdf_callbacks' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("pdf-reader", "pdf_callbacks") 28 | -------------------------------------------------------------------------------- /config/initializers/00_zeitwerk.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # This file is a relic of how Lobsters used to manage API keys and some other config variables. 4 | # It only exists to give developers and sister sites an error message as they setup. 5 | 6 | if Rails.application.credentials.secret_key_base.blank? 7 | config = <<~CONFIG 8 | ** SETUP REQUIRED 9 | 10 | The lobsters codebase manages API keys using the new (to us) Rails credentials feature. 11 | 12 | Look for "credentials" in README.md for setup instructions and a template. 13 | CONFIG 14 | migrate = <<~MIGRATE 15 | 16 | If you used the old config/initializers/production.rb method, your API keys are there and can be removed from that file after copying them to credentials. 17 | MIGRATE 18 | 19 | if Rails.env.production? 20 | raise config + migrate 21 | else 22 | raise config 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/extras/routes_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe Routes do 6 | it "has access to rails routes" do 7 | expect(Routes.root_path).to eq "/" 8 | end 9 | 10 | it "routes to stories with titles as slugs" do 11 | s = Story.new(short_id: "abc123", title: "Hello world!") 12 | expect(Routes.title_path(s)).to eq "/s/abc123/hello_world" 13 | end 14 | 15 | it "routes title_path with anchor" do 16 | s = Story.new(short_id: "abc123", title: "Hello world!") 17 | expect(Routes.title_path(s, anchor: "footer")).to eq "/s/abc123/hello_world#footer" 18 | end 19 | 20 | it "routes to comments with anchors" do 21 | s = Story.new(short_id: "abc123", title: "Hello world!") 22 | c = Comment.new(story: s, short_id: "def456") 23 | expect(Routes.comment_target_path(c, true)).to eq "/s/abc123/hello_world#c_def456" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/home/_for_domain.html.erb: -------------------------------------------------------------------------------- 1 | <%# locals: (domain:, stories:) -%> 2 |

      3 | <%= domain.domain %>: 4 | <%= "#{number_with_delimiter domain.stories_count} story".pluralize(domain.stories_count) %> from 5 | <%= "#{number_with_delimiter domain.n_submitters} submitter".pluralize(domain.n_submitters) %> 6 | <% if domain.origins.count > 0 %> 7 | with <%= link_to "#{number_with_delimiter domain.origins.count} origins", domain_origins_path(domain) %> 8 | <% end %> 9 | 10 | <%= link_to 'Edit', edit_domain_path(domain) if @user&.is_moderator? %> 11 |

      12 | 13 | <% if domain.banned? %> 14 |

      15 | Banned 16 | <%= how_long_ago_label(domain.banned_at) %> 17 | <% if domain.banned_by_user %> 18 | by <%= link_to domain.banned_by_user.try(:username), domain.banned_by_user %>: 19 | <%= domain.banned_reason %> 20 | <% end %> 21 |

      22 | <% end %> 23 | 24 | -------------------------------------------------------------------------------- /bin/ruby-memory-profiler: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ruby-memory-profiler' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("memory_profiler", "ruby-memory-profiler") 28 | -------------------------------------------------------------------------------- /bin/stackprof-gprof2dot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'stackprof-gprof2dot.py' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("stackprof", "stackprof-gprof2dot.py") 28 | -------------------------------------------------------------------------------- /bin/stackprof-flamegraph.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'stackprof-flamegraph.pl' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("stackprof", "stackprof-flamegraph.pl") 28 | -------------------------------------------------------------------------------- /.github/workflows/check/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | zcS5WjQmGyGNQx3HkAKuLBq5IA8Fvn4WUBNxDIs5H+WldnNvnknuFny1BhBOmqwxPm0cepASiWKlAOyqCtSLXHyVVfxKn7QolWc7XakXh5jneSZR5lENRfdv518vSH81zIBMjeodsjvO7qu219yLjK5C5bXJNTfZDJl5P4LUYUgQadMRUnSIyLuWao5Plh4nN/iavcOcPKsxIXDIdbDtVX/yMJ00HtkK08GbUS6R5i7tMyi2Q6l+g4UuuCh3GJYtRbAxGKuNW0HvxXUZc72vALLxI+JHeNC2ldP4xb/r4HtjJWjVdDWXMyxZfGvh3TN4kUNWmV5UKqPXfuTSdCCCjxdID0Efh0+gaIejlFAsaXO9r3ydpGzFmeZCg85UpIKzgJ7TEDmdEPVUaYhKnkIhmPD3HKaaKinalBcSvyot5ig7csCJCA0DNtLT3XwBMlqCUebbWZNFWhVUqsynDXAd7QbChYKNL03rao8ahMvYHwf7mC+GJ0Uz/1bBT/NmpJP8RdcFan+xngD6hX6CGYTwho+gD01k1af24T0UF0MsEOi5ewZAREU3lZ6XSQbTUpTt822Zmq35pULi88MMa08xs7pBkvwIq0MDBJZufwm2APNzz/6Ura6aZlGNu6TNRVLqLCL7tBrzXgN9nPwNvrWTbgVQWSSaXWBTwOsd2aI/iRBAy96w7MLn27l+FfRj0LE7NvfC8Zq909sIATzrAJIxppXmig0ExnAv/6Pfy2Af5j/9QEQebcEhSN+xwcjt33uX0pMpgDupS/aQSpan1SHu621p0ZJjfPRvY5OUL5rGm3B17sC22YHkvSFw8lt847o1sUP310lKwwyVcfZp9FECN79rLZc=--nHG/SUA/akl2b6Hz--xYdN8CMc0C3RumXq093utg== -------------------------------------------------------------------------------- /config/credentials.yml.enc.sample: -------------------------------------------------------------------------------- 1 | # See README.md for instructions. 2 | 3 | # Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies. 4 | secret_key_base: "secret" 5 | 6 | diffbot: 7 | api_key: null 8 | 9 | github: 10 | client_id: null 11 | client_secret: null 12 | 13 | mastodon: 14 | # mastodon bot posting setup (not needed for account linking) 15 | # 1. visit instance to register your desired username 16 | # 2. run `rails credentials:edit` to enter instance name and username 17 | instance_name: "mastodon.social" 18 | bot_name: "lobsters" 19 | # 3. run Mastodon.get_bot_credentials! in the prod rails console for these three values 20 | client_id: null 21 | client_secret: null 22 | token: null 23 | # 4. create a list in the web ui, see the ID in the URL when you edit 24 | list_id: null 25 | 26 | pushover: 27 | api_token: null 28 | subscription_code: null 29 | -------------------------------------------------------------------------------- /spec/models/mastodon_app_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | RSpec.describe MastodonApp, type: :model do 6 | describe "#sanitized_instance_name" do 7 | it "accepts an instance name" do 8 | expect(MastodonApp.sanitized_instance_name("example.com")).to eq("example.com") 9 | end 10 | 11 | it "accepts urls" do 12 | expect(MastodonApp.sanitized_instance_name("https://example.com")).to eq("example.com") 13 | expect(MastodonApp.sanitized_instance_name("https://example.com/")).to eq("example.com") 14 | expect(MastodonApp.sanitized_instance_name("https://example.com/@user")).to eq("example.com") 15 | end 16 | 17 | it "accepts a user id" do 18 | expect(MastodonApp.sanitized_instance_name("user@example.com")).to eq("example.com") 19 | expect(MastodonApp.sanitized_instance_name("@user@example.com")).to eq("example.com") 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/helpers/interval_helper_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe IntervalHelper do 6 | describe "#time_interval" do 7 | let(:placeholder) { IntervalHelper::PLACEHOLDER } 8 | 9 | it "replaces empty input with placeholder" do 10 | expect(helper.time_interval("")).to eq(placeholder) 11 | expect(helper.time_interval(nil)).to eq(placeholder) 12 | end 13 | 14 | # concerned with xss and sql injection 15 | it "replaces invalid input with placeholder" do 16 | expect(helper.time_interval("0h")).to eq(placeholder) 17 | expect(helper.time_interval("1'h")).to eq(placeholder) 18 | expect(helper.time_interval("1h'")).to eq(placeholder) 19 | expect(helper.time_interval("-1w")).to eq(placeholder) 20 | expect(helper.time_interval("2")).to eq(placeholder) 21 | expect(helper.time_interval("m")).to eq(placeholder) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /bin/safe_yaml: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby2.7 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'safe_yaml' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("safe_yaml", "safe_yaml") 30 | -------------------------------------------------------------------------------- /app/models/invitation.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class Invitation < ApplicationRecord 4 | belongs_to :user 5 | belongs_to :new_user, class_name: "User", inverse_of: nil, optional: true 6 | 7 | scope :used, -> { where.not(used_at: nil) } 8 | scope :unused, -> { where(used_at: nil) } 9 | 10 | include Token 11 | 12 | validate do 13 | unless /\A[^@ ]+@[^ @]+\.[^ @]+\z/.match?(email.to_s) 14 | errors.add(:email, "is not valid") 15 | end 16 | end 17 | 18 | validates :code, :email, length: {maximum: 255} 19 | validates :memo, length: {maximum: 375} 20 | 21 | before_validation :create_code, on: :create 22 | 23 | def create_code 24 | 10.times do 25 | self.code = Utils.random_str(15) 26 | return unless Invitation.exists?(code: code) 27 | end 28 | raise "too many hash collisions" 29 | end 30 | 31 | def send_email 32 | InvitationMailer.invitation(self).deliver_now 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/models/invitation_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe Invitation do 6 | it "has a valid factory" do 7 | invitation = build(:invitation) 8 | expect(invitation).to be_valid 9 | end 10 | 11 | it "has a limit on the email field" do 12 | invitation = build(:invitation, email: "a" * 256 + "@b.b") 13 | invitation.valid? 14 | expect(invitation.errors[:email]).to eq(["is too long (maximum is 255 characters)"]) 15 | end 16 | 17 | it "creates a code before validation" do 18 | invitation = build(:invitation) 19 | invitation.code = "my code" 20 | invitation.valid? 21 | expect(invitation.code).to_not eq("my_code") 22 | end 23 | 24 | it "has a limit on the memo field" do 25 | invitation = build(:invitation, memo: "a" * 376) 26 | invitation.valid? 27 | expect(invitation.errors[:memo]).to eq(["is too long (maximum is 375 characters)"]) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/models/short_id.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class ShortId 4 | attr_accessor :klass, :generation_attempts 5 | 6 | def initialize(klass) 7 | self.klass = klass 8 | self.generation_attempts = 0 9 | end 10 | 11 | def generate 12 | until (generated_id = candidate_id) && generated_id.valid? 13 | self.generation_attempts += 1 14 | raise "too many hash collisions" if generation_attempts == 10 15 | end 16 | generated_id.to_s 17 | end 18 | 19 | def candidate_id 20 | CandidateId.new(klass) 21 | end 22 | 23 | class CandidateId 24 | attr_accessor :klass, :id 25 | 26 | def initialize(klass) 27 | self.klass = klass 28 | self.id = generate_id 29 | end 30 | 31 | def to_s 32 | id 33 | end 34 | 35 | def generate_id 36 | Utils.random_str(6).downcase 37 | end 38 | 39 | def valid? 40 | !klass.exists?(short_id: id) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/fixtures/inbound_emails/4.eml: -------------------------------------------------------------------------------- 1 | Date: Tue, 16 Jul 2013 10:37:11 -0500 2 | From: joshua stein 3 | To: ##SHORTNAME##-##MAILING_LIST_TOKEN##@lobste.rs 4 | Subject: Re: Lobsters by mail [announce] 5 | Message-ID: <20130716103519.78e3be260a@5d7607215bd9704> 6 | References: 7 | 8 | MIME-Version: 1.0 9 | Content-Type: text/plain; charset=us-ascii 10 | Content-Disposition: inline 11 | In-Reply-To: 12 | X-No-Archive: Yes 13 | 14 | On Sun, Feb 2, 2014 at 11:51 PM, blah wrote: 15 | 16 | > This is some quoted text. 17 | > With an attribution line 18 | 19 | It hasn't decreased any measurable amount but since the traffic to 20 | the site is increasing a bit each week, it's hard to tell. 21 | 22 | On Mon, Feb 3, 2014 at 3:36 PM, someone else wrote: 23 | > This is some terrible reply-on-top text 24 | 25 | -------------------------------------------------------------------------------- /lib/time_ago_in_words.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | module TimeAgoInWords 4 | def how_long_ago(time) 5 | secs = (Time.current - time).to_i 6 | if secs <= 5 7 | "just now" 8 | elsif secs < 60 9 | "less than a minute ago" 10 | elsif secs < (60 * 60) 11 | mins = (secs / 60.0).floor 12 | "#{mins} #{"minute".pluralize(mins)} ago" 13 | elsif secs < (60 * 60 * 48) 14 | hours = (secs / 60.0 / 60.0).floor 15 | "#{hours} #{"hour".pluralize(hours)} ago" 16 | elsif secs < (60 * 60 * 24 * 30) 17 | days = (secs / 60.0 / 60.0 / 24.0).floor 18 | "#{days} #{"day".pluralize(days)} ago" 19 | elsif secs < (60 * 60 * 24 * 365) 20 | months = (secs / 60.0 / 60.0 / 24.0 / 30.0).floor 21 | "#{months} #{"month".pluralize(months)} ago" 22 | else 23 | years = (secs / 60.0 / 60.0 / 24.0 / 365.0).floor 24 | "#{years} #{"year".pluralize(years)} ago" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies if using Yarn 21 | # system('bin/yarn') 22 | 23 | puts "\n== Updating database ==" 24 | system! 'bin/rails db:migrate' 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! 'bin/rails log:clear tmp:clear' 28 | 29 | puts "\n== Restarting application server ==" 30 | system! 'bin/rails restart' 31 | end 32 | -------------------------------------------------------------------------------- /db/migrate/20250424165830_add_typeid.rb: -------------------------------------------------------------------------------- 1 | class AddTypeid < ActiveRecord::Migration[8.0] 2 | disable_ddl_transaction! # took ~75 min in dev, prod will be similar 3 | 4 | def up 5 | [ 6 | Category, 7 | Comment, 8 | Domain, 9 | Hat, 10 | HatRequest, 11 | HiddenStory, 12 | Invitation, 13 | InvitationRequest, 14 | Link, 15 | Message, 16 | Moderation, 17 | ModNote, 18 | Origin, 19 | SavedStory, 20 | Story, 21 | Tag, 22 | User 23 | ].each do |model| 24 | add_column model.table_name, :slug, :string, default: nil 25 | model.find_each { |m| m.update_column :slug, TypeID.new(model.to_s.parameterize) } 26 | change_column model.table_name, :slug, :string, default: nil, null: false 27 | add_index model.table_name, :slug, unique: true 28 | end 29 | end 30 | 31 | def down 32 | raise ActiveRecord::IrreversibleMigration 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/requests/authenticatable_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe "login", type: :request do 6 | let(:user) { create(:user, password: "asdf") } 7 | let(:moderator) { create(:user, :moderator, password: "asdf") } 8 | 9 | describe "mod-only routes" do 10 | it "loads for a mod" do 11 | sign_in user 12 | get "/mod" 13 | expect(flash[:error]).to include("authorized") 14 | expect(response).to redirect_to("/") 15 | end 16 | 17 | it "doesn't load for a non-mod user" do 18 | sign_in moderator 19 | get "/mod" 20 | expect(flash[:error]).to be_nil 21 | expect(response).to be_successful 22 | end 23 | 24 | # visitors hit the require_logged_in_user path first; 25 | # this is a more useful error for mods who are logged out 26 | it "doesn't load for a visitor" do 27 | get "/mod" 28 | expect(response).to redirect_to("/login") 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/views/settings/mastodon_authentication.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :subnav do %> 2 | Back to Settings 3 | <% end %> 4 | 5 |
      6 | <%= form_with url: "/settings/mastodon_auth", method: :get do |f| %> 7 |

      8 | Enter the name or URL of your Mastodon instance (for example, mastodon.social): 9 |

      10 | 11 |
      12 | <%= f.label :mastodon_instance_name, "Name or URL:", :class => "required" %> 13 | <%= f.text_field :mastodon_instance_name, :autofocus => true %> 14 |
      15 | 16 |

      17 | <%= f.submit "Continue" %> 18 |

      19 | 20 |

      21 | We request the 22 | read:accounts scope to confirm your username, 23 | which includes basic profile info. 24 |

      25 | 26 | <% end %> 27 |
      28 | -------------------------------------------------------------------------------- /spec/requests/mod/reparents_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe "Mod::ReparentsController", type: :request do 6 | context "/new form" do 7 | it "loads" do 8 | sign_in create(:user, :admin) 9 | reparent_user = create(:user) 10 | get "/mod/reparents/new", params: {id: reparent_user.username} 11 | expect(response).to be_successful 12 | end 13 | end 14 | 15 | context "reparenting" do 16 | it "reparents the user and logs it" do 17 | sign_in create(:user, :admin) 18 | inviter = create(:user) 19 | reparent_user = create(:user, invited_by_user: inviter) 20 | post "/mod/reparents", params: {id: reparent_user.username, reason: "Abuse"} 21 | expect(response).to redirect_to user_path(reparent_user) 22 | reparent_user.reload 23 | expect(reparent_user.invited_by_user).to_not be(inviter) 24 | expect(Moderation.last.reason).to include("Abuse") 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/views/settings/twofa.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :subnav do %> 2 | Back to Settings 3 | <% end %> 4 | 5 |
      6 | <%= form_with model: @user, url: twofa_auth_url, method: :post do |f| %> 7 |

      8 | <% if @user.has_2fa? %> 9 | To turn off two-factor authentication for your account, enter your 10 | current password: 11 | <% else %> 12 | To begin the two-factor authentication enrollment for your account, 13 | enter your current password: 14 | <% end %> 15 |

      16 | 17 |
      18 | <%= f.label :password, "Current Password:", :class => "required" %> 19 | <%= f.password_field :password, :size => 40, :autocomplete => "off" %> 20 |
      21 | 22 |

      23 | <% if @user.has_2fa? %> 24 | <%= f.submit "Disable Two-Factor Authentication" %> 25 | <% else %> 26 | <%= f.submit "Continue" %> 27 | <% end %> 28 |

      29 | <% end %> 30 |
      31 | -------------------------------------------------------------------------------- /db/migrate/20220128133226_fix_story_mod_notes.rb: -------------------------------------------------------------------------------- 1 | class FixStoryModNotes < ActiveRecord::Migration[6.1] 2 | def up 3 | # fix formatting in existing notes 4 | ActiveRecord::Base.connection.execute <<~SQL 5 | update mod_notes set note = 6 | replace( 7 | replace( 8 | replace( 9 | replace( 10 | note, 11 | "\nurl: ", "\n- url: " 12 | ), 13 | "title: ", "\n- title: " 14 | ), 15 | "user_is_author: ", "\n- user_is_author: " 16 | ), 17 | "etags: ", "e\n- tags: " 18 | ) 19 | where note like "Attempted to post a story %"; 20 | SQL 21 | # rerender markdown 22 | ModNote.where('note like "Attempted to post a story %"').each do |mn| 23 | mn.markeddown_note = mn.generated_markeddown 24 | mn.save! 25 | end 26 | end 27 | 28 | def down 29 | raise ActiveRecord::IrreversibleMigration 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /app/views/mod/reparents/new.html.erb: -------------------------------------------------------------------------------- 1 |

      Reparent <%= @reparent_user.username %>

      2 | 3 | <%= form_with url: mod_reparents_path({}, id: @reparent_user), method: :post do |f| %> 4 |

      5 | This will reparent <%= @reparent_user.username %> to you in the invite tree, create a mod note on the user with their old inviter, and put an entry in the moderation log with the given reason. 6 |

      7 | 8 |

      9 | This should be used rarely because it makes it harder to investigate sockpuppets and voting rings. 10 | Currently the only known purpose is if this user was abused off-site by their inviter and we can't really do anything about it here except try to disassociate them. 11 | Write a good reason here, it's going to get attention by virtue of being rare. 12 |

      13 | 14 |
      15 | <%= f.label :reason, "Reason:", :class => "required" %> 16 | <%= f.text_field :reason, :size => 80 %> 17 |
      18 | <%= f.submit "Reparent" %> 19 | <% end %> 20 | 21 | -------------------------------------------------------------------------------- /app/views/settings/twofa_enroll.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :subnav do %> 2 | Back to Settings 3 | <% end %> 4 | 5 |
      6 |

      7 | Scan the QR code below or click on it to open in your TOTP application of choice: 10 |

      11 | 12 |
      13 | <%= raw @qr_svg %> 14 |
      15 | 16 |

      17 | Or to add to a device manually enter the secret: 18 |

      19 | Reveal Secret 20 | <%= raw @qr_secret %> 21 |
      22 |

      23 | 24 |

      25 | Once you have finished registering with your TOTP application, proceed to 26 | the next screen to verify your current TOTP code and actually enable 27 | Two-Factor Authentication on your account. 28 |

      29 | 30 |

      31 | <%= button_to "Verify and Enable", twofa_verify_url, :method => :get %> 32 |

      33 |
      34 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | # include a comment explaining where each token is used, or 'preventative' if it's obviously 4 | # sensitive and likely to be used, or used in a popular tool like Devise 5 | 6 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 7 | Rails.application.config.filter_parameters += [ 8 | :authenticity_token, # all forms 9 | :certificate, # preventative 10 | :crypt, # preventative 11 | :_key, # preventative 12 | :lobster_trap, # session cookie 13 | :otp, # preventative 14 | :password_confirmation, # LoginController (redundant with passw) 15 | :password, # LoginController (redundant with passw) 16 | :password_token, # LoginController 17 | :passw, # preventative 18 | :salt, # preventative 19 | :secret, # preventative 20 | :session_token, # auth cookie value 21 | :token, # rss token - need to transition this to a typeid 22 | :totp_code # LoginController 23 | ] 24 | -------------------------------------------------------------------------------- /db/migrate/20240909220644_recalculate_comment_confidences.rb: -------------------------------------------------------------------------------- 1 | class RecalculateCommentConfidences < ActiveRecord::Migration[7.1] 2 | # it's ok to incrementally fix this old data, and we don't want to lock popular tables 3 | disable_ddl_transaction! 4 | 5 | def change 6 | # Refresh the memoization of score and flags for all comments. 7 | # 1. Three comments (lli01e, gairjl, ajbg61) in the db have the wrong memoized score (1, when 8 | # the author apparently unvoted). 9 | # 2. Comment.delete_for_user set score to FLAGGABLE_MIN_SCORE; now handled by calculated_confidence 10 | Comment.all.update_all <<~SQL 11 | score = (select coalesce(sum(vote), 0) from votes where comment_id = comments.id), 12 | flags = (select count(*) from votes where comment_id = comments.id and vote = -1) 13 | SQL 14 | 15 | # now recalc all confidences on all comments 16 | Comment.all.find_each do |c| 17 | c.update_score_and_recalculate! 0, 0 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/views/comments/index.rss.builder: -------------------------------------------------------------------------------- 1 | xml.instruct! :xml, version: "1.0" 2 | xml.rss version: "2.0", "xmlns:atom": "http://www.w3.org/2005/Atom" do 3 | xml.channel do 4 | xml.title Rails.application.name + (@title.present? ? ": #{@title}" : "") 5 | xml.link Rails.application.root_url 6 | xml.tag! "atom:link", nil, href: request.original_url, rel: :self 7 | xml.description @title 8 | xml.pubDate @comments.first.created_at.rfc822 9 | xml.ttl 120 10 | 11 | @comments.each do |comment| 12 | xml.item do 13 | xml.title comment.story.title 14 | xml.link Routes.comment_short_id_url comment 15 | xml.guid Routes.comment_short_id_url comment 16 | xml.author "#{comment.user.username}@#{Rails.application.domain} (#{comment.user.username})" 17 | xml.pubDate comment.last_edited_at.rfc822 18 | xml.comments Routes.comment_short_id_url comment 19 | xml.description comment.markeddown_comment 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /app/controllers/domains_ban_controller.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class DomainsBanController < DomainsController 4 | before_action :require_logged_in_moderator 5 | before_action :find_or_initialize_domain 6 | 7 | def create_and_ban 8 | @domain = Domain.create!(domain: params[:id]) 9 | @domain.ban_by_user_for_reason!(@user, domain_params[:banned_reason]) 10 | flash[:success] = "Domain created and banned. Real short run." 11 | redirect_to domain_path(@domain) 12 | end 13 | 14 | def update 15 | if domain_params[:banned_reason].present? 16 | if @domain.banned? 17 | @domain.unban_by_user_for_reason!(@user, domain_params[:banned_reason]) 18 | else 19 | @domain.ban_by_user_for_reason!(@user, domain_params[:banned_reason]) 20 | end 21 | flash[:success] = "Domain updated." 22 | redirect_to domain_path(@domain) 23 | else 24 | flash.now[:error] = "Reason required for the modlog." 25 | render :edit 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/routing/domain_spec.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | require "rails_helper" 4 | 5 | describe "domains routing", type: :routing do 6 | it "#edit" do 7 | expect(get("/domains/github.com/edit")).to route_to( 8 | controller: "domains", 9 | action: "edit", 10 | id: "github.com" 11 | ) 12 | end 13 | 14 | it "#update" do 15 | expect(patch("/domains/github.com")).to route_to( 16 | controller: "domains", 17 | action: "update", 18 | id: "github.com" 19 | ) 20 | end 21 | end 22 | 23 | describe "domains_ban routing", type: :routing do 24 | it "#create_and_ban" do 25 | expect(post("/domains_ban/github.com")).to route_to( 26 | controller: "domains_ban", 27 | action: "create_and_ban", 28 | id: "github.com" 29 | ) 30 | end 31 | 32 | it "#update" do 33 | expect(patch("/domains_ban/github.com")).to route_to( 34 | controller: "domains_ban", 35 | action: "update", 36 | id: "github.com" 37 | ) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/models/story_text.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class StoryText < ApplicationRecord 4 | self.primary_key = :id 5 | 6 | belongs_to :story, foreign_key: :id, inverse_of: :story_text 7 | 8 | validates :title, presence: true, length: {maximum: 150} 9 | validates :description, :body, length: {maximum: 16_777_215} 10 | 11 | def body=(s) 12 | # pass nil, truncate to column limit https://mariadb.com/kb/en/mediumtext/ 13 | super(s ? s[...(2**24 - 1)] : s) 14 | end 15 | 16 | def self.fill_cache!(story) 17 | return true if StoryText.where(id: story).exists? 18 | 19 | body = DiffBot.get_story_text(story) 20 | StoryText.create! id: story.id, title: story.title, description: story.description, body: body 21 | end 22 | 23 | def self.cached?(story, &blk) 24 | if blk 25 | st = StoryText.find_by(id: story) 26 | if st.present? 27 | yield st.body 28 | return true 29 | end 30 | return false 31 | end 32 | 33 | where(id: story).exists? 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /db/migrate/20200812024241_create_story_texts.rb: -------------------------------------------------------------------------------- 1 | class CreateStoryTexts < ActiveRecord::Migration[5.2] 2 | def up 3 | create_table :story_texts, id: false do |t| 4 | t.primary_key :id, signed: false, null: false 5 | t.text :body, limit: 16777215, null: false 6 | 7 | t.timestamp :created_at, null: false 8 | end 9 | ActiveRecord::Base.connection.execute <<~SQL 10 | insert low_priority ignore into story_texts (id, body) 11 | select id, story_cache from stories where story_cache is not null 12 | SQL 13 | remove_column :stories, :story_cache 14 | add_index :story_texts, :body, type: :fulltext 15 | end 16 | 17 | def down 18 | add_column :stories, :story_cache, :text, limit: 16777215 19 | ActiveRecord::Base.connection.execute <<~SQL 20 | update low_priority stories inner join story_texts on stories.id = story_texts.id 21 | set story_cache = story_texts.body 22 | SQL 23 | add_index :stories, :story_cache, type: :fulltext 24 | drop_table :story_texts 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /extras/pushover.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class Pushover 4 | # see README.md on setting up credentials 5 | 6 | def self.enabled? 7 | Rails.application.credentials.pushover.api_token.present? 8 | end 9 | 10 | def self.push(user, params) 11 | if !enabled? 12 | return 13 | end 14 | 15 | begin 16 | if params[:message].to_s == "" 17 | params[:message] = "(No message)" 18 | end 19 | 20 | s = Sponge.new 21 | s.fetch("https://api.pushover.net/1/messages.json", :post, { 22 | token: Rails.application.credentials.pushover.api_token, 23 | user: user 24 | }.merge(params)) 25 | rescue => e 26 | Rails.logger.error "error sending to pushover: #{e.inspect}" 27 | end 28 | end 29 | 30 | def self.subscription_url(params) 31 | u = "https://pushover.net/subscribe/#{Rails.application.credentials.pushover.subscription_code}" 32 | u << "?success=#{CGI.escape(params[:success])}" 33 | u << "&failure=#{CGI.escape(params[:failure])}" 34 | u 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/models/category.rb: -------------------------------------------------------------------------------- 1 | # typed: false 2 | 3 | class Category < ApplicationRecord 4 | has_many :tags, 5 | -> { order("tag asc") }, 6 | dependent: :restrict_with_error, 7 | inverse_of: :category 8 | has_many :stories, through: :tags 9 | 10 | after_save :log_modifications 11 | 12 | include Token 13 | attr_accessor :edit_user_id 14 | 15 | validates :category, length: {maximum: 25}, presence: true, 16 | uniqueness: {case_sensitive: false}, 17 | format: {with: /\A[A-Za-z0-9_\-]+\z/} 18 | 19 | def to_param 20 | category 21 | end 22 | 23 | def log_modifications 24 | Moderation.create do |m| 25 | m.action = if id_previously_changed? 26 | "Created new category " + 27 | attributes.map { |f, c| "with #{f} '#{c}'" }.join(", ") 28 | else 29 | "Updating category #{category}, " + saved_changes 30 | .map { |f, c| "changed #{f} from '#{c[0]}' to '#{c[1]}'" }.join(", ") 31 | end 32 | m.moderator_user_id = @edit_user_id 33 | m.category_id = id 34 | end 35 | end 36 | end 37 | --------------------------------------------------------------------------------