diff --git a/benchmarks/lobsters/Gemfile b/benchmarks/lobsters/Gemfile index b4e5f8a3..becaf00e 100644 --- a/benchmarks/lobsters/Gemfile +++ b/benchmarks/lobsters/Gemfile @@ -1,75 +1,82 @@ source "https://rubygems.org" -# Everything except Action Cable. It's unused and it installs native gems. -%w[ - actionmailbox actionmailer actionpack actionview - actiontext activejob activemodel activerecord - activestorage activesupport railties -].each do |rails_gem| - gem rails_gem, "~> 7.2" -end +gem "rails" +# database and caching +gem "maxminddb" +gem "solid_cache" gem "sqlite3" -#gem "mysql2" +gem "trilogy" -# uncomment to use PostgreSQL -# gem "pg" +# jobs +gem "solid_queue" +gem "mission_control-jobs" # rails -gem 'scenic' -#gem 'scenic-mysql_adapter' +# gem "scenic" +# gem "scenic-mysql_adapter" gem "activerecord-typedstore" -gem 'sprockets-rails', '2.3.3' +gem "propshaft" +gem "importmap-rails", "~> 2.0" # js -gem "jquery-rails", "~> 4.3" gem "json" -#gem "uglifier", ">= 1.3.0" # deployment gem "actionpack-page_caching" -# gem "exception_notification" -# gem "puma", ">= 5.6.2" +gem "exception_notification" +gem "puma" # security -gem "bcrypt", "~> 3.1.2" +gem "bcrypt" gem "rotp" gem "rqrcode" # parsing -gem "pdf-reader" -gem "nokogiri", ">= 1.13.9" +gem "commonmarker", "<1" gem "htmlentities" -gem "commonmarker", ">= 0.23.6", "< 1.0" # The v1.0 Rust gem doesn't compile on Ruby master +gem "pdf-reader" +gem "nokogiri" +gem "parslet" -# perf - skip for benchmarking -group :development do - gem 'flamegraph' - gem 'memory_profiler' - gem 'rack-mini-profiler' - gem 'stackprof' -end +# perf +gem "flamegraph" +gem "memory_profiler" +# gem "rack-mini-profiler" +gem "stackprof" +# gem "prosopite" +gem "pg_query" -gem "oauth" # for twitter-posting bot +gem "builder" # for rss +gem "oauth" # for linking accounts gem "mail" # for parsing incoming mail -gem "ruumba" # tests views gem "sitemap_generator" # for better search engine indexing -gem "svg-graph", require: 'SVG/Graph/TimeSeries' # for charting, note workaround in lib/time_series.rb -gem 'rack-attack' # rate-limiting +gem "svg-graph", require: "SVG/Graph/TimeSeries" # for charting, note workaround in lib/time_series.rb + +# gem "rack-attack" # rate-limiting +gem "lograge" # for JSON logging +gem "silencer" # to disable default logging in prod group :test, :development do - gem 'capybara' - gem 'database_cleaner' - gem 'rspec-rails', '~> 6.0.0.rc1' + gem "benchmark-perf" + gem "brakeman" + gem "capybara" + gem "database_cleaner" + gem "listen" + gem "letter_opener" + gem "rspec-rails" gem "factory_bot_rails" - gem "ostruct" # required for such an old rubocop - gem "rubocop", "0.81", require: false - gem "rubocop-rails", require: false - gem "rubocop-rspec", require: false + gem "foreman" + gem "standard" + gem "standard-performance" + gem "standard-rails" + gem "super_diff" gem "faker" gem "byebug" gem "rb-readline" gem "vcr" gem "webmock" # used to support vcr - gem 'simplecov', require: false + gem "simplecov", require: false + gem "active_record_doctor" + gem "database_consistency" end diff --git a/benchmarks/lobsters/Gemfile.lock b/benchmarks/lobsters/Gemfile.lock index 61da6962..a6dde530 100644 --- a/benchmarks/lobsters/Gemfile.lock +++ b/benchmarks/lobsters/Gemfile.lock @@ -1,27 +1,32 @@ GEM remote: https://rubygems.org/ specs: - Ascii85 (1.1.0) - actionmailbox (7.2.2.1) - actionpack (= 7.2.2.1) - activejob (= 7.2.2.1) - activerecord (= 7.2.2.1) - activestorage (= 7.2.2.1) - activesupport (= 7.2.2.1) + Ascii85 (2.0.1) + actioncable (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) mail (>= 2.8.0) - actionmailer (7.2.2.1) - actionpack (= 7.2.2.1) - actionview (= 7.2.2.1) - activejob (= 7.2.2.1) - activesupport (= 7.2.2.1) + actionmailer (8.0.1) + actionpack (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activesupport (= 8.0.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.2.1) - actionview (= 7.2.2.1) - activesupport (= 7.2.2.1) + actionpack (8.0.1) + actionview (= 8.0.1) + activesupport (= 8.0.1) nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4, < 3.2) + rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) @@ -29,37 +34,39 @@ GEM useragent (~> 0.16) actionpack-page_caching (1.2.4) actionpack (>= 4.0.0) - actiontext (7.2.2.1) - actionpack (= 7.2.2.1) - activerecord (= 7.2.2.1) - activestorage (= 7.2.2.1) - activesupport (= 7.2.2.1) + actiontext (8.0.1) + actionpack (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.2.1) - activesupport (= 7.2.2.1) + actionview (8.0.1) + activesupport (= 8.0.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.2.2.1) - activesupport (= 7.2.2.1) + active_record_doctor (1.15.0) + activerecord (>= 4.2.0) + activejob (8.0.1) + activesupport (= 8.0.1) globalid (>= 0.3.6) - activemodel (7.2.2.1) - activesupport (= 7.2.2.1) - activerecord (7.2.2.1) - activemodel (= 7.2.2.1) - activesupport (= 7.2.2.1) + activemodel (8.0.1) + activesupport (= 8.0.1) + activerecord (8.0.1) + activemodel (= 8.0.1) + activesupport (= 8.0.1) timeout (>= 0.4.0) activerecord-typedstore (1.6.0) activerecord (>= 6.1) - activestorage (7.2.2.1) - actionpack (= 7.2.2.1) - activejob (= 7.2.2.1) - activerecord (= 7.2.2.1) - activesupport (= 7.2.2.1) + activestorage (8.0.1) + actionpack (= 8.0.1) + activejob (= 8.0.1) + activerecord (= 8.0.1) + activesupport (= 8.0.1) marcel (~> 1.0) - activesupport (7.2.2.1) + activesupport (8.0.1) base64 benchmark (>= 0.3) bigdecimal @@ -71,71 +78,129 @@ GEM minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + uri (>= 0.13.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) afm (0.2.2) ast (2.4.2) + attr_extras (7.1.0) base64 (0.2.0) bcrypt (3.1.20) benchmark (0.4.0) - bigdecimal (3.1.8) - builder (3.2.4) + benchmark-perf (0.6.0) + bigdecimal (3.1.9) + brakeman (7.0.0) + racc + builder (3.3.0) byebug (11.1.3) - capybara (3.39.2) + capybara (3.40.0) addressable matrix mini_mime (>= 0.1.3) - nokogiri (~> 1.8) + nokogiri (~> 1.11) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + childprocess (5.1.0) + logger (~> 1.5) chunky_png (1.4.0) - commonmarker (0.23.10) - concurrent-ruby (1.3.4) - connection_pool (2.4.1) - crack (0.4.5) + commonmarker (0.23.11) + concurrent-ruby (1.3.5) + connection_pool (2.5.0) + crack (1.0.0) + bigdecimal rexml crass (1.0.6) - database_cleaner (2.0.2) + database_cleaner (2.1.0) database_cleaner-active_record (>= 2, < 3) - database_cleaner-active_record (2.1.0) + database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.3.4) - diff-lcs (1.5.0) - docile (1.4.0) + database_consistency (2.0.3) + activerecord (>= 3.2) + date (3.4.1) + diff-lcs (1.5.1) + docile (1.4.1) drb (2.2.1) - erubi (1.13.0) - factory_bot (6.4.5) + erubi (1.13.1) + et-orbi (1.2.11) + tzinfo + exception_notification (4.1.1) + actionmailer (>= 3.0.4) + activesupport (>= 3.0.4) + factory_bot (6.5.0) activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) + factory_bot_rails (6.4.4) + factory_bot (~> 6.5) railties (>= 5.0.0) - faker (3.2.3) + faker (3.5.1) i18n (>= 1.8.11, < 2) + ffi (1.17.1-aarch64-linux-gnu) + ffi (1.17.1-aarch64-linux-musl) + ffi (1.17.1-arm-linux-gnu) + ffi (1.17.1-arm-linux-musl) + ffi (1.17.1-arm64-darwin) + ffi (1.17.1-x86_64-darwin) + ffi (1.17.1-x86_64-linux-gnu) + ffi (1.17.1-x86_64-linux-musl) flamegraph (0.9.5) + foreman (0.88.1) + fugit (1.11.1) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) - hashdiff (1.1.0) + google-protobuf (4.29.3) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-aarch64-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-arm64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-x86_64-darwin) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-x86_64-linux) + bigdecimal + rake (>= 13) + hashdiff (1.1.2) hashery (2.1.2) hashie (5.0.0) htmlentities (4.3.4) - i18n (1.14.5) + i18n (1.14.7) concurrent-ruby (~> 1.0) - io-console (0.7.2) - irb (1.14.0) + importmap-rails (2.1.0) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.0) + irb (1.15.1) + pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jaro_winkler (1.5.6) - jquery-rails (4.6.0) - rails-dom-testing (>= 1, < 3) - railties (>= 4.2.0) - thor (>= 0.14, < 2.0) json (2.9.1) - logger (1.6.1) - loofah (2.22.0) + language_server-protocol (3.17.0.4) + launchy (3.1.0) + addressable (~> 2.8) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) + lint_roller (1.1.0) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.6.5) + lograge (0.14.0) + actionpack (>= 4) + activesupport (>= 4) + railties (>= 4) + request_store (~> 1.0) + loofah (2.24.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -145,21 +210,44 @@ GEM net-smtp marcel (1.0.4) matrix (0.4.2) - memory_profiler (1.0.1) + maxminddb (0.1.22) + memory_profiler (1.1.0) mini_mime (1.1.5) - mini_portile2 (2.8.5) - minitest (5.25.1) - net-imap (0.4.9.1) + minitest (5.25.4) + mission_control-jobs (1.0.1) + actioncable (>= 7.1) + actionpack (>= 7.1) + activejob (>= 7.1) + activerecord (>= 7.1) + importmap-rails (>= 1.2.1) + irb (~> 1.13) + railties (>= 7.1) + stimulus-rails + turbo-rails + net-imap (0.5.5) date net-protocol net-pop (0.1.2) net-protocol net-protocol (0.2.2) timeout - net-smtp (0.4.0.1) - net-protocol - nokogiri (1.16.0) - mini_portile2 (~> 2.8.2) + net-smtp (0.5.0) + nio4r (2.7.4) + nokogiri (1.18.2-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.2-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.2-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.2-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.2-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.2-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.2-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.2-x86_64-linux-musl) racc (~> 1.4) oauth (1.1.0) oauth-tty (~> 1.0, >= 1.0.1) @@ -167,43 +255,70 @@ GEM version_gem (~> 1.1) oauth-tty (1.0.5) version_gem (~> 1.1, >= 1.1.1) - ostruct (0.6.1) + optimist (3.2.0) parallel (1.26.3) - parser (3.3.6.0) + parser (3.3.7.0) ast (~> 2.4.1) racc - pdf-reader (2.12.0) - Ascii85 (~> 1.0) + parslet (2.0.0) + patience_diff (1.2.0) + optimist (~> 3.0) + pdf-reader (2.14.0) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) afm (~> 0.2.1) hashery (~> 2.0) ruby-rc4 ttfunk - psych (5.1.2) + pg_query (6.0.0) + google-protobuf (>= 3.25.3) + pp (0.6.2) + prettyprint + prettyprint (0.2.0) + propshaft (1.1.0) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + railties (>= 7.0.0) + psych (5.2.3) + date stringio - public_suffix (5.0.4) + public_suffix (6.0.1) + puma (6.6.0) + nio4r (~> 2.0) + raabro (1.4.0) racc (1.8.1) - rack (2.2.8) - rack-attack (6.7.0) - rack (>= 1.0, < 4) - rack-mini-profiler (3.3.0) - rack (>= 1.2.0) - rack-session (1.0.2) - rack (< 3) - rack-test (2.1.0) + rack (3.1.8) + rack-session (2.1.0) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) rack (>= 1.3) - rackup (1.0.0) - rack (< 3) - webrick + rackup (2.2.1) + rack (>= 3) + rails (8.0.1) + actioncable (= 8.0.1) + actionmailbox (= 8.0.1) + actionmailer (= 8.0.1) + actionpack (= 8.0.1) + actiontext (= 8.0.1) + actionview (= 8.0.1) + activejob (= 8.0.1) + activemodel (= 8.0.1) + activerecord (= 8.0.1) + activestorage (= 8.0.1) + activesupport (= 8.0.1) + bundler (>= 1.15.0) + railties (= 8.0.1) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) + rails-html-sanitizer (1.6.2) loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.2.2.1) - actionpack (= 7.2.2.1) - activesupport (= 7.2.2.1) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.0.1) + actionpack (= 8.0.1) + activesupport (= 8.0.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -211,151 +326,213 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) rb-readline (0.5.5) - rdoc (6.7.0) + rdoc (6.11.0) psych (>= 4.0.0) - regexp_parser (2.9.0) - reline (0.5.10) + regexp_parser (2.10.0) + reline (0.6.0) io-console (~> 0.5) + request_store (1.7.0) + rack (>= 1.4) rexml (3.4.0) rotp (6.3.0) rqrcode (2.2.0) chunky_png (~> 1.0) rqrcode_core (~> 1.0) rqrcode_core (1.2.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) + rspec-core (3.13.2) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.6) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-rails (6.0.4) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) - rspec-core (~> 3.12) - rspec-expectations (~> 3.12) - rspec-mocks (~> 3.12) - rspec-support (~> 3.12) - rspec-support (3.12.1) - rubocop (0.81.0) - jaro_winkler (~> 1.5.1) + rspec-support (~> 3.13.0) + rspec-rails (7.1.0) + actionpack (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + rspec-core (~> 3.13) + rspec-expectations (~> 3.13) + rspec-mocks (~> 3.13) + rspec-support (~> 3.13) + rspec-support (3.13.2) + rubocop (1.70.0) + json (~> 2.3) + language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 2.7.0.1) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - rexml + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.36.2, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 2.0) - rubocop-rails (2.5.2) - activesupport + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.38.0) + parser (>= 3.3.1.0) + rubocop-performance (1.23.1) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) + rubocop-rails (2.26.2) + activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 0.72.0) - rubocop-rspec (1.41.0) - rubocop (>= 0.68.1) + rubocop (>= 1.52.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) - ruumba (0.1.17) - rubocop - scenic (1.7.0) - activerecord (>= 4.0.0) - railties (>= 4.0.0) - securerandom (0.3.1) + securerandom (0.4.1) + silencer (2.0.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) sitemap_generator (6.3.0) builder (~> 3.0) snaky_hash (2.0.1) hashie version_gem (~> 1.1, >= 1.1.1) - sprockets (3.7.2) - concurrent-ruby (~> 1.0) - rack (> 1, < 3) - sprockets-rails (2.3.3) - actionpack (>= 3.0) - activesupport (>= 3.0) - sprockets (>= 2.8, < 4.0) - sqlite3 (1.7.1) - mini_portile2 (~> 2.8.0) - stackprof (0.2.26) - stringio (3.1.1) + solid_cache (1.0.6) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.1.3) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (~> 1.3.1) + sqlite3 (2.5.0-aarch64-linux-gnu) + sqlite3 (2.5.0-aarch64-linux-musl) + sqlite3 (2.5.0-arm-linux-gnu) + sqlite3 (2.5.0-arm-linux-musl) + sqlite3 (2.5.0-arm64-darwin) + sqlite3 (2.5.0-x86_64-darwin) + sqlite3 (2.5.0-x86_64-linux-gnu) + sqlite3 (2.5.0-x86_64-linux-musl) + stackprof (0.2.27) + standard (1.44.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.70.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.6) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.6.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.23.0) + standard-rails (1.2.0) + lint_roller (~> 1.0) + rubocop-rails (~> 2.26.0) + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.1.2) + super_diff (0.15.0) + attr_extras (>= 6.2.4) + diff-lcs + patience_diff svg-graph (2.2.2) - thor (1.3.0) - timeout (0.4.1) - ttfunk (1.7.0) + thor (1.3.2) + timeout (0.4.3) + trilogy (2.9.0) + ttfunk (1.8.0) + bigdecimal (~> 3.1) + turbo-rails (2.0.11) + actionpack (>= 6.0.0) + railties (>= 6.0.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (1.8.0) - useragent (0.16.10) - vcr (6.2.0) - version_gem (1.1.3) - webmock (3.19.1) + unicode-display_width (3.1.4) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) + uri (1.0.2) + useragent (0.16.11) + vcr (6.3.1) + base64 + version_gem (1.1.4) + webmock (3.24.0) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - webrick (1.8.1) + websocket-driver (0.7.7) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.18) + zeitwerk (2.7.1) PLATFORMS - ruby + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES - actionmailbox (~> 7.2) - actionmailer (~> 7.2) - actionpack (~> 7.2) actionpack-page_caching - actiontext (~> 7.2) - actionview (~> 7.2) - activejob (~> 7.2) - activemodel (~> 7.2) - activerecord (~> 7.2) + active_record_doctor activerecord-typedstore - activestorage (~> 7.2) - activesupport (~> 7.2) - bcrypt (~> 3.1.2) + bcrypt + benchmark-perf + brakeman + builder byebug capybara - commonmarker (>= 0.23.6, < 1.0) + commonmarker (< 1) database_cleaner + database_consistency + exception_notification factory_bot_rails faker flamegraph + foreman htmlentities - jquery-rails (~> 4.3) + importmap-rails (~> 2.0) json + letter_opener + listen + lograge mail + maxminddb memory_profiler - nokogiri (>= 1.13.9) + mission_control-jobs + nokogiri oauth - ostruct + parslet pdf-reader - rack-attack - rack-mini-profiler - railties (~> 7.2) + pg_query + propshaft + puma + rails rb-readline rotp rqrcode - rspec-rails (~> 6.0.0.rc1) - rubocop (= 0.81) - rubocop-rails - rubocop-rspec - ruumba - scenic + rspec-rails + silencer simplecov sitemap_generator - sprockets-rails (= 2.3.3) + solid_cache + solid_queue sqlite3 stackprof + standard + standard-performance + standard-rails + super_diff svg-graph + trilogy vcr webmock BUNDLED WITH - 2.4.19 + 2.6.2 diff --git a/benchmarks/lobsters/app/assets/images/logo-bw.svg b/benchmarks/lobsters/app/assets/images/logo-bw.svg new file mode 100644 index 00000000..0cdfaf48 --- /dev/null +++ b/benchmarks/lobsters/app/assets/images/logo-bw.svg @@ -0,0 +1,11 @@ + + + diff --git a/benchmarks/lobsters/app/assets/images/logo-color.svg b/benchmarks/lobsters/app/assets/images/logo-color.svg new file mode 100644 index 00000000..be070b8f --- /dev/null +++ b/benchmarks/lobsters/app/assets/images/logo-color.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/benchmarks/lobsters/app/assets/images/select2.png b/benchmarks/lobsters/app/assets/images/select2.png new file mode 100644 index 00000000..d08e4b7e Binary files /dev/null and b/benchmarks/lobsters/app/assets/images/select2.png differ diff --git a/benchmarks/lobsters/app/assets/stylesheets/application.css b/benchmarks/lobsters/app/assets/stylesheets/application.css new file mode 100644 index 00000000..d79abf8c --- /dev/null +++ b/benchmarks/lobsters/app/assets/stylesheets/application.css @@ -0,0 +1,1889 @@ + +/* light and dark mode colorschemes */ + +:root { + color-scheme: light dark; + + /** main monochrome and variants **/ + --base-bg-light: 254 254 254; + --base-bg-dark: 12 12 12; + + --color-bg: light-dark(rgb(var(--base-bg-light)), rgb(var(--base-bg-dark))); + --color-bg-50: color-mix(in srgb, var(--color-bg) 50%, transparent); + + --base-fg-light: 51 51 51; + --base-fg-dark: 255 255 255; + + --color-fg: light-dark(rgb(var(--base-fg-light) / 100%), rgb(var(--base-fg-dark) / 87%)); /* primary text */ + --color-fg-contrast-13: light-dark(rgb(var(--base-fg-light) / 94%), rgb(var(--base-fg-dark) / 87%)); + --color-fg-contrast-10: light-dark(rgb(var(--base-fg-light) / 84%), rgb(var(--base-fg-dark) / 87%)); + --color-fg-contrast-7-5: light-dark(rgb(var(--base-fg-light) / 75%), rgb(var(--base-fg-dark) / 77%)); + --color-fg-contrast-6: light-dark(rgb(var(--base-fg-light) / 67%), rgb(var(--base-fg-dark) / 69%)); + --color-fg-contrast-5: light-dark(rgb(var(--base-fg-light) / 63%), rgb(var(--base-fg-dark) / 64%)); + --color-fg-contrast-4-5: light-dark(rgb(var(--base-fg-light) / 59%), rgb(var(--base-fg-dark) / 61%)); + + --color-fg-gradient-lit: light-dark(rgb(var(--base-fg-light) / 0%), rgb(var(--base-fg-dark) / 13%)); + --color-fg-gradient-shadowed: light-dark(rgb(var(--base-fg-light) / 13%), rgb(var(--base-fg-dark) / 3%)); + + --color-light: rgb(255, 255, 255); + --color-light-25: color-mix(in srgb, var(--color-light) 25%, transparent); + + --color-shadow: rgb(0, 0, 0); + --color-shadow-80: color-mix(in srgb, var(--color-shadow) 80%, transparent); + --color-shadow-25: color-mix(in srgb, var(--color-shadow) 25%, transparent); + --color-shadow-10: color-mix(in srgb, var(--color-shadow) 10%, transparent); + + /* colored backgrounds and foregrounds */ + --color-bg-accent: light-dark(rgb(172, 19, 13), rgb(253, 78, 72)); + --color-bg-target: light-dark(rgb(255, 252, 215), rgb(61, 53, 11)); + + --color-fg-link: light-dark(rgb(28, 89, 209), rgb(138, 177, 255)); + --color-fg-link-visited: light-dark(rgb(95, 134, 212), rgb(79, 138, 255)); + --color-fg-accent: light-dark(rgb(172, 19, 13), rgb(207, 54, 49)); /* red as in lobste.rs */ + --color-fg-negative: light-dark(rgb(139, 0, 0), rgb(190, 45, 45)); /* red as in warning */ + --color-fg-affirmative: light-dark(rgb(0, 128, 0), rgb(42, 180, 42) ); + --color-fg-author: light-dark(rgb(96, 129, 189), rgb(80, 112, 177)); + --color-fg-shape: light-dark(rgb(187, 187, 187), rgb(80, 80, 80)); + --color-hr: light-dark(rgb(187, 187, 187), rgb(80, 80, 80)); + + /* explicit view colors */ + --color-box-bg: light-dark(#fff, #141414); + --color-box-bg-shaded: light-dark(#eee, #202020); + --color-box-border: light-dark(#ccc, #303030); + --color-box-border-focus: light-dark(#808080, #808080); + + --color-button-bg: light-dark(#fafafa, #181818); + --color-button-bg-shaded: light-dark(#e6e6e6, #242424); + + --color-table-header-bg: light-dark(#eaeaea, #262626); + --color-table-header-border: light-dark(#cacaca, #404040); + --color-table-row-bg-even: light-dark(#f8f8f8, #181818); + --color-table-row-bg-odd: light-dark(#f5f5f5, #1b1b1b); + --color-table-row-border: light-dark(#eaeaea, #262626); + + --color-tag-bg: light-dark(#fffcd7, #3b320d); + --color-tag-border: light-dark(#d5d458, #665501); + --color-tag-media-bg: light-dark(#ddebf9, #15293d); + --color-tag-media-border: light-dark(#b2ccf0, #214669); + --color-tag-meta-bg: light-dark(#e0e0e0, #2c2c2c); + --color-tag-meta-border: light-dark(#c8c8c8, #484848); + + --color-hat-crown-fill: light-dark(#ddd, #3b3b3b); + --color-hat-crown-stroke: light-dark(#eee, #202020); + --color-hat-brim-stroke: light-dark(#bbb, #2a2a2a); + + --color-flash-bg-error: light-dark(#fdcfcc, #451714); + --color-flash-bg-success: light-dark(#dff0d8, #273820); + --color-flash-bg-notice: light-dark(#cce6ee, #142e36); + + --color-lobsters-fg-has-suggestions: light-dark(#bd6060, #df7171); + + --color-lobsters-tag-special-bg: light-dark(#f9ddde, #3b1719); + --color-lobsters-tag-special-border: light-dark(#f0b2b8, #611a21); + + --color-lobsters-hat-sysop-crown-fill: light-dark(#ddc7c7, #3e2f2f); + --color-lobsters-hat-sysop-brim-stroke: light-dark(#bbb2b2, #2c2222); + + /* mobile */ + --color-mobile-story-liner-bg: light-dark(#fbfbfb, #0c0c0c); + --color-mobile-story-comments-bubble-fill: light-dark(#ccc, #282828); + --color-mobile-story-comments-bubble-fill-zero: light-dark(#d8d8d8, #1c1c1c); +} + +/* +By default, in supporting browsers, the color returned by the light-dark() color function depends +on the user preference set through an operating system's settings (e.g., light or dark mode) or +from a user agent setting. + +This can be overridden by user by setting perferences in their account. +*/ + +html.color-scheme-dark { + color-scheme: dark; +} + +html.color-scheme-light { + color-scheme: light; +} + +/* generics */ + +html { + /* force a vertical scrollbar to avoid page-shifting */ + overflow-y: scroll; + + /* avoid automatic resizing of some text elements on phones in landscape */ + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; +} + +body, textarea, input, button { + font-family: "helvetica neue", arial, sans-serif; + font-size: 10pt; + color: var(--color-fg); + line-height: 1.45em; +} + +body { + background-color: var(--color-bg); + margin: 0 auto; + padding-bottom: 2em; +} + +a { + color: var(--color-fg-link); + cursor: pointer; +} + +li.story div.details span.link a:visited { + color: var(--color-fg-link-visited); +} + +h1 { + font-size: 12pt; + font-weight: bold; + margin: 0.5em 0 1em 0; + padding: 0; +} +h2 { + font-size: 11pt; +} + +.clear { + clear: both; +} + +a.tag { + background-color: var(--color-tag-bg); + border: 1px solid var(--color-tag-border); + border-radius: 5px; + color: var(--color-fg-contrast-10); + font-size: 8pt; + margin-left: 0.25em; + padding: 0px 0.4em 1px 0.4em; + text-decoration: none; + white-space: nowrap; +} +a.tag_is_media { + background-color: var(--color-tag-media-bg); + border-color: var(--color-tag-media-border); + color: var(--color-fg-contrast-10); +} +a.tag_meta { + background-color: var(--color-tag-meta-bg); + border-color: var(--color-tag-meta-border); +} +a.tag_announce, a.tag_ask, a.tag_show, a.tag_interview { + background-color: var(--color-lobsters-tag-special-bg); + border-color: var(--color-lobsters-tag-special-border); + color: var(--color-fg-67); +} + +span.hat { + border-bottom: 6px solid var(--color-hat-brim-stroke); + border-radius: 0px 0px 4px 4px; + padding: 1px 8px; + vertical-align: super; + white-space: nowrap; +} +span.hat span.crown { + background-color: var(--color-hat-crown-fill); + border: 1px solid var(--color-hat-crown-stroke); + border-bottom: 0; + border-radius: 5px 5px 0px 0px; + font-size: 8pt; + padding: 3px 5px 2px 5px; + text-decoration: none; + vertical-align: text-top; +} + +span.hat span.crown, span.hat a { + color: var(--color-fg-contrast-6); + text-decoration: none; +} +span.hat_openbsd_developer span.crown { + font-family: comic sans ms, comic sans, comic neue, sans-serif; + font-size: 7pt; +} +span.hat_sysop { + border-color: var(--color-lobsters-hat-sysop-brim-stroke); + color: var(--color-fg-67); +} +span.hat_sysop span.crown { + background-color: var(--color-lobsters-hat-sysop-crown-fill); +} + +span.na { + color: var(--color-fg-contrast-5); + font-style: italic; +} + +div.shorten_first_p p:first-child { + margin-top: 0.5em; +} + +hr { + /* make it lighter than the browser's default */ + border: none; + height: 1px; + background-color: var(--color-hr); +} +pre { + overflow-x: auto; +} + +/* default form styling */ + +input, +button, +select, +textarea { + color: var(--color-fg-contrast-10); + background-color: var(--color-box-bg); + padding: 3px 5px; +} +textarea { + resize: vertical; +} +input[type="text"], +input[type="search"], +input[type="password"], +input[type="email"], +input[type="number"], +input[type="url"], +textarea { + border: 1px solid var(--color-box-border); +} +input:focus, textarea:focus { + outline-style: solid; + outline-width: 2px; +} +input[type="checkbox"] { + margin-top: 0.5em; +} +select { + border: 1px solid var(--color-box-border); +} +input:focus, +textarea:focus { + border-color: var(--color-box-border-focus); + color: var(--color-fg); + outline: 0; +} +textarea::placeholder { + color: var(--color-fg-contrast-7-5); +} +.link_post { + display: inline; +} +input[type="submit"].link_post { + background: none; + border: none; + padding: 0; + color: var(--color-fg-link); + text-decoration: underline; + cursor: pointer; +} + +input[type="submit"]:focus, +button:focus { + border-color: var(--color-box-border-focus); + outline: 1px solid var(--color-box-border-focus); +} + +/* these must be separate */ +::-webkit-input-placeholder { + color: var(--color-fg-contrast-5); + font-style: italic; +} +:-moz-placeholder { + color: var(--color-fg-contrast-5); + font-style: italic; +} + +form.disowner-form { + display: inline; + button { + background: none; + border: none; + padding: 0; + color: var(--color-fg-contrast-4-5); + text-decoration: none; + cursor: pointer; + outline: none; + &:not(.disable):hover { + color: var(--color-fg-contrast-4-5); + } + } +} + +button, +input[type="button"], +input[type="reset"], +input[type="submit"], +a.button { + background-color: var(--color-button-bg); + border-bottom-color: var(--color-fg-shape); + border: 1px solid var(--color-box-border); + color: var(--color-fg); + cursor: pointer; + display: inline-block; + line-height: 18px; + padding: 2px 10px 2px 10px; + text-align: center; + text-decoration: none; +} +button:first-child, +input[type="button"]:first-child, +input[type="reset"]:first-child, +input[type="submit"]:first-child { + margin-left: 0; +} +button:hover, +input[type="button"]:hover, +input[type="reset"]:hover, +input[type="submit"]:hover { + color: var(--color-fg-contrast-10); + text-decoration: none; + background-color: var(--color-button-bg-shaded); +} + +select { + margin-top: 3px; + min-width: 100px; +} +select:focus { + border-color: var(--color-box-border-focus); + color: var(--color-fg); + outline: 0; +} +select::-moz-focus-inner { + border: 0; + outline: 0; +} + +input:disabled, +textarea:disabled, +button:disabled { + background-color: var(--color-box-bg-shaded); + color: var(--color-fg-contrast-5); +} + +a.button, +button.deletion, +input.deletion { + color: var(--color-fg-negative); + border: 1px solid var(--color-fg-negative); +} + +#qrsvg { + background-color: white; + display: inline-block; + padding: 5%; +} + +.totp_code::-webkit-inner-spin-button, +.totp_code::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + + +/* header */ + +header { + font-weight: bold; + line-height: 18px; +} + +#logo { + background-position: center; + flex: 0 0 16px; /* width; no padding l/r */ + float: left; + display: block; + width: 16px; + height: 16px; + padding: 1px; + margin-left: 4px; + margin-right: 11px; + position: relative; +} +#logo:after { + content: ''; + background: transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAAZiS0dEAL0ALQAtZF7+HAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9wCCBAuLt2rqugAAACMSURBVDjL1ZMxCsJQEERfRPEk9im9mGfxBpaCnaXmBraeQEidKsKz2eIb/peEIOLAwu4Ws7PMbsUAKlOwYCaWmd4aWCV1B4yXpR59R61SitwKDdBHfgPaSTsF8zOmHz5NLykAeCRqvuvCfxGcgP0YF3ZqHy7c1Yt6jfo8dCF3idvkQjcRRVQ/f6bZBC+RBoeZnlCyqwAAAABJRU5ErkJggg==) no-repeat center; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} +@media +only screen and (min-resolution: 2dppx), +only screen and (-webkit-min-device-pixel-ratio: 2) { + #logo { + background: transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABmJLR0QAAAAAAAD5Q7t/AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4AsLFB03Jr/FjQAAAVZJREFUWMPtlr1KQ0EQhc+ZdZMQEkQlNopKENu8gLWVvkQa8QHs8wQ+h4UPYG+fTpsQEATJhYtFrkKy2btrFYuwV/Kv6D2w1QzLNzPLngFy/XcxK9DpdAr1er05zSXOuUet9cM8ABtZgVKpVAZwDeCAZDDPe58CeCV5B2C5AFprQzICsE9mNsoBiAF0VzajOI4raZre+wk55977/f72Wh7KaDS6CgA8L+NumZYh9ATWCbAy5QA5wN8GMMY0jDGNHwEYDofHSqm2Uqrd6/XKawdQSt2QFJJSrVZPZjajBb/uUxG5GJuYiFRW0YGgRSZJsiMitxPjeFm0A6EKdq215wA+ABRIbgI4AnBJco8kvPcgiSRJopnLa7VaHAwGh9baszRNnwJumHkmctxcK1kURVu1Wu3tKzGwlHifbYjjfO+9FRE98wiKxaL/ZhPKhAqoi1y5cv1mfQLQB8QlNdQ0/wAAAABJRU5ErkJggg==) no-repeat; + background-size: 16px; + background-position: 1px 1px; + } +} + +header#nav .navholder { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: clip; + position: relative; /* context for nav gradients */ + width: 100%; +} +header#nav { + box-sizing: border-box; + display: flex; + padding: 0.5rem 0.5rem 0 0.5rem; +} +header#nav nav.links { + display: flex; + width: 100% +} +header#nav a, header#subnav a, header#subnav input[type="submit"] { + color: var(--color-fg-contrast-6); + font-weight: bold; + margin: 0 0.25rem; + text-decoration: none; +} +header#subnav { + box-sizing: border-box; + min-height: 2em; + line-height: 1em; + padding: 0.5rem 0.25rem; + text-align: right; + width: 100%; +} +header#subnav a.current_page:last-child { + margin-right: 0.375rem; +} +header a.current_page { + border-bottom: 0.25em solid var(--color-fg-accent); + padding: 0 0.25rem 0.25rem; +} +header#nav #logo, header#nav #logo ~ a { + margin-left: 0; +} +header#nav a:last-child { + margin-right: 0; +} +header .inbox_unread { + color: var(--color-fg-accent); +} + +#inside { + margin: 0.5rem 0; +} + +/* footer */ + +footer { + box-sizing: border-box; + display: flex; + height: 2em; + line-height: 1em; + padding: 0.5em; + justify-content: end; + width: 100%; +} +footer a { + color: var(--color-fg-contrast-4-5); + text-decoration: none; + padding-left: 0.75em; +} + + +/* other specifics */ +div#gravatar { + border: 2px solid var(--color-box-bg); + border-radius: 100% 100%; + box-shadow: 0 1px 4px var(--color-shadow-80); + float: right; + height: 100px; + width: 100px; +} +div#gravatar img { + border-radius: 50px; + height: 100px; + width: 100px; +} + +/* stories */ + +ol.stories, +ol.comments, +ol.category_tags { + padding: 0; + list-style: none; + margin: 0; +} + +ol.comments { + margin-left: 20px; + margin-bottom: 0em; + padding-left: 6px; +} +ol.comments1 { + margin-left: -40px; + padding-left: 0; +} +ol.comments.comments1 { + border-left-color: transparent; +} +ol.comments1 > li.comments_subtree { + margin-left: 11px; +} +ol.comments1 > li.comments_subtree > ol.comments { + margin-left: 0; + padding-left: 0; +} + +div.voters { + float: left; + margin-top: 0px; + width: 40px; +} + +div.voters div.score { + color: var(--color-fg-contrast-4-5); + font-size: 9pt; + margin-top: 1px; + margin-bottom: -3px; + text-align: center; +} + +li.story div.voters div.score { + font-size: 9.5pt; + margin-top: 2px; +} + +div.voters .upvoter { + border-color: transparent transparent var(--color-fg-shape) transparent; + border-style: solid; + border-width: 6px; + text-decoration: none; + width: 0px; + height: 0; + margin-bottom: 0px; + margin-left: 14px; + padding: 0; + display: block; +} + +div.voters .upvoter:hover, +.upvoted div.voters .upvoter { + border-bottom-color: var(--color-fg-accent); +} + +div.voters .upvoter { + border-bottom-width: 11px; +} + +li.story { + clear: both; +} +ol.stories li.story div.story_liner { + padding-top: 0.25em; + padding-bottom: 0.25em; + word-break: break-word; +} +.comment { + clear: both; + padding-top: 0.4em; + padding-bottom: 0.4em; + position: relative; +} + +.comment a { + color: var(--color-fg-contrast-7-5); +} + +ol.stories li:first-child div.story_liner { + padding-top: 0.5em; +} +li div.details { + padding-top: 0.1em; +} + +/* +Stories with negative scores are dimmed by decreasing their opacity. +However, if the flag or archive dropdowns have an opacity set, it creates a "stacking context", +which overides the Z index, so the drop down appears under textareas, making them unclickable. +Thus, the selector for opacity must not target those divs or any of their direct parents. +We must also becareful to apply to only children and not any of the parents. +Otherwise it will double apply, resulting in invidual elements being darker. + +We'd like to write the following selector to express that directly, but can't until Firefox +supports it: https://caniuse.com/?search=%3Ahas +.negative_1 *:not(.dropdown_parent, :has(*)) { ... } +*/ +.negative_1 *:not(div, .dropdown_parent, .dropdown_parent > div *, .comments_label, .link, .tags, img), +.negative_1 .score { + opacity: 0.7; + } + +.negative_3 *:not(div, .dropdown_parent, .dropdown_parent > div *, .comments_label, .link, .tags, img), +.negative_3 .score { + opacity: 0.6; +} + +.negative_5 *:not(div, .dropdown_parent, .dropdown_parent > div *, .comments_label, .link, .tags, img), +.negative_5 .score { + opacity: 0.5; +} + +.comment.bad *:not(div, .dropdown_parent, .dropdown_parent *) { + opacity: 0.7; +} + +.comment:target, li:target, p:target, div:target, span:target { + background-color: var(--color-bg-target); + border-radius: 8px; +} + +.showing-user-comment { + background-color: var(--color-bg-target); + border-radius: 8px; + padding: 3px; +} + +li .link { + font-weight: bold; + vertical-align: middle; +} + +li .link a { + font-size: 11.5pt; + text-decoration: none; +} + +li.story .description_present { + color: var(--color-fg-contrast-5); + padding-left: 0.25em; + text-decoration: none; + vertical-align: middle; +} + +li.story a.tag { + vertical-align: middle; +} + +li .tags { + margin-right: 0.25em; +} + +li .domain { + color: var(--color-fg-contrast-4-5); + font-style: italic; + font-size: 9pt; + text-decoration: none; + vertical-align: middle; +} + +.merge { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAsAAAAOCAYAAAD5YeaVAAAD8GlDQ1BJQ0MgUHJvZmlsZQAAOMuNVd1v21QUP4lvXKQWP6Cxjg4Vi69VU1u5GxqtxgZJk6XpQhq5zdgqpMl1bhpT1za2021Vn/YCbwz4A4CyBx6QeEIaDMT2su0BtElTQRXVJKQ9dNpAaJP2gqpwrq9Tu13GuJGvfznndz7v0TVAx1ea45hJGWDe8l01n5GPn5iWO1YhCc9BJ/RAp6Z7TrpcLgIuxoVH1sNfIcHeNwfa6/9zdVappwMknkJsVz19HvFpgJSpO64PIN5G+fAp30Hc8TziHS4miFhheJbjLMMzHB8POFPqKGKWi6TXtSriJcT9MzH5bAzzHIK1I08t6hq6zHpRdu2aYdJYuk9Q/881bzZa8Xrx6fLmJo/iu4/VXnfH1BB/rmu5ScQvI77m+BkmfxXxvcZcJY14L0DymZp7pML5yTcW61PvIN6JuGr4halQvmjNlCa4bXJ5zj6qhpxrujeKPYMXEd+q00KR5yNAlWZzrF+Ie+uNsdC/MO4tTOZafhbroyXuR3Df08bLiHsQf+ja6gTPWVimZl7l/oUrjl8OcxDWLbNU5D6JRL2gxkDu16fGuC054OMhclsyXTOOFEL+kmMGs4i5kfNuQ62EnBuam8tzP+Q+tSqhz9SuqpZlvR1EfBiOJTSgYMMM7jpYsAEyqJCHDL4dcFFTAwNMlFDUUpQYiadhDmXteeWAw3HEmA2s15k1RmnP4RHuhBybdBOF7MfnICmSQ2SYjIBM3iRvkcMki9IRcnDTthyLz2Ld2fTzPjTQK+Mdg8y5nkZfFO+se9LQr3/09xZr+5GcaSufeAfAww60mAPx+q8u/bAr8rFCLrx7s+vqEkw8qb+p26n11Aruq6m1iJH6PbWGv1VIY25mkNE8PkaQhxfLIF7DZXx80HD/A3l2jLclYs061xNpWCfoB6WHJTjbH0mV35Q/lRXlC+W8cndbl9t2SfhU+Fb4UfhO+F74GWThknBZ+Em4InwjXIyd1ePnY/Psg3pb1TJNu15TMKWMtFt6ScpKL0ivSMXIn9QtDUlj0h7U7N48t3i8eC0GnMC91dX2sTivgloDTgUVeEGHLTizbf5Da9JLhkhh29QOs1luMcScmBXTIIt7xRFxSBxnuJWfuAd1I7jntkyd/pgKaIwVr3MgmDo2q8x6IdB5QH162mcX7ajtnHGN2bov71OU1+U0fqqoXLD0wX5ZM005UHmySz3qLtDqILDvIL+iH6jB9y2x83ok898GOPQX3lk3Itl0A+BrD6D7tUjWh3fis58BXDigN9yF8M5PJH4B8Gr79/F/XRm8m241mw/wvur4BGDj42bzn+Vmc+NL9L8GcMn8F1kAcXi1s/XUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsSAAALEgHS3X78AAAAB3RJTUUH3gQIECAcSXeCTQAAAdhJREFUKM9dkDFoE1Ecxr/vvbvkBiEgFaSDg6uDBBJvEl2kOBaEIrgEAle4xxUKFnGR4mCCLiFZ3MVCBzOIdHBx6CIhgoNKQESsS7GKLVbv8u69v4NNof7Gb/h9fB9FBIPBIAHQwT/uGGMe4z9IkoPB4KKIbAM4BQACOSR4yRjzvtfrtUXkrFJqK8uyMfv9/pUwDF/leS4AWK1WUZbl1bIsL4dheF8phaIoPgFYUmEYvrPWvgyCgGEYoizL5yR3RMR471EUxZ8ois5TeF0lSbLnnLvpvf/ivf86nU5vzc/Pfya5SxIiEllrIZQd1e129crKyncA30jurq6uHiwuLnqSaVmWH0jCe/+oVqttqPF47GaLvfcAgCPBttb6dRAEh1EU3Wu1WoXa3NxEu90+vihJEgA4kc1Q3W5XO3csh7X2RMuM9fV1BJPJxDWbzdPOuTMAEMfx3Gg02ms0GnXn3BKAF8vLy79FBEEcx3PT6fSp1vrckflJvV5/5r2/S1IAPBARDIdDFVhrL1QqlWt5ngOAVCqVBRFZEJGPJG+kafqGJEXEK5I/rbW/tNbQWtM5t++cW1NKxcaYLZIEICShjDFvSd4GsA/ggORalmUP0zT90el0NACZjfwLrcfo3fIgR6gAAAAASUVORK5CYII=) no-repeat; + width: 11px; + height: 14px; + padding-right: 2px; + display: inline-block; + vertical-align: middle; +} + +li input.comment_folder_button { + display: none; +} +li .comment_folder { + display: inline-block; + font-size: 9pt; + color: var(--color-fg-contrast-4-5); + letter-spacing: 0.1em; + width: 1.5em; + cursor: pointer; + float: left; + text-align: center; + margin-left: -4px; +} +li .comment_folder.comment_folder_inline { + display: none; + color: inherit; + width: 1.4em; + float: none; + text-align: left; + margin: 0; +} +li .comment_folder.comment_folder_inline.force_inline { + display: inline-block; +} +li .comment_folder:before { + content: "[-]"; +} +li .comment_folder_button:checked ~ .comment .comment_folder:before { + content: "[+]"; +} +li .comment_folder_button:checked ~ div.comment div.comment_text { + display: none; +} +li .comment_folder_button:checked ~ div.comment div.voters { + visibility: hidden; + margin-bottom: -15px; +} +li .comment_folder_button:checked ~ ol.comments ol, +li .comment_folder_button:checked ~ ol.comments div.comment, +li .comment_folder_button:checked ~ ol.comments li { + display: none; +} + +li.comments_subtree { + position: relative; +} + +li .comment_parent_tree_line { + position: absolute; + left: 30px; + border-left: 1px dotted var(--color-fg-shape); + top: 30px; + bottom: 0; +} + +li .comment_parent_tree_line.score_shown { + top: 44px; +} +li .comment_parent_tree_line.can_flag { + top: 56px; +} +li .comment_parent_tree_line.no_children { + border-left-color: transparent; +} +li .comment_siblings_tree_line { + position: absolute; + left: 4px; + border-left: 1px dotted var(--color-fg-shape); + top: 2em; + bottom: 0; +} +li:last-child > .comment_siblings_tree_line { + border-left-color: transparent; +} + +/* try to force a highlighted comment to stay visible */ +div.comment:target div.comment_text, +div.comment:target div.voters { + display: block !important; +} + +.thread_summary { + color: var(--color-fg-contrast-4-5); + font-size: 9.5pt; + /* .comment_folder (-4px margin-left + 1.5em width) + .voters (30px width) */ + margin-left: calc(-4px + 1.5em + 30px); +} +li .byline { + color: var(--color-fg-contrast-4-5); + font-size: 9.5pt; +} +li .byline > img.avatar { + margin-bottom: -5px; +} +li .byline img.avatar { + border-radius: 8px; + height: 16px; + margin-bottom: 2px; + margin-right: 2px; + vertical-align: middle; + width: 16px; +} +li.story .byline { + margin-top: 1px; +} +li.story span.byline { + margin-left: 0.5em; +} +.byline a { + color: var(--color-fg-contrast-4-5); + text-decoration: none; +} +/* preserve selection made elsewhere when clicking */ +li .byline a.comment_replier { + user-select: none; +} +span.new_user, a.new_user, +li .byline a.new_user { + color: var(--color-fg-affirmative); +} +span.inactive_user, a.inactive_user, +li .byline a.inactive_user, +.inactive_tag { + color: var(--color-fg-contrast-5); + text-decoration: line-through; +} +span.user_is_author, a.user_is_author, +li .byline a.user_is_author { + color: var(--color-fg-author); +} +li .byline a.story_has_suggestions { + color: var(--color-lobsters-fg-has-suggestions); +} + +.last_read_newest { + border-bottom: 2px solid var(--color-fg-shape); + border-top: 0; + border-left: 0; + border-right: 0; + margin: 1em 0 1em 32px; /* matching div.detail */ +} + +li.story.hidden *:not(div, .dropdown_parent, .dropdown_parent > div *, .comments_label, .link, .tags, img), +li.story.hidden .score { + opacity: 0.25; +} + +ol.show_hidden li.story.hidden { + opacity: 1.0 !important; +} +li.story.saved a.saver { + color: var(--color-fg-affirmative); +} + +li.story.deleted { + opacity: 0.6; +} +li.story.deleted a { + color: var(--color-fg-contrast-5) !important; +} + +li div.details { + margin-left: 32px; +} +div.story_content { + margin-bottom: 1em; +} +ol.stories.list div.story_content { + color: var(--color-fg-contrast-6); + max-height: 2.6em; + margin: 0.25em 40px 0.25em 0; + overflow: hidden; + text-overflow: clip; +} + +div.morelink { + float: left; +} + +div.morelink, +div.page_link_buttons { + margin-top: 1.5em; +} +div.morelink a { + color: var(--color-fg-contrast-7-5); + font-weight: bold; + text-decoration: none; +} +div.page_link_buttons { + font-weight: bold; + margin-top: 2em; +} +div.page_link_buttons a, +div.page_link_buttons span { + border: 1px solid var(--color-box-border); + background-color: var(--color-button-bg); + color: var(--color-fg-contrast-7-5); + padding: 0.25em 0.5em; + font-weight: bold; + text-decoration: none; + margin-left: 0.5em; +} +div.page_link_buttons a.cur, +div.page_link_buttons span { + background-color: transparent; + border-color: transparent; + margin-left: 0.25em; + padding-right: 0.25em; +} + + +div.story_text { + margin-bottom: 1.5em; + max-width: 700px; + word-wrap: break-word; +} +div.story_text p { + margin: 0.75em 0; +} + +div#collapsed_story_text { + display: none; +} +a#story_text_expander { + display: block; +} + +div.comment_text { + font-size: 10.5pt; + max-width: 700px; + word-wrap: break-word; + overflow: hidden; +} + +div.comment_text blockquote, +div.markdown_help blockquote, +div.story_text blockquote { + font-style: italic; + margin: 0.25em 0 0 0.5em; + padding: 0 0 0 1em; + border-left: 2px solid var(--color-fg-contrast-5); +} + +/* un-italicize italics inside a blockquote */ +div.comment_text blockquote em, +div.markdown_help blockquote em, +div.story_text blockquote em { + font-style: normal +} + +div#collapsed_story_text div.story_text blockquote { + font-style: normal; +} +div.comment_text pre, +div.markdown_help pre { + margin-left: 1em; +} +div.comment_text ol { + margin: 0; + padding: 0 1.5em; +} +div.comment_text ol li { + padding: 0 !important; + margin: 0 !important; +} + +div.comment_text p { + margin: 0.5em 0; +} +div.comment_text p:first-child { + margin-top: 0.3em; +} + +div.comment_text code { + line-height: 1.2em; +} + +.dropdown_parent { + position: relative; +} + +.comments_subtree .dropdown_parent > #flag_dropdown { + position: absolute; + left: 0.25rem; +} + +#flag_dropdown, .archive-dropdown { + position: absolute; + left: -0.125rem; + width: 100px; + border: 1px solid var(--color-box-border); + border-bottom: 0; + z-index: 15; +} +#flag_dropdown a, .archive-dropdown a { + background-color: var(--color-box-bg); + border-bottom: 1px solid var(--color-box-border); + color: var(--color-fg-contrast-10); + display: block; + font-size: 9pt; + padding: 3px; + text-decoration: underline; +} +#flag_dropdown a:hover, .archive-dropdown a:hover { + background-color: var(--color-box-bg-shaded); +} +#flag_dropdown a.cancel-reason { + background-color: var(--color-box-bg-shaded); + font-size: 8pt; +} + +#modal_backdrop { + position: fixed; + width: 100%; + height: 100%; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 1; +} + +/* archive; dropdown styling is with #flag_dropdown to match it */ +.archive_button { + display: none; +} +.archive_button:not(:checked) ~ .archive-dropdown { + display: none; +} +.archive_button ~ label{ + cursor: pointer; +} + + + +.markdown_help { + background-color: var(--color-box-bg-shaded); + border: 1px solid var(--color-box-border); + padding: 0 1em; + margin-top: 0.5em; + display: none; +} + +div.markdown_help_label { + float: right; + font-size: 9pt; + line-height: 2em; + color: var(--color-fg-contrast-4-5); + text-decoration: underline; + cursor: pointer; +} + +.comment .preview { + padding-left: 17px; +} + +div#story_preview { + margin-top: 2em; + margin-left: 3.5em; +} + +div#story_box input#story_url { + width: 512px; +} +div#story_box button#story_fetch_title { + height: 27px; + padding-top: 1px; + padding-bottom: 1px; + width: 84px; +} +div#story_box input#story_title, +div#story_box input#story_moderation_reason, +div#story_box input#story_merge_story_short_id, +div#domain_box input[type=text] { + width: 600px; +} +#story_box .url-updated, +#story_box .title-reminder, +#story_box .title-reminder-thanks +{ + display: none; +} +#story_box details ul { + padding-left: 1em; +} +.slide-down { + display: block !important; + transition: height .5s ease !important; +} +div#story_box #story_tags_a { + width: 624px; +} +div#story_box textarea { + width: 600px; +} + +div.comment_form_container { + margin-left: -11px; +} + +div.comment_form_container form { + margin-left: 40px; + max-width: 700px; +} + +div.comment_form_container textarea { + box-sizing: border-box; + width: 100%; +} + +span.comment_unread { + color: var(--color-fg-accent); + font-weight: 600; + cursor: pointer; +} + +/* trees */ + +.tree, +.tree ul { + margin: 0 0 0 0.5em; + padding: 0; + list-style: none; + position: relative; +} + +.tree ul { + margin-left: 0.5em; +} + +.tree:before, +.tree ul:before { + border-left: 1px solid var(--color-fg-shape); + bottom: 0; + content: ""; + display: block; + left: 0; + position: absolute; + top: 0; + width: 0; +} + +.tree li { + margin: 0; + padding: 0 1.1em; + position: relative; +} + +.tree li:before { + border-top: 1px solid var(--color-fg-shape); + content: ""; + display: block; + height: 0; + left: 0; + margin-top: -1px; + position: absolute; + top: 0.8em; + width: 8px; +} + +.tree li:last-child:before { + background-color: var(--color-bg); + border-left: 0; + bottom: 0; + height: auto; +} + +li.noparent:before, +ul.noparent:before { + border-top: 0 !important; + border-left: 0 !important; +} + +ul.user_tree { + color: var(--color-fg-contrast-4-5); +} + +/* /~:username/standing */ +/* https://codepen.io/sosuke/pen/Pjoqqp */ +.unwarned { + filter: invert(36%) sepia(52%) saturate(0%) hue-rotate(292deg) brightness(96%) contrast(85%); +} +.warned, .jaccuse { + filter: invert(13%) sepia(85%) saturate(4911%) hue-rotate(356deg) brightness(78%) contrast(95%); +} +table.standing tr td { vertical-align: top; } +table.standing td:first-child { text-align: right } +td.warned div, td.unwarned div { + display: inline-block; + height: 1em; + line-height: 1em; + text-align: center; + width: 1em; +} + +/* /mod dashbaord */ + +.mod { + color: var(--color-fg-accent); +} +.modmodlog .mod td:first-child { + border-left: 2pt solid var(--color-fg-accent); +} +.nominal { + color: var(--color-fg-accent); + font-size: 2em; + text-align: center; +} + +/* data tables */ + +table.data th { + background-color: var(--color-table-header-bg); + border-bottom: 1px solid var(--color-table-header-border); + border-top: 1px solid var(--color-table-header-border); + text-align: left; +} +table.data th img { + margin-bottom: 5px; +} + +table.data th.r, table.data td.r { + text-align: right; + padding-right: 3px; +} + +table.data th, +table.data td { + padding: 0.25em 0.5em; +} + +table.data tr.bold td { + font-weight: bold; +} + +table.data.zebra tr:nth-child(even) td { + background-color: var(--color-table-row-bg-even); + border-bottom: 1px solid var(--color-table-row-border); +} +table.data.zebra tr:nth-child(odd) td { + background-color: var(--color-table-row-bg-odd); + border-bottom: 1px solid var(--color-table-row-border); +} +table.data tr.nobottom td { + border-bottom: 0px; + padding-bottom: 0px; +} +table.data tr.nobottom td.wrap { + overflow-wrap: anywhere; +} +table.data.tall td { + vertical-align: top; +} + +table.data td p:first-child { + margin-top: 0; +} +table.data td p:last-child { + margin-bottom: 0; +} +table.data pre { + overflow-x: scroll; + max-width: 800px; +} +table.moderations tr > td:nth-child(1) { + white-space: nowrap; +} + +/* boxes */ + +.box label { + display: block; + float: left; + margin-bottom: 4px; +} + +.box label.required { + font-weight: bold; +} +.box img { + vertical-align: middle; +} +.box label, +.box > span, +.box select, +.box br { + line-height: 2em; +} +.box br { + clear: left; +} +.box .boxline { + clear: both; + margin-bottom: 0.5em; +} +.box p { + margin-top: 1em; +} + +.box textarea { + margin-bottom: 4px; + width: 75%; +} + +.box label { + width: 7em; + line-height: 2em; + vertical-align: middle; +} +.box div.d { + margin-left: 7em; +} + +.box input.normal, +.box label.normal { + display: inline; + float: none; + vertical-align: middle; +} + +.box span.d label, +.box td label, +.box div.d label { + display: inline; + float: none; + vertical-align: baseline; +} + +.box span.d a.tag { + vertical-align: middle; +} + +.hint { + color: var(--color-fg-contrast-5); + font-style: italic; +} +.hint.indent { + margin-left: 12em; +} + +.box.wide label { + width: 12em; +} +.box.wide div.d { + margin-left: 12em; +} + +.display-block { + display: block; +} + +/* for flash_notices() and flash_errors() */ + +div.flash-error, +div.flash-notice, +div.flash-success, +div.user-stats { + position: relative; + padding: 7px 15px; + margin-bottom: 18px; + border-color: var(--color-shadow-10) var(--color-shadow-10) var(--color-shadow-25); + border-width: 1px; + border-style: solid; + border-radius: 4px; + box-shadow: inset 0 1px 0 var(--color-light-25); +} +div.flash-error a, +div.flash-notice a, +div.flash-success a { + font-weight: bold; + color: var(--color-fg-contrast-13); +} +div.flash-error .similar a, +div.flash-notice .similar a, +div.flash-success .similar a, +div.user-stats a { + font-weight: normal; +} +div.flash-error div, +div.flash-notice div, +div.flash-success div { + margin-top: 5px; + margin-bottom: 2px; + line-height: 28px; +} +div.flash-error { + background-color: var(--color-flash-bg-error); +} +div.flash-success { + background-color: var(--color-flash-bg-success); +} +div.flash-notice { + background-color: var(--color-flash-bg-notice); +} +div.user-stats { + background-color: var(--color-box-bg-shaded); +} + +div.flash-error h2, +div.flash-notice h2, +div.flash-success h2, +div.user-stats h2 { + font-size: 1.25em; + margin: 0; +} + +/* Using #story_holder for precedence over TomSelect's vendored CSS */ + +#story_holder .ts-control { + background-color: var(--color-box-bg); + border: 1px solid var(--color-box-border); + border-radius: 0px; + box-shadow: none; + box-sizing: border-box; + margin-bottom: 2px; + padding: 2px 0 0px 5px; + /* supposed to be inherited from body, but TS overwrites */ + font-family: "helvetica neue", arial, sans-serif; + font-size: 13.33px; + color: var(--color-fg); + line-height: 1.5rem; +} + +#story_holder .ts-control:focus-within { + border-color: var(--color-box-border-focus) +} + +#story_holder .ts-control .data-ts-item { /* item already selected by user*/ + color: var(--color-fgcontrast-10); + line-height: 13px; + margin: 3px 5px 3px 0; + padding: 3px 0.5rem 3px 1rem !important; + position: relative; + cursor: default; +} + +#story_holder .ts-control .data-ts-item a { + display: block; + position: absolute; + height: 13px; + padding: 2px; + left: 0px; + margin-left: 0; + /* + For a decade, select2 had the `x` to remove a tag on the left. TomSelect puts it + on the right, so we hide that and insert an`x` on the left with `a::after` below. + */ + visibility: hidden; +} + +#story_holder .ts-control .data-ts-item a::after { + content: "\2715"; + display: block; + position: absolute; + color: grey; + font-size: 10px; + outline: none; + padding-right: 3px; + top: 0px; + width: 12px; + cursor: pointer; + visibility: visible; +} + +#story_holder .ts-dropdown { + position: relative; + width: 100%; + background: #fff; + background-color: var(--color-box-bg); + border-color: var(--color-box-border-focus); + border-radius: 0 0 4px 4px; + border-top: 1px solid #aaa; + box-shadow: 0 4px rgb(0 0 0 / 15%); + margin: 0px; + padding: 0px; + z-index: 999; +} + +#story_holder .ts-dropdown .ts-dropdown-content .active { + background: var(--color-bg-accent); + color: var(--color-box-bg); +} + +#story_holder .ts-dropdown-content { + max-height: 200px; + padding: 0; + position: relative; + overflow-x: hidden; + overflow-y: scroll; +} + +#story_holder .ts-dropdown .option { + color: var(--color-fg); + font-family: "helvetica neue", arial, sans-serif; + line-height: 80%; + margin: 0px; + padding: 7px 7px 8px; + cursor: pointer; +} + +/* pushover */ +input[type="submit"].link_post.pushover_button { + box-sizing: border-box; + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QAJQCeAPHNVUx7AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3wEPAh02ee0QVwAAACZpVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVAgb24gYSBNYWOV5F9bAAABqElEQVQ4y62TvUtbURjGf+fek2vQpMF2ED9QkfpR6VLaDoIEF0UKWQRBQ+yQP6GDU0Xw4y/obOgg6dDSJVS61MlBcXFQB6M1BsF+IES9NnrzcRy8xpvrBwH7bOflfc553+d5jsCF9tiRDwgBEaDXLi8B80AiGQ2Yzn7hIo8Cce5HOBkNfLo6aA7ydAVkgLjdez3BrS8rRbBJEmyU+AyBVVDE1i1SJ6psEmHvfOLktj0SjL/2snKQx8wpNg+LBJt0RjoN+j6bqOvN/ZotWAldtYK5gWpmV84YbPUw2ePlb7bI73+Kep+OLFONkGarXcK7l16+7+V57BW8qJMs7Ob5k4VQm4fVXzlyquyGiHRYBcC+WWQxnSP63AAgtn7O0FNJ9xOd0W+nbkF7pbsytXxOQw30N0t2MgX6Wzy8qtMJL5zy81jdsETaIXnjLL7trkLqGl+2svxI5/mwZt1l6ZJmJ6yEGqkYbveQOioQ2yiz7TbMa0DCWRnuMPBXaXzcsJx23YWEZmc7fJkqxdgzg8xZka/bFhVE2hSuKL+nMswko4GJ//KZxEO/8wVmfpjJTWeCTQAAAABJRU5ErkJggg==) 2px 2px no-repeat, linear-gradient(var(--color-fg-gradient-lit), var(--color-fg-gradient-shadowed)); + border: 1px solid var(--color-box-border); + border-radius: 3px; + color: var(--color-fg); + display: inline-block; + font: 11px/18px "Helvetica Neue",Arial,sans-serif; + font-weight: bold; + cursor: pointer; + height: 22px; + padding-left: 20px; + padding-right: 5px; + overflow: hidden; + text-shadow: 0px 1px 0px var(--color-bg-50); + text-decoration: none; + vertical-align: middle; +} + +/* search */ + +.searchresults summary .heading { + font-weight: bold; + font-size: 11pt; /* h2 */ +} +.searchresults summary { + margin: 1em 0; +} +.search_parse dd { + margin-bottom: 0.5em; +} +.searchq { + background: var(--color-box-bg-shaded); + border-radius: 4px; + font-family: monospace; + padding: 2px; +} + + +@media screen and (min-width: 481px) { + body { + max-width: 900px; + padding-bottom: 2em; + } + + header#nav { + margin: 1em 0 0.5rem 0; + padding: 5px 10px; + } + header#nav nav.links { + flex-flow: row wrap; + justify-content: start; + } + header#nav nav.links > .corner { + display: inline; + margin-left: auto; + } + header#nav nav.corner { + display: none; + } + header#subnav a:last-child { + margin-right: 0.5rem; + } + + div#inside { + margin-left: calc( + 10px + /* header#nav padding-left */ + 18px + /* #logo width + padding l/r */ + 0.25rem + /* header#nav a margin-right for logo */ + 0.25rem /* header#nav a margin-left for first link in header#nav */ + ); + margin-right: 10px; /* header#nav padding-right */ + } + + /* hang voters into left margin to center under logo */ + ol.stories, + #inside > ol.comments { + /* .voters width + .voters padding-right */ + margin-left: -32px; + } + ol.stories div.voters, + #inside > ol.comments div.voters { + width: 30px; + } + ol.stories .upvoter, + #inside > ol.comments .upvoter { + margin-left: 9px; + } + + .help { + margin-top: 2em; + } + + + div#story_box .actions { + margin-left: 7em; + width: 610px; + } + #story_holder .ts-control { + display: inline-block; + position: absolute; + width: 611px; + } +} + +@media only screen and (max-width: 480px) { + html { + -webkit-text-size-adjust: none; + } + + header#nav .corner .inbox { + font-size: 18px; + vertical-align: bottom; + } + header#nav .corner .inbox .inbox_unread { + font-size: 10pt; + } + header#nav { + background-color: var(--color-box-bg-shaded); + border-bottom: 1px solid var(--color-box-border); + flex-flow: row nowrap; + height: 44px; + min-width: 100%; + padding-top: 0.75rem; + } + header#nav nav.links { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: clip; + } + + header#nav .navholder:before, header#nav .navholder:after { + content: ""; + height: 100%; + pointer-events: none; + position: absolute; + top: 0px; + width: 2em; + z-index: 1; + } + header#nav .navholder:before { + background: linear-gradient(to right, var(--color-box-bg-shaded), transparent); + left: -1em; + } + header#nav .navholder:after { + right: 0px; + background: linear-gradient(to right, transparent, var(--color-box-bg-shaded)); + } + header#nav nav.links > .corner { + display: none; + } + header#nav nav.corner { + display: block; + } + header#subnav { + background-color: var(--color-box-bg-shaded); + } + header#subnav a:last-child { + margin-right: 0.75rem; + } + + header#nav .user { + display: inline; + margin-top: 0.25em; + clear: both; + } + + header#nav a { + padding-right: 0.25em; + padding-left: 0; + } + div#inside { + margin: 0.5rem; + } + + .comment { + padding-left: 0; + } + + .comment .preview { + padding-left: 25px; + } + + ol.comments { margin-left: 10px } + ol.stories, + div#inside > ol.comments { + margin: 0 0.5rem 0 -0.5rem; + padding-left: 0; + } + ol.comments1 > li.comments_subtree { + margin-left: 0; + } + + li .comment_folder { + display: none; + } + li .comment_folder.comment_folder_inline { + display: inline-block; + } + + li .comment_parent_tree_line, + li .comment_siblings_tree_line { + border-left-color: transparent; + } + + div.voters { + margin-left: 0.25em; + margin-top: 0px; + width: 30px; + } + div.voters a.upvoter { + margin-top: -5px; + } + + div.comment_form_container { + margin-left: 0; + } + + div.comment_form_container form { + margin-left: 35px; + } + + div.voters a.upvoter { + margin-left: 6px; + border-width: 9px; + } + + /* explicitly reset color when not upvoted, since previously-upvoted arrow + * will still be triggering :hover until next tap */ + div.voters .upvoter:hover { + border-bottom-color: var(--color-fg-shape); + } + .upvoted div.voters .upvoter { + border-bottom-color: var(--color-fg-accent); + } + + ol.stories.list { + margin-top: 0; + } + + ol.stories.list li.story { + display: table; + } + ol.stories.list li.story div.story_liner { + background-color: var(--color-mobile-story-liner-bg); + display: table-cell; + padding-top: 0.5em; + padding-bottom: 0.75em; + width: 100%; + } + ol.stories.list li.story .mobile_comments { + display: table-cell !important; + font-size: 11pt; + min-width: 40px; + text-align: center; + text-decoration: none; + vertical-align: middle; + } + ol.stories.list li.story .mobile_comments span:before { + border-style: solid; + border-width: 0px 6px 3px 0px; + border-color: var(--color-mobile-story-comments-bubble-fill); + border-bottom-right-radius: 13px; + bottom: -10px; + content: ""; + display: block; + height: 7px; + left: 4px; + position: absolute; + width: 5.4px; + z-index: 10; + } + ol.stories.list li.story .mobile_comments span:after { + border-bottom-right-radius: 10px; + border-color: var(--color-mobile-story-comments-bubble-fill); + border-style: solid; + border-width: 0px 3px 3px 0px; + bottom: -10px; + content: ""; + display: block; + height: 7px; + left: 5px; + position: absolute; + width: 10px; + z-index: 10; + } + ol.stories.list li.story .mobile_comments span { + background-color: var(--color-mobile-story-comments-bubble-fill); + border: 1px solid var(--color-mobile-story-comments-bubble-fill); + border-radius: 5px; + color: var(--color-fg); + display: block; + font-size: 9pt; + margin: 0 0.5em; + padding: 2px; + position: relative; + text-align: center; + } + ol.stories.list li.story .mobile_comments:active span, + ol.stories.list li.story .mobile_comments:focus span { + outline: 0; + } + ol.stories.list li.story .mobile_comments.zero span { + background-color: var(--color-mobile-story-comments-bubble-fill-zero); + color: var(--color-fg-contrast-4-5); + } + ol.stories.list li.story .mobile_comments.zero span, + ol.stories.list li.story .mobile_comments.zero span:before, + ol.stories.list li.story .mobile_comments.zero span:after { + border-color: var(--color-mobile-story-comments-bubble-fill-zero); + } + ol.stories.list li.story .comments_label { + display: none; + } + + li div.details { + margin: 0 0 0 36px; + } + div.story_content { + margin: 0 0 0 28px; + } + div.morelink { + margin-left: 28px; + float: none; + } + li.story div.byline { + font-size: 10pt; + } + + div.box label, + div.boxline label { + display: block; + width: 100% !important; + } + div.box div.d, + div.box.wide div.d { + margin-left: 0; + } + + div.boxline textarea, + div.boxline input[type="text"], + div.comment textarea { + max-width: 90%; + } + + div.markdown_help_label { + display: none; + } + div.markdown_help_label_mobile { + display: inline !important; + margin-right: 2em; + } + + div#story_box input, + div#story_box button, + div#story_box textarea, + div#story_box #story_tags_a, + div.actions { + margin: 0 !important; + } + + div#story_box #story_tags_a { + min-width: 305px !important; + } + + div#gravatar { + float: none; + } + + div#user_about { + margin-left: 0; + } + + #story_holder .ts-wrapper .multi { + margin-left: 0rem; + } + + footer { + text-align: center; + float: none; + } + .indent { + margin-left: 2em; + } +} +@media print { + .comment_folder, + .comment_form { + display: none; + } +} diff --git a/benchmarks/lobsters/app/controllers/about_controller.rb b/benchmarks/lobsters/app/controllers/about_controller.rb index 8ca4feed..febff1a0 100644 --- a/benchmarks/lobsters/app/controllers/about_controller.rb +++ b/benchmarks/lobsters/app/controllers/about_controller.rb @@ -1,14 +1,13 @@ +# typed: false + class AboutController < ApplicationController caches_page :about, :chat, if: CACHE_PAGE before_action :show_title_h1, except: [:four_oh_four] def four_oh_four @title = "Resource Not Found" + @requested_path = request.original_fullpath render action: "404", status: 404 - rescue ActionView::MissingTemplate - render status: 404, html: ( - "

404

Resource not found

" - ).html_safe, layout: 'application' end def about @@ -16,30 +15,26 @@ def about @title = "About" render action: "about" rescue ActionView::MissingTemplate - render layout: 'application', html: ("

A mystery.") + render layout: "application", html: "

A mystery." end raise "Seriously, write your own about page." if @homeabout end def chat - begin - @title = "Chat" - render action: "chat" - rescue ActionView::MissingTemplate - render html: ("

Don't speak. I know what you're thinking.

"), - layout: 'application' - end + @title = "Chat" + render action: "chat" + rescue ActionView::MissingTemplate + render html: "

Don't speak. I know what you're thinking.

", + layout: "application" end def privacy - begin - @title = "Privacy Policy" - render action: "privacy" - rescue ActionView::MissingTemplate - render layout: 'application', html: <<-HTML + @title = "Privacy Policy" + render action: "privacy" + rescue ActionView::MissingTemplate + render layout: "application", html: <<-HTML
You have zero privacy anyway. Get over it.

Scott McNealy

- HTML - end + HTML end end diff --git a/benchmarks/lobsters/app/controllers/application_controller.rb b/benchmarks/lobsters/app/controllers/application_controller.rb index 0ec9bfcb..fd0e183f 100644 --- a/benchmarks/lobsters/app/controllers/application_controller.rb +++ b/benchmarks/lobsters/app/controllers/application_controller.rb @@ -1,52 +1,55 @@ +# typed: false + class ApplicationController < ActionController::Base include IntervalHelper + include Authenticatable protect_from_forgery - before_action :authenticate_user + before_action :geoblock_uk + before_action :heinous_inline_partials, if: -> { Rails.env.development? } before_action :mini_profiler - before_action :set_traffic_style before_action :prepare_exception_notifier + before_action :set_traffic_style + around_action :n_plus_one_detection - # match this in your nginx config for bypassing the file cache - TAG_FILTER_COOKIE = :tag_filters - - # returning false until 1. nginx wants to serve cached files - # 2. the "stay logged in" cookie is separated from rails session cookie - # (lobster_trap) which is sent even to logged-out visitors - CACHE_PAGE = proc { false && @user.blank? && cookies[TAG_FILTER_COOKIE].blank? } + # 2023-10-07 one user in one of their browser envs is getting a CSRF failure, I'm reverting + # because I'll be AFK a while. + # after_action :clear_lobster_trap - rescue_from ActionController::UnknownFormat do - render plain: '404 Not Found', status: :not_found, content_type: 'text/plain' + # match this nginx config for bypassing the file cache + TAG_FILTER_COOKIE = :tag_filters + CACHE_PAGE = proc { @user.blank? && cookies[TAG_FILTER_COOKIE].blank? } + + # Rails misdesign: if the /recent route doesn't support .rss, Rails calls it anyways and then + # raises MissingTemplate when it's not handled, as if the app did something wrong (a prod 500!). + unless Rails.env.development? + rescue_from ActionController::UnknownFormat, ActionView::MissingTemplate do + request.format = :html # required, despite format.any + respond_to do |format| + format.any { render "about/404", status: :not_found, content_type: "text/html" } + end + end + end + rescue_from ActionController::UnpermittedParameters do + respond_to do |format| + format.html { render plain: "400 Unpermitted query or form parameter", status: :bad_request } + format.json { render json: {error: "400 Unpermitted query or form parameter"}, status: :bad_request } + end + end + rescue_from ActionController::ParameterMissing do |exception| + respond_to do |format| + format.html { render plain: "400 #{exception.message}", status: :bad_request } + format.json { render json: {error: exception.message.to_s}, status: :bad_request } + end end rescue_from ActionDispatch::Http::MimeNegotiation::InvalidType do - render plain: 'fix the mime type in your HTTP_ACCEPT header', - status: :bad_request, content_type: 'text/plain' + render plain: "fix the mime type in your HTTP_ACCEPT header", + status: :bad_request, content_type: "text/plain" end def agent_is_spider? ua = request.env["HTTP_USER_AGENT"].to_s - (ua == "" || ua.match(/(Google|bing|Slack|Twitter)bot|Slurp|crawler|Feedly|FeedParser|RSS/)) - end - - def authenticate_user - # eagerly evaluate, in case this triggers an IpSpoofAttackError - request.remote_ip - - if Rails.application.read_only? - return true - end - - if session[:u] && - (user = User.find_by(:session_token => session[:u].to_s)) && - user.is_active? - @user = user - end - Rails.logger.info( - " Request #{request.remote_ip} #{request.request_method} #{request.fullpath} user: " + - (@user ? "#{@user.id} #{@user.username}" : "0 nobody") - ) - - true + ua == "" || ua.match(/(Google|bing|Slack|Twitter)bot|Slurp|crawler|Feedly|FeedParser|RSS/) end def check_for_read_only_mode @@ -58,23 +61,36 @@ def check_for_read_only_mode true end + # clear Rails session cookie if not logged in so nginx uses the page cache + # https://ryanfb.xyz/etc/2021/08/29/going_cookie-free_with_rails.html + def clear_lobster_trap + key = Rails.application.config.session_options[:key] # "lobster_trap" + cookies.delete(key) if @user.blank? + # this probably should test session.empty? && controller... + request.session_options[:skip] = @user.blank? && controller_name != "login" + end + def find_user_from_rss_token - if !@user && request[:format] == "rss" && params[:token].to_s.present? - @user = User.where(:rss_token => params[:token].to_s).first + if !@user && params[:format] == "rss" && params[:token].to_s.present? + @user = User.where(rss_token: params[:token].to_s).first end end - def flag_warning - @flag_warning_int ||= time_interval('1m') - return false #if Rails.env.development? # expensive because Rails doesn't cache in dev - # Lobsters-bench: turning this off to avoid porting FlaggedCommenters from MySQL to SQLite - @show_flag_warning ||= ( - @user && !!FlaggedCommenters.new(@flag_warning_int[:param], 1.day).check_list_for(@user) - ) + # https://lobste.rs/s/ukosa1 + def geoblock_uk + return unless Time.current.utc >= Date.new(2025, 3, 16) + return unless Maxmind.uk?(req.ip) + + render body: "I'm very sorry, but the risks of the UK Online Safety Act have required that Lobsters geoblock the UK. Discussion", status: 451, content_type: "text/html" + false + end + + def heinous_inline_partials + do_heinous_inline_partial_replacement end def mini_profiler - if @user && @user.is_admin? + if @user&.is_moderator? Rack::MiniProfiler.authorize_request end end @@ -88,31 +104,26 @@ def prepare_exception_notifier # https://web.archive.org/web/20180108083712/http://umaine.edu/lobsterinstitute/files/2011/12/LobsterColorsWeb.pdf def set_traffic_style - @traffic_intensity = '?' - @traffic_style = 'background-color: #ac130d;' + @traffic_intensity = "?" + @traffic_style = "background-color: #ac130d;" return true if Rails.application.read_only? || - agent_is_spider? || - %w{json rss}.include?(params[:format]) - if (skip = TrafficHelper.novelty_logo) - @traffic_style = skip - return - end + agent_is_spider? || + %w[json rss].include?(params[:format]) + return if (@traffic_novelty = TrafficHelper.novelty_logo) @traffic_intensity = TrafficHelper.cached_current_intensity # map intensity to 80-255 so there's always a little red - hex = sprintf('%02x', (@traffic_intensity * 1.75 + 80).round) + hex = sprintf("%02x", (@traffic_intensity * 1.75 + 80).round) @traffic_style = "background-color: ##{hex}0000;" return true unless @user color = :red [ - # rubocop:disable Layout/LineLength, [2_000_000, :blue, "background-color: #0000#{hex};"], [6, :yellow, "background-color: ##{hex}#{hex}00;"], [3, :calico, "background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAC4jAAAuIwF4pT92AAAABmJLR0QA/wD/AP+gvaeTAAACpElEQVQYGQXBWW8bVRgA0Hu/u814NsdxGsUxztJUzaJSVS1CCCTKE7zxxiP/gH+I+lKKQEVCLUlJ5YTsU8f2eJvxbHfjHLz7sKeU2mhNfvl579vnEPKUEUJxji1YoBaIob4m6+cX8Our/m99TBwmpKGV0hZjz+EO06FHOAKlFNKIcE+p8HYo3rwd/Xk8m+pVEjW4EzIFdjopVVG6Nt1ocpc3ALnIhqMRnF3afz6qd2flcMElAOWu3nm4tr6xMh2cyDpprqwBwdjQ0Uz9fXJ9el0lRTOekVQ13DCKvCXVWO7sdl6+/Gp01cbpv/uHPcqGlUKIr50NZq+Pi7mymrt+GOxvbz9+zKjS5OLi1uV/ZeObAC3un4qgt+c0bL8/v5qJ64WbaocIPC2HzbaDGCOeF0ySJI7vzz9eLuZFpfDq2lZWmd/fx6/e3twkuDIiL3KCysV83D+/xZ/1uhYXjuC6lg0BVk2fHPXcQMWD7L+bvJCettzhEPpgzRIxjbe3u6VMCcXWMEY5E9qisqo1QlRLjDVwxqxSQpBW5CFnSB2PaulyRleCSEtNhDPLltjkdQWYCC+gDVF6pHzU8z8/7IKgVFaVtshSWaQxA2Osz4FiokTQrLRrQCLIXzxr/fT94cFWVFlGmXExNQznnbbzaGcVgb0bJqO8kS5BzmusNAMdYN5mPlsihRh5sL7pRYHXQM+OOj/+8MV3Xx+2mmQ8qQZxkmfKSGXq1Odyt9MShByffKLgcc3JsqrHk3Eyumu6LbkYFHcfsjttSaR5OFP29H755nzw/sq8+yMh/sYKYiRL76dxzOqr9RBsmeisnCWqVlZaMIyxgC5U9eEy7p9awj0ByDiQ7XfgmyfRl0fRwZbb7bLVNmOOXynADDY3Hxzs7+WL5XSY/w/0MGrkMYhXjAAAAABJRU5ErkJggg==) no-repeat center"], [2, :split, "background: linear-gradient(90deg, ##{hex}0000 50%, #0000#{hex} 50%)"], - [2, :albino, "filter: invert(100%);"], - # rubocop:enable Layout/LineLength, + [2, :albino, "filter: invert(100%);"] ].each do |cumulative_odds, name, style| break unless rand(cumulative_odds) == 0 color = name @@ -123,55 +134,8 @@ def set_traffic_style end end - def require_logged_in_user - if @user - true - else - if request.get? - session[:redirect_to] = request.original_fullpath - end - - redirect_to "/login" - end - end - - def require_logged_in_moderator - require_logged_in_user - - if @user - if @user.is_moderator? - true - else - flash[:error] = "You are not authorized to access that resource." - return redirect_to "/" - end - end - end - - def require_logged_in_admin - require_logged_in_user - - if @user - if @user.is_admin? - true - else - flash[:error] = "You are not authorized to access that resource." - return redirect_to "/" - end - end - end - - def require_logged_in_user_or_400 - if @user - true - else - render :plain => "not logged in", :status => 400 - return false - end - end - def require_no_user_or_redirect - return redirect_to "/" if @user + redirect_to "/" if @user end def show_title_h1 @@ -180,7 +144,14 @@ def show_title_h1 def tags_filtered_by_cookie @_tags_filtered ||= Tag.where( - :tag => cookies[TAG_FILTER_COOKIE].to_s.split(",") + tag: cookies[TAG_FILTER_COOKIE].to_s.split(",") ) end + + def n_plus_one_detection + # Prosopite.scan + yield + ensure + # Prosopite.finish + end end diff --git a/benchmarks/lobsters/app/controllers/avatars_controller.rb b/benchmarks/lobsters/app/controllers/avatars_controller.rb index 753c2b5c..bc8f16df 100644 --- a/benchmarks/lobsters/app/controllers/avatars_controller.rb +++ b/benchmarks/lobsters/app/controllers/avatars_controller.rb @@ -1,27 +1,27 @@ +# typed: false + class AvatarsController < ApplicationController - before_action :require_logged_in_user, :only => [:expire] + before_action :require_logged_in_user, only: [:expire] ALLOWED_SIZES = [16, 32, 100, 200].freeze - CACHE_DIR = "#{Rails.root}/public/avatars/".freeze + CACHE_DIR = Rails.public_path.join("avatars/").to_s.freeze def expire expired = 0 - Dir.entries(CACHE_DIR).select {|f| + Dir.entries(CACHE_DIR).select { |f| f.match(/\A#{@user.username}-(\d+)\.png\z/) }.each do |f| - begin - Rails.logger.debug "Expiring #{f}" - File.unlink("#{CACHE_DIR}/#{f}") - expired += 1 - rescue => e - Rails.logger.error "Failed expiring #{f}: #{e}" - end + Rails.logger.debug { "Expiring #{f}" } + File.unlink("#{CACHE_DIR}/#{f}") + expired += 1 + rescue => e + Rails.logger.error "Failed expiring #{f}: #{e}" end - flash[:success] = "Your avatar cache has been purged of #{'file'.pluralize(expired)}" - return redirect_to "/settings" + flash[:success] = "Your avatar cache has been purged of #{"file".pluralize(expired)}" + redirect_to "/settings" end def show @@ -36,7 +36,7 @@ def show raise ActionController::RoutingError.new("invalid user name") end - u = User.where(:username => username).first! + u = User.where(username: username).first! if !(av = u.fetched_avatar(size)) raise ActionController::RoutingError.new("failed fetching avatar") @@ -53,6 +53,6 @@ def show File.rename("#{CACHE_DIR}/.#{u.username}-#{size}.png", "#{CACHE_DIR}/#{u.username}-#{size}.png") response.headers["Expires"] = 1.hour.from_now.httpdate - send_data av, :type => "image/png", :disposition => "inline" + send_data av, type: "image/png", disposition: "inline" end end diff --git a/benchmarks/lobsters/app/controllers/categories_controller.rb b/benchmarks/lobsters/app/controllers/categories_controller.rb index 92064e33..811b0ad2 100644 --- a/benchmarks/lobsters/app/controllers/categories_controller.rb +++ b/benchmarks/lobsters/app/controllers/categories_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class CategoriesController < ApplicationController before_action :require_logged_in_admin @@ -7,38 +9,38 @@ def new end def create - category = Category.create(category_params) + category = Category.create!(category_params) if category.valid? flash[:success] = "Category #{category.category} has been created" redirect_to tags_path else - flash[:error] = "New category not created: #{category.errors.full_messages.join(', ')}" + flash[:error] = "New category not created: #{category.errors.full_messages.join(", ")}" redirect_to new_category_path end end def edit - @category = Category.where(:category => params[:category_name]).first! + @category = Category.where(category: params[:category_name]).first! @title = "Edit Category" end def update - category = Category.where(:category => params[:category_name]).first! + category = Category.where(category: params[:category_name]).first! if category.update(category_params) flash[:success] = "Category #{category.category} has been updated" redirect_to tags_path else - flash[:error] = "Category not updated: #{category.errors.full_messages.join(', ')}" + flash[:error] = "Category not updated: #{category.errors.full_messages.join(", ")}" redirect_to edit_category_path end end -private + private def category_params params.require(:category).permit( :category_name, - :category, + :category ).merge(edit_user_id: @user.id) end end diff --git a/benchmarks/lobsters/app/controllers/comments_controller.rb b/benchmarks/lobsters/app/controllers/comments_controller.rb index d63ca85a..49bad8f3 100644 --- a/benchmarks/lobsters/app/controllers/comments_controller.rb +++ b/benchmarks/lobsters/app/controllers/comments_controller.rb @@ -1,190 +1,206 @@ +# typed: false + class CommentsController < ApplicationController COMMENTS_PER_PAGE = 20 caches_page :index, :threads, if: CACHE_PAGE before_action :require_logged_in_user_or_400, - :only => [:create, :preview, :upvote, :flag, :unvote] - before_action :require_logged_in_user, :only => [:upvoted] - before_action :flag_warning, only: [:threads] + only: [:create, :reply, :upvote, :flag, :unvote, :update] + before_action :require_logged_in_user, only: [:upvoted] before_action :show_title_h1 # for rss feeds, load the user's tag filters if a token is passed - before_action :find_user_from_rss_token, :only => [:index] + before_action :find_user_from_rss_token, only: [:index] def create - if !(story = Story.where(:short_id => params[:story_id]).first) || - story.is_gone? - return render :plain => "can't find story", :status => 400 + if !(story = Story.where(short_id: params[:story_id]).first) || + story.is_gone? + return render plain: "can't find story", status: 400 end comment = story.comments.build comment.comment = params[:comment].to_s comment.user = @user - - if params[:hat_id] && @user.wearable_hats.where(:id => params[:hat_id]) - comment.hat_id = params[:hat_id] - end + comment.hat = @user.wearable_hats.find_by(short_id: params[:hat_id]) if params[:parent_comment_short_id].present? - if (pc = Comment.where(:story_id => story.id, :short_id => params[:parent_comment_short_id]) - .first) - comment.parent_comment = pc - else - return render :json => { :error => "invalid parent comment", :status => 400 } + # includes parent story_id to ensure this comment's story_id matches + comment.parent_comment = + Comment.find_by(story_id: story.id, short_id: params[:parent_comment_short_id]) + if !comment.parent_comment + return render json: {error: "invalid parent comment", status: 400} end end # sometimes on slow connections people resubmit; silently accept it if (already = Comment.find_by(user: comment.user, - story: comment.story, - parent_comment_id: comment.parent_comment_id, - comment: comment.comment)) - self.render_created_comment(already) + story: comment.story, + parent_comment_id: comment.parent_comment_id, + comment: comment.comment)) + render_created_comment(already) return end - # rate-limit users to one reply per 5m per parent comment - if params[:preview].blank? && - (pc = Comment.where(:story_id => story.id, - :user_id => @user.id, - :parent_comment_id => comment.parent_comment_id).first) - if (Time.current - pc.created_at) < 5.minutes && !@user.is_moderator? - comment.errors.add(:comment, "^You have already posted a comment " << - "here recently.") - - return render :partial => "commentbox", :layout => false, - :content_type => "text/html", :locals => { :comment => comment } - end + if params[:preview].blank? && comment.breaks_speed_limit? + return render partial: "commentbox", layout: false, + content_type: "text/html", locals: {comment: comment} end - if comment.valid? && params[:preview].blank? && ActiveRecord::Base.transaction { comment.save } - comment.current_vote = { :vote => 1 } - self.render_created_comment(comment) + if comment.valid? && params[:preview].blank? && comment.save + comment.current_vote = {vote: 1} + render_created_comment(comment) else comment.score = 1 - comment.current_vote = { :vote => 1 } + comment.current_vote = {vote: 1} preview comment end end - def render_created_comment(comment) - if request.xhr? - render :partial => "comments/postedreply", :layout => false, - :content_type => "text/html", :locals => { :comment => comment } - else - redirect_to comment.path - end - end - def show - if !((comment = find_comment) && comment.is_editable_by_user?(@user)) - return render :plain => "can't find comment", :status => 400 + if !(comment = find_comment) + return render plain: "can't find comment", status: 404 + end + if !comment.is_editable_by_user?(@user) + return redirect_to comment.path end - render :partial => "comment", - :layout => false, - :content_type => "text/html", - :locals => { - :comment => comment, - :show_tree_lines => params[:show_tree_lines], - } + render partial: "comment", + layout: false, + content_type: "text/html", + locals: { + comment: comment, + show_tree_lines: params[:show_tree_lines] + } end def show_short_id if !(comment = find_comment) - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end - render :json => comment.as_json + render json: comment.as_json end def redirect_from_short_id if (comment = find_comment) - return redirect_to comment.path + redirect_to comment.path else - return render :plain => "can't find comment", :status => 400 + render plain: "can't find comment", status: 400 end end def edit if !((comment = find_comment) && comment.is_editable_by_user?(@user)) - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end - render :partial => "commentbox", :layout => false, - :content_type => "text/html", :locals => { :comment => comment } + render partial: "commentbox", layout: false, + content_type: "text/html", locals: {comment: comment} end def reply if !(parent_comment = find_comment) - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end - comment = Comment.new - comment.story = parent_comment.story + story = parent_comment.story + comment = story.comments.build comment.parent_comment = parent_comment + comment.comment = params[:comment].to_s + comment.user = @user - render :partial => "commentbox", :layout => false, - :content_type => "text/html", :locals => { :comment => comment } + if !parent_comment.depth_permits_reply? + ModNote.tattle_on_max_depth_limit(@user, parent_comment) + if request.xhr? + render partial: "too_deep" + else + render "_too_deep" + end + return + end + + if request.xhr? + render partial: "commentbox", locals: {comment: comment, story: story} + else + parents = comment.parents.with_thread_attributes.for_presentation + + parent_ids = parents.map(&:id) + @votes = Vote.comment_votes_by_user_for_comment_ids_hash(@user&.id, parent_ids) + summaries = Vote.comment_vote_summaries(parent_ids) + parents.each do |c| + c.current_vote = @votes[c.id] + c.vote_summary = summaries[c.id] + end + render "_commentbox", locals: { + comment: comment, + story: story, + parents: parents + } + end end def delete if !((comment = find_comment) && comment.is_deletable_by_user?(@user)) - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end comment.delete_for_user(@user, params[:reason]) - render :partial => "comment", :layout => false, - :content_type => "text/html", :locals => { :comment => comment } + render partial: "comment", layout: false, + content_type: "text/html", locals: {comment: comment} end def undelete if !((comment = find_comment) && comment.is_undeletable_by_user?(@user)) - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end comment.undelete_for_user(@user) - render :partial => "comment", :layout => false, - :content_type => "text/html", :locals => { :comment => comment } + render partial: "comment", layout: false, + content_type: "text/html", locals: {comment: comment} end def disown if !((comment = find_comment) && comment.is_disownable_by_user?(@user)) - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end InactiveUser.disown! comment - comment = find_comment - render :partial => "comment", :layout => false, - :content_type => "text/html", :locals => { :comment => comment } + if request.xhr? + comment = find_comment + show_story = ActiveModel::Type::Boolean.new.cast(params[:show_story]) + show_tree_lines = ActiveModel::Type::Boolean.new.cast(params[:show_tree_lines]) + + render partial: "comment", locals: {comment: comment, show_story: show_story, show_tree_lines: show_tree_lines} + else + redirect_back fallback_location: root_path + end end def update if !((comment = find_comment) && comment.is_editable_by_user?(@user)) - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end comment.comment = params[:comment] comment.hat_id = nil - if params[:hat_id] && @user.wearable_hats.where(:id => params[:hat_id]) - comment.hat_id = params[:hat_id] - end + comment.hat = @user.wearable_hats.find_by(short_id: params[:hat_id]) if params[:preview].blank? && comment.save votes = Vote.comment_votes_by_user_for_comment_ids_hash(@user.id, [comment.id]) comment.current_vote = votes[comment.id] + comment.vote_summary = Vote.comment_vote_summaries([comment.id])[comment.id] - render :partial => "comments/comment", - :layout => false, - :content_type => "text/html", - :locals => { :comment => comment, :show_tree_lines => params[:show_tree_lines] } + render partial: "comments/comment", + layout: false, + content_type: "text/html", + locals: {comment: comment, show_tree_lines: params[:show_tree_lines]} else - comment.current_vote = { :vote => 1 } + comment.current_vote = {vote: 1} preview comment end @@ -192,52 +208,52 @@ def update def unvote if !(comment = find_comment) || comment.is_gone? - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end Vote.vote_thusly_on_story_or_comment_for_user_because( 0, comment.story_id, comment.id, @user.id, nil ) - render :plain => "ok" + render plain: "ok" end def upvote if !(comment = find_comment) || comment.is_gone? - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end Vote.vote_thusly_on_story_or_comment_for_user_because( 1, comment.story_id, comment.id, @user.id, params[:reason] ) - render :plain => "ok" + render plain: "ok" end def flag if !(comment = find_comment) || comment.is_gone? - return render :plain => "can't find comment", :status => 400 + return render plain: "can't find comment", status: 400 end if !Vote::COMMENT_REASONS[params[:reason]] - return render :plain => "invalid reason", :status => 400 + return render plain: "invalid reason", status: 400 end if !@user.can_flag?(comment) - return render :plain => "not permitted to flag", :status => 400 + return render plain: "not permitted to flag", status: 400 end Vote.vote_thusly_on_story_or_comment_for_user_because( -1, comment.story_id, comment.id, @user.id, params[:reason] ) - render :plain => "ok" + render plain: "ok" end def index @rss_link ||= { - :title => "RSS 2.0 - Newest Comments", - :href => "/comments.rss" + (@user ? "?token=#{@user.rss_token}" : ""), + title: "RSS 2.0 - Newest Comments", + href: "/comments.rss" + (@user ? "?token=#{@user.rss_token}" : "") } @title = "Newest Comments" @@ -245,70 +261,75 @@ def index @page = params[:page].to_i if @page == 0 @page = 1 - elsif @page < 0 || @page > (2 ** 32) + elsif @page < 0 || @page > (2**32) raise ActionController::RoutingError.new("page out of bounds") end @comments = Comment.accessible_to_user(@user) .not_on_story_hidden_by(@user) .order("id DESC") - .includes(:user, :hat, :story => :user) - .joins(:story).where.not(stories: { is_deleted: true }) + .includes(:user, :hat, story: :user) + .joins(:story).where.not(stories: {is_deleted: true}) .limit(COMMENTS_PER_PAGE) .offset((@page - 1) * COMMENTS_PER_PAGE) - if @user - @votes = Vote.comment_votes_by_user_for_comment_ids_hash(@user.id, @comments.map(&:id)) - - @comments.each do |c| - if @votes[c.id] - c.current_vote = @votes[c.id] - end - end + comment_ids = @comments.map(&:id) + @votes = Vote.comment_votes_by_user_for_comment_ids_hash(@user&.id, comment_ids) + summaries = Vote.comment_vote_summaries(comment_ids) + @comments.each do |c| + c.current_vote = @votes[c.id] + c.vote_summary = summaries[c.id] end respond_to do |format| - format.html { render :action => "index" } + format.html { render action: "index" } format.rss { if @user && params[:token].present? @title = "Private comments feed for #{@user.username}" + render action: "index", layout: false + else + content = Rails.cache.fetch("comments.rss", expires_in: (60 * 2)) { + render_to_string action: "index", layout: false + } + render plain: content, layout: false end - - render :action => "index", :layout => false } end end def upvoted @rss_link ||= { - :title => "RSS 2.0 - Newest Comments", - :href => upvoted_comments_path(format: :rss) + (@user ? "?token=#{@user.rss_token}" : ""), + title: "RSS 2.0 - Newest Comments", + href: upvoted_comments_path(format: :rss) + (@user ? "?token=#{@user.rss_token}" : "") } @title = "Upvoted Comments" - @above = 'saved/subnav' + @above = "saved/subnav" @page = params[:page].to_i if @page == 0 @page = 1 - elsif @page < 0 || @page > (2 ** 32) + elsif @page < 0 || @page > (2**32) raise ActionController::RoutingError.new("page out of bounds") end @comments = Comment.accessible_to_user(@user) .where.not(user_id: @user.id) .order("id DESC") - .includes(:user, :hat, :story => :user) - .joins(:votes).where(votes: { user_id: @user.id, vote: 1 }) - .joins(:story).where.not(stories: { is_deleted: true }) + .includes(:user, :hat, story: :user) + .joins(:votes).where(votes: {user_id: @user.id, vote: 1}) + .joins(:story).where.not(stories: {is_deleted: true}) .limit(COMMENTS_PER_PAGE) .offset((@page - 1) * COMMENTS_PER_PAGE) # TODO: respect hidden stories - @votes = Vote.comment_votes_by_user_for_comment_ids_hash(@user.id, @comments.map(&:id)) + comment_ids = @comments.map(&:id) + @votes = Vote.comment_votes_by_user_for_comment_ids_hash(@user.id, comment_ids) + summaries = Vote.comment_vote_summaries(comment_ids) @comments.each do |c| c.current_vote = @votes[c.id] + c.vote_summary = summaries[c.id] end respond_to do |format| @@ -318,12 +339,12 @@ def upvoted @title = "Upvoted comments feed for #{@user.username}" end - render :action => "index", :layout => false + render action: "index", layout: false } end end - def threads + def user_threads if params[:user] @showing_user = User.find_by!(username: params[:user]) @title = "Threads for #{@showing_user.username}" @@ -334,55 +355,60 @@ def threads @title = "Your Threads" end - thread_ids = @showing_user.recent_threads( - 20, - include_submitted_stories: !!(@user && @user.id == @showing_user.id), - for_user: @user - ) - - comments = Comment.accessible_to_user(@user) - .where(:thread_id => thread_ids) - .includes(:user, :hat, :story => :user, :votes => :user) - .joins(:story).where.not(stories: { is_deleted: true }) - .arrange_for_user(@user) - - comments_by_thread_id = comments.group_by(&:thread_id) - @threads = comments_by_thread_id.values_at(*thread_ids).compact + @threads = Comment.recent_threads(@showing_user) + .accessible_to_user(@user) + .merge(Story.not_deleted(@user)) + .for_presentation + .joins(:story) if @user - @votes = Vote.comment_votes_by_user_for_story_hash(@user.id, comments.map(&:story_id).uniq) + @user.clear_unread_replies! + @votes = Vote.comment_votes_by_user_for_story_hash(@user.id, @threads.map(&:story_id).uniq) + summaries = Vote.comment_vote_summaries(@threads.map(&:id)) - comments.each do |c| - if @votes[c.id] - c.current_vote = @votes[c.id] - end + @threads.each do |c| + c.current_vote = @votes[c.id] + c.vote_summary = summaries[c.id] end end end -private + private + + def find_comment + comment = Comment.where(short_id: params[:id]).first + # convenience to use PK (from external queries) without generally permitting enumeration: + comment ||= Comment.find(params[:id]) if @user&.is_admin? + + if @user && comment + comment.current_vote = Vote.where(user_id: @user.id, + story_id: comment.story_id, comment_id: comment.id).first + comment.vote_summary = Vote.comment_vote_summaries([comment.id])[comment.id] + end + + comment + end def preview(comment) comment.previewing = true comment.is_deleted = false # show normal preview for deleted comments - render :partial => "comments/commentbox", - :layout => false, - :content_type => "text/html", - :locals => { - :comment => comment, - :show_comment => comment, - :show_tree_lines => params[:show_tree_lines], - } + render partial: "comments/commentbox", + layout: false, + content_type: "text/html", + locals: { + comment: comment, + show_comment: comment, + show_tree_lines: params[:show_tree_lines] + } end - def find_comment - comment = Comment.where(short_id: params[:id]).first - if @user && comment - comment.current_vote = Vote.where(:user_id => @user.id, - :story_id => comment.story_id, :comment_id => comment.id).first + def render_created_comment(comment) + if request.xhr? + render partial: "comments/postedreply", layout: false, + content_type: "text/html", locals: {comment: comment} + else + redirect_to comment.path end - - comment end end diff --git a/benchmarks/lobsters/app/controllers/concerns/authenticatable.rb b/benchmarks/lobsters/app/controllers/concerns/authenticatable.rb new file mode 100644 index 00000000..bd06814f --- /dev/null +++ b/benchmarks/lobsters/app/controllers/concerns/authenticatable.rb @@ -0,0 +1,71 @@ +module Authenticatable + extend ActiveSupport::Concern + + included do + before_action :authenticate_user + end + + def authenticate_user + # eagerly evaluate, in case this triggers an IpSpoofAttackError + request.remote_ip + + if Rails.application.read_only? + return true + end + + if session[:u] && + (user = User.find_by(session_token: session[:u].to_s)) && + user.is_active? + @user = user + end + + true + end + + def require_logged_in_moderator + require_logged_in_user + + if @user + if @user.is_moderator? + true + else + flash[:error] = "You are not authorized to access that resource." + redirect_to "/" + end + end + end + + def require_logged_in_user + if @user + true + else + if request.get? + session[:redirect_to] = request.original_fullpath + end + + redirect_to "/login" + end + end + + def require_logged_in_admin + require_logged_in_user + + if @user + if @user.is_admin? + true + else + flash[:error] = "You are not authorized to access that resource." + redirect_to "/" + end + end + end + + def require_logged_in_user_or_400 + if @user + true + else + render plain: "not logged in", status: 400 + false + end + end +end diff --git a/benchmarks/lobsters/app/controllers/concerns/story_finder.rb b/benchmarks/lobsters/app/controllers/concerns/story_finder.rb new file mode 100644 index 00000000..c50d00a5 --- /dev/null +++ b/benchmarks/lobsters/app/controllers/concerns/story_finder.rb @@ -0,0 +1,26 @@ +module StoryFinder + extend ActiveSupport::Concern + + def find_story + story = Story.find_by(short_id: params[:story_id] || params[:id]) + # convenience to use PK (from external queries) without generally permitting enumeration: + story ||= Story.find(params[:id]) if @user&.is_admin? + + if @user && story + story.current_vote = Vote.find_by( + user: @user, + story: story.id, + comment: nil + ).try(:vote) + end + + story + end + + def find_story! + @story = find_story + if !@story + raise ActiveRecord::RecordNotFound + end + end +end diff --git a/benchmarks/lobsters/app/controllers/csp_controller.rb b/benchmarks/lobsters/app/controllers/csp_controller.rb index 2914a7ca..f7bdbc10 100644 --- a/benchmarks/lobsters/app/controllers/csp_controller.rb +++ b/benchmarks/lobsters/app/controllers/csp_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class CspController < ApplicationController skip_before_action :verify_authenticity_token skip_before_action :authenticate_user diff --git a/benchmarks/lobsters/app/controllers/domains_ban_controller.rb b/benchmarks/lobsters/app/controllers/domains_ban_controller.rb new file mode 100644 index 00000000..22c86c52 --- /dev/null +++ b/benchmarks/lobsters/app/controllers/domains_ban_controller.rb @@ -0,0 +1,28 @@ +# typed: false + +class DomainsBanController < DomainsController + before_action :require_logged_in_moderator + before_action :find_or_initialize_domain + + def create_and_ban + @domain = Domain.create!(domain: params[:id]) + @domain.ban_by_user_for_reason!(@user, domain_params[:banned_reason]) + flash[:success] = "Domain created and banned. Real short run." + redirect_to domain_path(@domain) + end + + def update + if domain_params[:banned_reason].present? + if @domain.banned? + @domain.unban_by_user_for_reason!(@user, domain_params[:banned_reason]) + else + @domain.ban_by_user_for_reason!(@user, domain_params[:banned_reason]) + end + flash[:success] = "Domain updated." + redirect_to domain_path(@domain) + else + flash.now[:error] = "Reason required for the modlog." + render :edit + end + end +end diff --git a/benchmarks/lobsters/app/controllers/domains_controller.rb b/benchmarks/lobsters/app/controllers/domains_controller.rb index e727570d..56b27583 100644 --- a/benchmarks/lobsters/app/controllers/domains_controller.rb +++ b/benchmarks/lobsters/app/controllers/domains_controller.rb @@ -1,43 +1,58 @@ +# typed: false + class DomainsController < ApplicationController - before_action :require_logged_in_admin - before_action :find_domain, only: [:edit, :update] + before_action :require_logged_in_moderator, only: [:edit, :update] + before_action :find_or_initialize_domain, only: [:edit, :update] - def edit; end + def create + @domain = Domain.new(domain: params[:new_domain]) + @domain.selector = domain_params[:selector] + @domain.replacement = domain_params[:replacement] + + if @domain.save + flash[:success] = "Domain created" + redirect_to domain_path(@domain) + else + render :edit + end + end + + def edit + end def update - if domain_params[:banned_reason].present? - if @domain.banned? - @domain.unban_by_user_for_reason!(@user, domain_params[:banned_reason]) - else - @domain.ban_by_user_for_reason!(@user, domain_params[:banned_reason]) - end - flash[:success] = "Domain updated." + @domain.assign_attributes(domain_params) + if @domain.save + flash[:success] = "Domain edited" redirect_to domain_path(@domain) else - flash.now[:error] = "Reason required for the modlog." render :edit end end -private + private def domain_params - params.require(:domain).permit(:banned_reason) + params.require(:domain).permit(:banned_reason, :selector, :replacement) end - def find_domain - @domain = Domain.find_by(domain: params[:id]) + def find_or_initialize_domain + @domain = Domain.find_or_initialize_by(domain: params[:id]) end def path_of_form(domain) - prms = { name: domain.domain } + prms = {name: domain.domain} domain.banned_at ? unban_domain_path(prms) : update_domain_path(prms) end helper_method :path_of_form def caption_of_button(domain) - domain.banned_at ? 'Unban' : 'Ban' + if domain.new_record? + "Create and Ban" + else + domain.banned_at ? "Unban" : "Ban" + end end helper_method :caption_of_button diff --git a/benchmarks/lobsters/app/controllers/filters_controller.rb b/benchmarks/lobsters/app/controllers/filters_controller.rb index 50293a71..820a5497 100644 --- a/benchmarks/lobsters/app/controllers/filters_controller.rb +++ b/benchmarks/lobsters/app/controllers/filters_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class FiltersController < ApplicationController before_action :authenticate_user before_action :show_title_h1 @@ -6,25 +8,25 @@ def index @title = "Filtered Tags" @categories = Category.all - .order('category asc, tags.tag asc') - .eager_load(:tags) - .references(:tags) - .where('tags.active = true') + .order("category asc, tags.tag asc") + .eager_load(:tags) + .references(:tags) + .where("tags.active = true") # perf: three queries is much faster than joining, grouping on tags.id for counts @story_counts = Tagging.group(:tag_id).count @filter_counts = TagFilter.group(:tag_id).count - if @user - @filtered_tags = @user.tag_filter_tags.index_by(&:id) + @filtered_tags = if @user + @user.tag_filter_tags.index_by(&:id) else - @filtered_tags = tags_filtered_by_cookie.index_by(&:id) + tags_filtered_by_cookie.index_by(&:id) end end def update - new_tags = Tag.active.where(:tag => (params[:tags] || {}).keys).to_a - new_tags.keep_if {|t| t.user_can_filter? @user } + new_tags = Tag.active.where(tag: (params[:tags] || {}).keys).to_a + new_tags.keep_if { |t| t.user_can_filter? @user } if @user @user.tag_filter_tags = new_tags diff --git a/benchmarks/lobsters/app/controllers/hat_requests_controller.rb b/benchmarks/lobsters/app/controllers/hat_requests_controller.rb new file mode 100644 index 00000000..ae173911 --- /dev/null +++ b/benchmarks/lobsters/app/controllers/hat_requests_controller.rb @@ -0,0 +1,53 @@ +# typed: false + +class HatRequestsController < ApplicationController + before_action :require_logged_in_user + before_action :require_logged_in_moderator, only: [:approve, :reject] + before_action :show_title_h1 + + def new + @title = "Request a Hat" + @hat_request = HatRequest.new + render :new + end + + def create + @hat_request = HatRequest.new + @hat_request.user_id = @user.id + @hat_request.hat = params[:hat_request][:hat] + @hat_request.link = params[:hat_request][:link] + @hat_request.comment = params[:hat_request][:comment] + + if @hat_request.save + flash[:success] = "Successfully submitted hat request." + return redirect_to "/hats" + end + + render action: :new + end + + def index + @title = "Hat Requests" + @hat_requests = HatRequest.all.includes(:user) + end + + def approve + @hat_request = HatRequest.find(params[:id]) + @hat_request.update!(params.require(:hat_request) + .permit(:hat, :link, :reason).except(:reason)) + @hat_request.approve_by_user_for_reason!(@user, params[:hat_request][:reason]) + + flash[:success] = "Successfully approved hat request." + + redirect_to hat_requests_path + end + + def reject + @hat_request = HatRequest.find(params[:id]) + @hat_request.reject_by_user_for_reason!(@user, params[:hat_request][:reason]) + + flash[:success] = "Successfully rejected hat request." + + redirect_to hat_requests_path + end +end diff --git a/benchmarks/lobsters/app/controllers/hats_controller.rb b/benchmarks/lobsters/app/controllers/hats_controller.rb index 0ddc7143..960fd220 100644 --- a/benchmarks/lobsters/app/controllers/hats_controller.rb +++ b/benchmarks/lobsters/app/controllers/hats_controller.rb @@ -1,63 +1,90 @@ +# typed: false + class HatsController < ApplicationController - before_action :require_logged_in_user, :except => [:index] - before_action :require_logged_in_moderator, :except => [:build_request, :index, :create_request] + before_action :require_logged_in_user, except: [:index] before_action :show_title_h1 - - def build_request - @title = "Request a Hat" - - @hat_request = HatRequest.new - end + before_action :find_hat!, only: [:doff, :doff_by_user, :edit, :update_in_place, :update_by_recreating] + before_action :only_hat_user_or_moderator, only: [:edit, :update_in_place, :update_by_recreating, :doff, :doff_by_user] def index @title = "Hats" @hat_groups = {} - Hat.active.includes(:user).each do |h| + Hat.active.includes(:user).find_each do |h| @hat_groups[h.hat] ||= [] @hat_groups[h.hat].push h end end - def create_request - @hat_request = HatRequest.new - @hat_request.user_id = @user.id - @hat_request.hat = params[:hat_request][:hat] - @hat_request.link = params[:hat_request][:link] - @hat_request.comment = params[:hat_request][:comment] + def doff + @title = "Doffing a Hat" + end - if @hat_request.save - flash[:success] = "Successfully submitted hat request." - return redirect_to "/hats" + def doff_by_user + if params[:reason].blank? + flash[:error] = "You must give a reason for the doffing." + return redirect_to doff_hat_path(@hat) end - render :action => "build_request" + @hat.doff_by_user_with_reason(@user, params[:reason]) + redirect_to @user end - def requests_index - @title = "Hat Requests" + def edit + @title = "Edit a Hat" + end - @hat_requests = HatRequest.all.includes(:user) + def update_in_place + old_hat = @hat.hat + new_hat = params[:hat][:hat] + + if @hat.update(hat: new_hat) + m = Moderation.new + m.user_id = @hat.user_id + m.moderator_user_id = @hat.user_id + m.action = "Renamed hat \"#{old_hat}\" to \"#{new_hat}\"" + m.save! + + redirect_to hats_url + else + flash[:error] = @hat.errors.full_messages.join(", ") + redirect_to edit_hat_path(@hat) + end end - def approve_request - @hat_request = HatRequest.find(params[:id]) - @hat_request.update!(params.require(:hat_request) - .permit(:hat, :link, :reason).except(:reason)) - @hat_request.approve_by_user_for_reason!(@user, params[:hat_request][:reason]) + def update_by_recreating + new_hat = params[:hat][:hat] + + replaced_hat = @hat.dup + replaced_hat.hat = new_hat + replaced_hat.doffed_at = nil - flash[:success] = "Successfully approved hat request." + if replaced_hat.save + @hat.doff_by_user_with_reason(@user, "To replace with \"#{new_hat}\"") - return redirect_to "/hats/requests" + redirect_to hats_url + else + flash[:error] = replaced_hat.errors.full_messages.join(", ") + redirect_to edit_hat_path(@hat) + end end - def reject_request - @hat_request = HatRequest.find(params[:id]) - @hat_request.reject_by_user_for_reason!(@user, params[:hat_request][:reason]) + private - flash[:success] = "Successfully rejected hat request." + def only_hat_user_or_moderator + if @hat.user == @user || @user&.is_moderator? + true + else + redirect_to @user + end + end - return redirect_to "/hats/requests" + def find_hat! + @hat = if @user.is_moderator? + Hat.find_by(short_id: params[:id]) + else + @user.wearable_hats.find_by(short_id: params[:id]) + end end end diff --git a/benchmarks/lobsters/app/controllers/home_controller.rb b/benchmarks/lobsters/app/controllers/home_controller.rb index aedf7fd2..3f538582 100644 --- a/benchmarks/lobsters/app/controllers/home_controller.rb +++ b/benchmarks/lobsters/app/controllers/home_controller.rb @@ -1,12 +1,14 @@ +# typed: false + class HomeController < ApplicationController include IntervalHelper - caches_page :index, :newest, :newest_by_user, :recent, :top, if: CACHE_PAGE + caches_page :active, :index, :newest, :newest_by_user, :recent, :top, if: CACHE_PAGE # for rss feeds, load the user's tag filters if a token is passed - before_action :find_user_from_rss_token, :only => [:index, :newest, :saved, :upvoted] + before_action :find_user_from_rss_token, only: [:index, :newest, :saved, :upvoted] before_action { @page = page } - before_action :require_logged_in_user, :only => [:hidden, :saved, :upvoted] + before_action :require_logged_in_user, only: [:hidden, :saved, :upvoted] before_action :show_title_h1, only: [:top] def active @@ -14,8 +16,8 @@ def active paginate stories.active } - @title = 'Active Discussions' - @above = 'active' + @title = "Active Discussions" + @above = {partial: "active"} respond_to do |format| format.html { render action: :index } @@ -29,9 +31,9 @@ def hidden } @title = "Hidden Stories" - @above = 'saved/subnav' + @above = {partial: "saved/subnav"} - render :action => "index" + render action: "index" end def index @@ -40,31 +42,31 @@ def index } @rss_link ||= { - :title => "RSS 2.0", - :href => user_token_link("/rss"), + title: "RSS 2.0", + href: user_token_link("/rss") } @comments_rss_link ||= { - :title => "Comments - RSS 2.0", - :href => user_token_link("/comments.rss"), + title: "Comments - RSS 2.0", + href: user_token_link("/comments.rss") } @title = "" @root_path = true respond_to do |format| - format.html { render :action => "index" } + format.html { render action: "index" } format.rss { if @user @title = "Private feed for #{@user.username}" - render :action => "rss", :layout => false + render action: "stories", layout: false else - content = Rails.cache.fetch("rss", :expires_in => (60 * 2)) { - render_to_string :action => "rss", :layout => false + content = Rails.cache.fetch("rss", expires_in: (60 * 2)) { + render_to_string action: "stories", layout: false } - render :plain => content, :layout => false + render plain: content, layout: false end } - format.json { render :json => @stories } + format.json { render json: @stories } end end @@ -74,47 +76,42 @@ def newest } @title = "Newest Stories" - @above = 'stories/subnav' + @above = {partial: "stories/subnav"} @rss_link = { - :title => "RSS 2.0 - Newest Items", - :href => user_token_link("/newest.rss"), + title: "RSS 2.0 - Newest Items", + href: user_token_link("/newest.rss") } respond_to do |format| - format.html { render :action => "index" } + format.html { render action: "index" } format.rss { if @user && params[:token].present? @title += " - Private feed for #{@user.username}" end - render :action => "rss", :layout => false + render action: "stories", layout: false } - format.json { render :json => @stories } + format.json { render json: @stories } end + + @user&.touch(:last_read_newest) end def newest_by_user by_user = User.find_by!(username: params[:user]) - @stories, @show_more = get_from_cache(by_user: by_user) { - if @user && @user.is_moderator? - paginate stories.newest_including_deleted_by_user(by_user) - else - paginate stories.newest_by_user(by_user) - end - } + @stories, @show_more = paginate stories.newest_by_user(by_user) @title = "Newest Stories by #{by_user.username}" - @newest_by_user = by_user - @above = 'newest_by_user' + @above = {partial: "newest_by_user", locals: {newest_by_user: by_user}} respond_to do |format| - format.html { render :action => "index" } + format.html { render action: "index" } format.rss { - render :action => "rss", :layout => false + render action: "stories", layout: false } - format.json { render :json => @stories } + format.json { render json: @stories } end end @@ -124,13 +121,13 @@ def recent } @title = "Recent Stories" - @above = 'stories/subnav' - @below = 'recent' + @above = {partial: "stories/subnav"} + @below = {partial: "recent"} # our list is unstable because upvoted stories get removed, so point at /newest.rss - @rss_link = { :title => "RSS 2.0 - Newest Items", :href => user_token_link("/newest.rss") } + @rss_link = {title: "RSS 2.0 - Newest Items", href: user_token_link("/newest.rss")} - render :action => "index" + render action: "index" end def saved @@ -139,47 +136,47 @@ def saved } @rss_link ||= { - :title => "RSS 2.0", - :href => user_token_link("/saved.rss"), + title: "RSS 2.0", + href: user_token_link("/saved.rss") } @title = "Saved Stories" - @above = 'saved/subnav' + @above = {partial: "saved/subnav"} respond_to do |format| - format.html { render :action => "index" } + format.html { render action: "index" } format.rss { if @user @title = "Private feed of saved stories for #{@user.username}" end - render :action => "rss", :layout => false + render action: "stories", layout: false } - format.json { render :json => @stories } + format.json { render json: @stories } end end def category - category_params = params[:category].split(',') - @categories = Category.where(category: category_params) + category_params = params[:category].split(",") + categories = Category.where(category: category_params) - raise ActiveRecord::RecordNotFound unless @categories.length == category_params.length + raise ActiveRecord::RecordNotFound unless categories.length == category_params.length - @stories, @show_more = get_from_cache(categories: category_params.sort.join(',')) do - paginate stories.categories(@categories) + @stories, @show_more = get_from_cache(categories: category_params.sort.join(",")) do + paginate stories.categories(categories) end - @title = @categories.map(&:category).join(' ') - @above = 'category' + @title = categories.map(&:category).join(" ") + @above = {partial: "category", locals: {categories: categories}} @rss_link = { title: "RSS 2.0 - Categorized #{@title}", - href: category_url(params[:category], format: 'rss'), + href: category_url(params[:category], format: "rss") } respond_to do |format| - format.html { render :action => "index" } - format.rss { render :action => "rss", :layout => false } - format.json { render :json => @stories } + format.html { render action: "index" } + format.rss { render action: "stories", layout: false } + format.json { render json: @stories } end end @@ -190,48 +187,48 @@ def single_tag paginate stories.tagged([@tag]) end - @title = [@tag.tag, @tag.description].compact.join(' - ') - @above = 'single_tag' @related = Rails.cache.fetch("related_#{@tag.tag}", expires_in: 1.day) { Tag.related(@tag) } - @below = 'tags/multi_tag_tip' + @title = [@tag.tag, @tag.description].compact.join(" - ") + @above = {partial: "single_tag", locals: {tag: @tag, related: @related}} + @below = {partial: "tags/multi_tag_tip"} @rss_link = { title: "RSS 2.0 - Tagged #{@tag.tag} (#{@tag.description})", - href: "/t/#{@tag.tag}.rss", + href: "/t/#{@tag.tag}.rss" } respond_to do |format| - format.html { render :action => "index" } - format.rss { render :action => "rss", :layout => false } - format.json { render :json => @stories } + format.html { render action: "index" } + format.rss { render action: "stories", layout: false } + format.json { render json: @stories } end end def multi_tag - tag_params = params[:tag].split(',') + tag_params = params[:tag].split(",") @tags = Tag.where(tag: tag_params) raise ActiveRecord::RecordNotFound unless @tags.length == tag_params.length - @stories, @show_more = get_from_cache(tags: tag_params.sort.join(',')) do + @stories, @show_more = get_from_cache(tags: tag_params.sort.join(",")) do paginate stories.tagged(@tags) end @title = @tags.map do |tag| - [tag.tag, tag.description].compact.join(' - ') - end.join(' ') - @above = 'multi_tag' + [tag.tag, tag.description].compact.join(" - ") + end.join(" ") + @above = {partial: "multi_tag", locals: {tags: @tags}} @rss_link = { title: "RSS 2.0 - Tagged #{tags_with_description_for_rss(@tags)}", - href: "/t/#{params[:tag]}.rss", + href: "/t/#{params[:tag]}.rss" } respond_to do |format| - format.html { render :action => "index" } - format.rss { render :action => "rss", :layout => false } - format.json { render :json => @stories } + format.html { render action: "index" } + format.rss { render action: "stories", layout: false } + format.json { render json: @stories } end end @@ -239,21 +236,43 @@ def for_domain @domain = Domain.find_by!(domain: params[:id]) @stories, @show_more = get_from_cache(domain: @domain.domain) do - paginate @domain.stories.base(@user).order('id desc') + paginate @domain.stories.base(@user).order("id desc") end @title = @domain.domain - @above = 'for_domain' + @above = {partial: "for_domain", locals: {domain: @domain, stories: @stories}} @rss_link = { title: "RSS 2.0 - For #{@domain.domain}", - href: "/domain/#{@domain.domain}.rss", + href: "/domains/#{@domain.domain}.rss" } respond_to do |format| - format.html { render :action => "index" } - format.rss { render :action => "rss", :layout => false } - format.json { render :json => @stories } + format.html { render action: "index" } + format.rss { render action: "stories", layout: false } + format.json { render json: @stories } + end + end + + def for_origin + @origin = Origin.find_by!(identifier: params[:identifier]) + + @stories, @show_more = get_from_cache(identifier: @origin.identifier) do + paginate @origin.stories.base(@user).order("id desc") + end + + @title = @origin.identifier + @above = {partial: "for_origin", locals: {origin: @origin, stories: @stories}} + + @rss_link = { + title: "RSS 2.0 - For #{@origin.identifier}", + href: "/origins/#{@origin.identifier}.rss" + } + + respond_to do |format| + format.html { render action: "index" } + format.rss { render action: "stories", layout: false } + format.json { render json: @stories } end end @@ -264,35 +283,35 @@ def top paginate stories.top(length) } - if length[:dur] > 1 - @title = "Top Stories of the Past #{length[:dur]} #{length[:intv]}" + @title = if length[:dur] > 1 + "Top Stories of the Past #{length[:dur]} #{length[:intv]}" else - @title = "Top Stories of the Past #{length[:intv]}" + "Top Stories of the Past #{length[:intv]}" end - @above = 'stories/subnav' + @above = "stories/subnav" @rss_link ||= { - :title => "RSS 2.0 - " + @title, - :href => "/top/rss", + title: "RSS 2.0 - " + @title, + href: "/top/rss" } respond_to do |format| - format.html { render :action => "index" } - format.rss { render :action => "rss", :layout => false } + format.html { render action: "index" } + format.rss { render action: "stories", layout: false } end end def upvoted @stories, @show_more = get_from_cache(upvoted: true, user: @user) { - paginate @user.upvoted_stories.includes(:tags).order('votes.id DESC') + paginate @user.upvoted_stories.includes(:tags).order("votes.id DESC") } @title = "Upvoted Stories" - @above = 'saved/subnav' + @above = "saved/subnav" @rss_link = { - :title => "RSS 2.0 - Upvoted Stories", - :href => user_token_link("/upvoted.rss"), + title: "RSS 2.0 - Upvoted Stories", + href: user_token_link("/upvoted.rss") } respond_to do |format| @@ -302,13 +321,13 @@ def upvoted @title += " - Private feed for #{@user.username}" end - render :action => "rss", :layout => false + render action: "stories", layout: false } - format.json { render :json => @stories } + format.json { render json: @stories } end end -private + private def filtered_tag_ids if @user @@ -326,7 +345,7 @@ def page p = params[:page].to_i if p == 0 p = 1 - elsif p < 0 || p > (2 ** 32) + elsif p < 0 || p > (2**32) raise ActionController::RoutingError.new("page out of bounds") end p @@ -336,13 +355,13 @@ def paginate(scope) StoriesPaginator.new(scope, page, @user).get end - def get_from_cache(opts = {}, &block) + def get_from_cache(opts = {}, &) if Rails.env.development? || @user || tags_filtered_by_cookie.any? yield else - key = opts.merge(page: page).sort.map {|k, v| "#{k}=#{v.to_param}" }.join(" ") + key = opts.merge(page: page).sort.map { |k, v| "#{k}=#{v.to_param}" }.join(" ") begin - Rails.cache.fetch("stories #{key}", :expires_in => 45, &block) + Rails.cache.fetch("stories #{key}", expires_in: 45, &) rescue Errno::ENOENT => e Rails.logger.error "error fetching stories #{key}: #{e}" yield @@ -355,6 +374,6 @@ def user_token_link(url) end def tags_with_description_for_rss(tags) - tags.map {|tag| "#{tag.tag} (#{tag.description})" }.join(' ') + tags.map { |tag| "#{tag.tag} (#{tag.description})" }.join(" ") end end diff --git a/benchmarks/lobsters/app/controllers/inbox_controller.rb b/benchmarks/lobsters/app/controllers/inbox_controller.rb index fe18b85c..8336aad6 100644 --- a/benchmarks/lobsters/app/controllers/inbox_controller.rb +++ b/benchmarks/lobsters/app/controllers/inbox_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class InboxController < ApplicationController before_action :require_logged_in_user diff --git a/benchmarks/lobsters/app/controllers/invitations_controller.rb b/benchmarks/lobsters/app/controllers/invitations_controller.rb index 79f2f1a5..5d6f8e9f 100644 --- a/benchmarks/lobsters/app/controllers/invitations_controller.rb +++ b/benchmarks/lobsters/app/controllers/invitations_controller.rb @@ -1,5 +1,7 @@ +# typed: false + class InvitationsController < ApplicationController - before_action :require_logged_in_user, :except => [:build, :create_by_request, :confirm_email] + before_action :require_logged_in_user, except: [:build, :create_by_request, :confirm_email] before_action :show_title_h1 def build @@ -8,7 +10,7 @@ def build @invitation_request = InvitationRequest.new else flash[:error] = "Public invitation requests are not allowed." - return redirect_to "/login" + redirect_to "/login" end end @@ -19,11 +21,11 @@ def index return redirect_to "/" end - @invitation_requests = InvitationRequest.where(:is_verified => true) + @invitation_requests = InvitationRequest.where(is_verified: true) end def confirm_email - if !(ir = InvitationRequest.where(:code => params[:code].to_s).first) + if !(ir = InvitationRequest.where(code: params[:code].to_s).first) flash[:error] = "Invalid or expired invitation request" return redirect_to "/invitations/request" end @@ -31,9 +33,9 @@ def confirm_email ir.is_verified = true ir.save! - flash[:success] = "Your invitation request has been validated and " << - "will now be shown to other logged-in users." - return redirect_to "/invitations/request" + flash[:success] = "Your invitation request has been validated and " \ + "will now be shown to other logged-in users." + redirect_to "/invitations/request" end def create @@ -45,54 +47,55 @@ def create i = Invitation.new i.user_id = @user.id - i.email = params[:email] + i.email = params[:email].delete_prefix("mailto:").strip i.memo = params[:memo] begin i.save! i.send_email flash[:success] = "Successfully e-mailed invitation to " << - params[:email].to_s << "." + params[:email].to_s << "." rescue => e Rails.logger.error "Error creating invitation for #{params[:email]}: #{e.message}" - flash[:error] = "Could not send invitation, verify the e-mail " << - "address is valid." + flash[:error] = "Could not send invitation, verify the e-mail " \ + "address is valid." end if params[:return_home] - return redirect_to "/" + redirect_to "/" else - return redirect_to "/settings" + redirect_to "/settings" end end def create_by_request if Rails.application.allow_invitation_requests? @invitation_request = InvitationRequest.new( - params.require(:invitation_request).permit(:name, :email, :memo)) + params.require(:invitation_request).permit(:name, :email, :memo) + ) @invitation_request.ip_address = request.remote_ip if @invitation_request.save flash[:success] = "You have been e-mailed a confirmation to " << - params[:invitation_request][:email].to_s << "." - return redirect_to "/invitations/request" + params[:invitation_request][:email].to_s << "." + redirect_to "/invitations/request" else - render :action => :build + render action: :build end else - return redirect_to "/login" + redirect_to "/login" end end def send_for_request if !@user.can_see_invitation_requests? - flash[:error] = "Your account is not permitted to view invitation " << - "requests." + flash[:error] = "Your account is not permitted to view invitation " \ + "requests." return redirect_to "/" end - if !(ir = InvitationRequest.where(:code => params[:code].to_s).first) + if !(ir = InvitationRequest.where(code: params[:code].to_s).first) flash[:error] = "Invalid or expired invitation request" return redirect_to "/invitations" end @@ -104,12 +107,12 @@ def send_for_request i.send_email ir.destroy! flash[:success] = "Successfully e-mailed invitation to " << - ir.name.to_s << "." + ir.name.to_s << "." Rails.logger.info "[u#{@user.id}] sent invitiation for request " << - ir.inspect + ir.inspect - return redirect_to "/invitations" + redirect_to "/invitations" end def delete_request @@ -117,18 +120,18 @@ def delete_request return redirect_to "/invitations" end - if !(ir = InvitationRequest.where(:code => params[:code].to_s).first) + if !(ir = InvitationRequest.where(code: params[:code].to_s).first) flash[:error] = "Invalid or expired invitation request" return redirect_to "/invitations" end ir.destroy! flash[:success] = "Successfully deleted invitation request from " << - ir.name.to_s << "." + ir.name.to_s << "." - Rails.logger.info "[u#{@user.id}] deleted invitation request " << - "from #{ir.inspect}" + Rails.logger.info "[u#{@user.id}] deleted invitation request " \ + "from #{ir.inspect}" - return redirect_to "/invitations" + redirect_to "/invitations" end end diff --git a/benchmarks/lobsters/app/controllers/jobs_mod_controller.rb b/benchmarks/lobsters/app/controllers/jobs_mod_controller.rb new file mode 100644 index 00000000..e068b968 --- /dev/null +++ b/benchmarks/lobsters/app/controllers/jobs_mod_controller.rb @@ -0,0 +1,7 @@ +# rubocop:disable Rails/ApplicationController +class JobsModController < ActionController::Base + include Authenticatable + + before_action :require_logged_in_moderator +end +# rubocop:enable Rails/ApplicationController diff --git a/benchmarks/lobsters/app/controllers/keybase_proofs_controller.rb b/benchmarks/lobsters/app/controllers/keybase_proofs_controller.rb index d9d743b0..f8b05d01 100644 --- a/benchmarks/lobsters/app/controllers/keybase_proofs_controller.rb +++ b/benchmarks/lobsters/app/controllers/keybase_proofs_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class KeybaseProofsController < ApplicationController before_action :require_logged_in_user, only: [:new, :create, :destroy] before_action :check_new_params, only: :new @@ -19,10 +21,10 @@ def create if Keybase.proof_valid?(kb_username, kb_signature, @user.username) @user.add_or_update_keybase_proof(kb_username, kb_signature) @user.save! - return redirect_to Keybase.success_url(kb_username, kb_signature, kb_ua, @user.username) + redirect_to Keybase.success_url(kb_username, kb_signature, kb_ua, @user.username), allow_other_host: true else flash[:error] = "Failed to connect your account to Keybase. Try again from Keybase." - return redirect_to settings_path + redirect_to settings_path end end @@ -37,21 +39,21 @@ def destroy def kbconfig return render json: {} unless Keybase.enabled? - @domain = Keybase.DOMAIN + @domain = Rails.application.credentials.keybase.domain @name = Rails.application.name @brand_color = "#AC130D" @description = "Computing-focused community centered around link aggregation and discussion" - @contacts = ["admin@#{Keybase.DOMAIN}"] + @contacts = ["admin@#{@domain}"] @prefill_url = "#{new_keybase_proof_url}?kb_username=%{kb_username}&" \ "kb_signature=%{sig_hash}&kb_ua=%{kb_ua}&username=%{username}" - @profile_url = "#{u_url}/%{username}" - @check_url = "#{u_url}/%{username}.json" + @profile_url = "/~%{username}" + @check_url = "/~%{username}.json" @logo_black = "https://lobste.rs/small-black-logo.svg" @logo_full = "https://lobste.rs/full-color.logo.svg" @user_re = User.username_regex_s[1...-1] end -private + private def force_to_json request.format = :json @@ -60,7 +62,7 @@ def force_to_json def check_user_matches unless case_insensitive_match?(@user.username, params[:username]) flash[:error] = "not logged in as the correct user" - return redirect_to settings_path + redirect_to settings_path end end diff --git a/benchmarks/lobsters/app/controllers/login_controller.rb b/benchmarks/lobsters/app/controllers/login_controller.rb index 82387fe9..5774db80 100644 --- a/benchmarks/lobsters/app/controllers/login_controller.rb +++ b/benchmarks/lobsters/app/controllers/login_controller.rb @@ -1,14 +1,22 @@ +# typed: false + class LoginBannedError < StandardError; end + class LoginDeletedError < StandardError; end + class LoginTOTPFailedError < StandardError; end + class LoginWipedError < StandardError; end + class LoginFailedError < StandardError; end +class LoginPasswordTooLong < StandardError; end + class LoginController < ApplicationController before_action :authenticate_user - before_action :check_for_read_only_mode, :except => [:index] + before_action :check_for_read_only_mode, except: [:index] before_action :require_no_user_or_redirect, - only: [:index, :login, :forgot_password, :reset_password] + only: [:index, :login, :forgot_password, :reset_password] before_action :show_title_h1 def logout @@ -22,14 +30,14 @@ def logout def index @title = "Login" @referer ||= request.referer - render :action => "index" end def login - if params[:email].to_s.match(/@/) - user = User.where(:email => params[:email]).first + @title = "Login" + user = if /@/.match?(params[:email].to_s) + User.where(email: params[:email]).first else - user = User.where(:username => params[:email]).first + User.where(username: params[:email]).first end fail_reason = nil @@ -43,6 +51,11 @@ def login raise LoginWipedError end + # BCrypt accepts a max of 72 bytes (not characters!), bug #1277 + if params[:password].to_s.bytesize > 72 + raise LoginPasswordTooLong + end + if !user.authenticate(params[:password].to_s) raise LoginFailedError end @@ -55,9 +68,9 @@ def login raise LoginDeletedError end - if !user.password_digest.to_s.match(/^\$2a\$#{BCrypt::Engine::DEFAULT_COST}\$/) + if !user.password_digest.to_s.match(/^\$2a\$#{BCrypt::Engine::DEFAULT_COST}\$/o) user.password = user.password_confirmation = params[:password].to_s - user.save + user.save! end if user.has_2fa? && !Rails.env.development? @@ -82,27 +95,30 @@ def login end return redirect_to "/" + rescue LoginFailedError + fail_reason = "Invalid e-mail address and/or password." rescue LoginWipedError - fail_reason = "Your account was banned or deleted before the site changed admins. " << - "Your email and password hash were wiped for privacy." + fail_reason = "Your account was banned or deleted before the site changed admins. " \ + "Your email and password hash were wiped for privacy." + rescue LoginPasswordTooLong + fail_reason = "BCrypt passwords need to be less than 72 bytes, you'll have to reset to set a shorter one, sorry for the hassle." rescue LoginBannedError fail_reason = "Your account has been banned. Log: #{user.banned_reason}" + ModNote.tattle_on_banned_login(user) rescue LoginDeletedError - fail_reason = "Your account has been deleted." + fail_reason = "You deleted your account." + ModNote.tattle_on_deleted_login(user) rescue LoginTOTPFailedError fail_reason = "Your TOTP code was invalid." - rescue LoginFailedError - fail_reason = "Invalid e-mail address and/or password." end flash.now[:error] = fail_reason @referer = params[:referer] - index + render "index" end def forgot_password @title = "Reset Password" - render :action => "forgot_password" end def reset_password @@ -120,24 +136,24 @@ def reset_password end if @found_user.is_wiped? - flash.now[:error] = "It's not possible to reset your password " << - "because your account was deleted before the site changed admins " << - "and your email address was wiped for privacy." + flash.now[:error] = "It's not possible to reset your password " \ + "because your account was deleted before the site changed admins " \ + "and your email address was wiped for privacy." return forgot_password end @found_user.initiate_password_reset_for_ip(request.remote_ip) flash.now[:success] = "Password reset instructions have been e-mailed to you." - return index + render "index" end def set_new_password @title = "Set New Password" if (m = params[:token].to_s.match(/^(\d+)-/)) && - (Time.current - Time.zone.at(m[1].to_i)) < 24.hours - @reset_user = User.where(:password_reset_token => params[:token].to_s).first + (Time.current - Time.zone.at(m[1].to_i)) < 24.hours + @reset_user = User.where(password_reset_token: params[:token].to_s).first end if @reset_user && !@reset_user.is_banned? @@ -154,30 +170,30 @@ def set_new_password if @reset_user.save && @reset_user.is_active? if @reset_user.has_2fa? flash[:success] = "Your password has been reset." - return redirect_to "/login" + redirect_to "/login" else session[:u] = @reset_user.session_token - return redirect_to "/" + redirect_to "/" end else flash[:error] = "Could not reset password." end end else - flash[:error] = "Invalid reset token. It may have already been " << - "used or you may have copied it incorrectly." - return redirect_to forgot_password_path + flash[:error] = "Invalid reset token. It may have already been " \ + "used or you may have copied it incorrectly." + redirect_to forgot_password_path end end def twofa @title = "Login - Two Factor Authentication" if (tmpu = find_twofa_user) - Rails.logger.info " Authenticated as user #{tmpu.id} " << - "(#{tmpu.username}), verifying TOTP" + Rails.logger.info " Authenticated as user #{tmpu.id} " \ + "(#{tmpu.username}), verifying TOTP" else reset_session - return redirect_to "/login" + redirect_to "/login" end end @@ -186,18 +202,18 @@ def twofa_verify if (tmpu = find_twofa_user) && tmpu.authenticate_totp(params[:totp_code]) session[:u] = tmpu.session_token session.delete(:twofa_u) - return redirect_to "/" + redirect_to "/" else flash[:error] = "Your TOTP code did not match. Please try again." - return redirect_to "/login/2fa" + redirect_to "/login/2fa" end end -private + private def find_twofa_user if session[:twofa_u].present? - return User.where(:session_token => session[:twofa_u]).first + User.where(session_token: session[:twofa_u]).first end end end diff --git a/benchmarks/lobsters/app/controllers/messages_controller.rb b/benchmarks/lobsters/app/controllers/messages_controller.rb index 549fa308..0bba14a2 100644 --- a/benchmarks/lobsters/app/controllers/messages_controller.rb +++ b/benchmarks/lobsters/app/controllers/messages_controller.rb @@ -1,7 +1,9 @@ +# typed: false + class MessagesController < ApplicationController before_action :require_logged_in_user before_action :require_logged_in_moderator, only: [:mod_note] - before_action :find_message, :only => [:show, :destroy, :keep_as_new, :mod_note] + before_action :find_message, only: [:show, :destroy, :keep_as_new, :mod_note] before_action :show_title_h1 def index @@ -21,7 +23,7 @@ def index end } format.json { - render :json => @messages + render json: @messages } end end @@ -38,10 +40,10 @@ def sent @new_message = Message.new - render :action => "index" + render action: "index" } format.json { - render :json => @messages + render json: @messages } end end @@ -51,6 +53,7 @@ def create @new_message = Message.new(message_params) @new_message.author_user_id = @user.id + @new_message.hat = @user.wearable_hats.find_by(short_id: params[:message][:hat_id]) @direction = :out @@ -58,12 +61,11 @@ def create if @user.is_moderator? && @new_message.mod_note ModNote.create_from_message(@new_message, @user) end - flash[:success] = "Your message has been sent to " << - @new_message.recipient.username.to_s << "." - return redirect_to "/messages" + flash[:success] = "Your message has been sent to #{@new_message.recipient.username}." + redirect_to "/messages" else @messages = Message.inbox(@user).load - render :action => "index" + render action: "index" end end @@ -72,19 +74,19 @@ def show if @message.author @new_message = Message.new - @new_message.recipient_username = (@message.author_user_id == @user.id ? + @new_message.recipient_username = ((@message.author_user_id == @user.id) ? @message.recipient.username : @message.author.username) - if @message.subject.match(/^re:/i) - @new_message.subject = @message.subject + @new_message.subject = if /^re:/i.match?(@message.subject) + @message.subject else - @new_message.subject = "Re: #{@message.subject}" + "Re: #{@message.subject}" end end if @message.recipient_user_id == @user.id @message.has_been_read = true - @message.save + @message.save! end Rails.cache.delete("user:#{@user.id}:unread_replies") end @@ -103,9 +105,9 @@ def destroy flash[:success] = "Deleted message." if @message.author_user_id == @user.id - return redirect_to "/messages/sent" + redirect_to "/messages/sent" else - return redirect_to "/messages" + redirect_to "/messages" end end @@ -114,7 +116,7 @@ def batch_delete params.each do |k, v| if (v.to_s == "1") && (m = k.match(/^delete_(.+)$/)) - if (message = Message.where(:short_id => m[1]).first) + if (message = Message.where(short_id: m[1]).first) ok = false if message.author_user_id == @user.id message.deleted_by_author = true @@ -133,27 +135,27 @@ def batch_delete end end - flash[:success] = "Deleted #{deleted} #{'message'.pluralize(deleted)}" + flash[:success] = "Deleted #{deleted} #{"message".pluralize(deleted)}" @user.update_unread_message_count! - return redirect_to "/messages" + redirect_to "/messages" end def keep_as_new @message.has_been_read = false - @message.save + @message.save! - return redirect_to "/messages" + redirect_to "/messages" end def mod_note ModNote.create_from_message(@message, @user) - return redirect_to @message, notice: 'ModNote created' + redirect_to @message, notice: "ModNote created" end -private + private def message_params params.require(:message).permit( @@ -163,7 +165,7 @@ def message_params end def find_message - if (@message = Message.where(:short_id => params[:message_id] || params[:id]).first) + if (@message = Message.where(short_id: params[:message_id] || params[:id]).first) if @message.author_user_id == @user.id || @message.recipient_user_id == @user.id return true end @@ -171,6 +173,6 @@ def find_message flash[:error] = "Could not find message." redirect_to "/messages" - return false + false end end diff --git a/benchmarks/lobsters/app/controllers/mod/moderator_controller.rb b/benchmarks/lobsters/app/controllers/mod/moderator_controller.rb new file mode 100644 index 00000000..39d26a3f --- /dev/null +++ b/benchmarks/lobsters/app/controllers/mod/moderator_controller.rb @@ -0,0 +1,3 @@ +class Mod::ModeratorController < ApplicationController + before_action :require_logged_in_moderator +end diff --git a/benchmarks/lobsters/app/controllers/mod/reparents_controller.rb b/benchmarks/lobsters/app/controllers/mod/reparents_controller.rb new file mode 100644 index 00000000..3883fa6b --- /dev/null +++ b/benchmarks/lobsters/app/controllers/mod/reparents_controller.rb @@ -0,0 +1,35 @@ +class Mod::ReparentsController < Mod::ModeratorController + before_action :require_logged_in_admin + before_action :load_user + + def new + end + + def create + if params[:reason].blank? + return redirect_to new_mod_reparent_path({}, id: @reparent_user), flash: {error: "Reason can't be blank."} + end + + User.transaction do + ModNote.record_reparent!(@reparent_user, @user, params[:reason]) + @reparent_user.invited_by_user = @user + @reparent_user.save! + Moderation.create!({ + moderator: @user, + user: @reparent_user, + action: "Reparented user to be invited by #{@user.username}", + reason: params[:reason] + }) + end + Rails.cache.delete("users_tree_#{User.last.id}") # UsersController#tree + + redirect_to user_path(@reparent_user), flash: {success: "User been has reparented to you."} + end + + private + + def load_user + params.require(:id) + @reparent_user = User.find_by! username: params[:id] + end +end diff --git a/benchmarks/lobsters/app/controllers/mod/stories_controller.rb b/benchmarks/lobsters/app/controllers/mod/stories_controller.rb new file mode 100644 index 00000000..6375bfbd --- /dev/null +++ b/benchmarks/lobsters/app/controllers/mod/stories_controller.rb @@ -0,0 +1,68 @@ +class Mod::StoriesController < Mod::ModeratorController + include StoryFinder + + before_action :find_story! + before_action :show_title_h1 + + def edit + @title = "Edit Story" + + if @story.merged_into_story + @story.merge_story_short_id = @story.merged_into_story.short_id + User.update_counters @story.user_id, karma: (@story.votes.count * -2) + end + end + + def update + @story.is_deleted = false + @story.editor = @user + @story.attributes = story_params + + if @story.save + redirect_to @story.comments_path + else + render action: "edit" + end + end + + def undelete + @story.is_deleted = false + @story.editor = @user + @story.attributes = story_params + + if @story.save + Keystore.increment_value_for("user:#{@story.user.id}:stories_deleted", -1) + end + + redirect_to @story.comments_path + end + + def destroy + @story.attributes = story_params + + if @story.user_id != @user.id && @story.moderation_reason.blank? + @story.errors.add(:moderation_reason, message: "is required") + return render action: "edit" + end + + @story.is_deleted = true + @story.editor = @user + + if @story.save + Keystore.increment_value_for("user:#{@story.user.id}:stories_deleted") + Mastodon.delete_post(@story) + end + + redirect_to @story.comments_path + end + + private + + def story_params + params.require(:story).permit( + :title, :url, :description, :moderation_reason, + :merge_story_short_id, :is_unavailable, :user_is_author, :user_is_following, + tags_a: [] + ) + end +end diff --git a/benchmarks/lobsters/app/controllers/mod_controller.rb b/benchmarks/lobsters/app/controllers/mod_controller.rb index 8fe3a03c..0a1ab253 100644 --- a/benchmarks/lobsters/app/controllers/mod_controller.rb +++ b/benchmarks/lobsters/app/controllers/mod_controller.rb @@ -1,3 +1,5 @@ +# typed: false + # This controller is going to have a lot of one-off queries. If they do need # to be used elsewhere, remember to make them into model scopes. @@ -9,15 +11,16 @@ class ModController < ApplicationController def index @title = "Activity by Other Mods" @moderations = Moderation.all - .eager_load(:moderator, :story, :tag, :user, :comment => [:story, :user]) + .eager_load(:moderator, :story, :tag, :user, comment: [:story, :user]) .where("moderator_user_id != ? or moderator_user_id is null", @user.id) - .where('moderations.created_at >= (NOW() - INTERVAL 1 MONTH)') - .order('moderations.id desc') + # CHANGE: + .where("moderations.created_at >= DATE(('NOW', '-1 month')") + .order("moderations.id desc") end def flagged_stories @title = "Flagged Stories" - @stories = period(Story.includes(:tags).unmerged + @stories = period(Story.base(@user).unmerged .includes(:user, :tags) .where("flags > 1") .order("stories.id DESC")) @@ -26,7 +29,7 @@ def flagged_stories def flagged_comments @title = "Flagged Comments" @comments = period(Comment - .eager_load(:user, :hat, :story => :user, :votes => :user) + .eager_load(:user, :hat, story: :user, votes: :user) .where("comments.flags >= 2") .where("(select count(*) from votes where votes.comment_id = comments.id and @@ -43,10 +46,10 @@ def commenters @commenters = fc.commenters end -private + private def default_periods - @periods = %w{1d 2d 3d 1w 1m} + @periods = %w[1d 2d 3d 1w 1m] end def period(query) diff --git a/benchmarks/lobsters/app/controllers/mod_notes_controller.rb b/benchmarks/lobsters/app/controllers/mod_notes_controller.rb index a73a26b5..01779374 100644 --- a/benchmarks/lobsters/app/controllers/mod_notes_controller.rb +++ b/benchmarks/lobsters/app/controllers/mod_notes_controller.rb @@ -1,10 +1,12 @@ +# typed: false + class ModNotesController < ModController before_action :require_logged_in_moderator def index @title = "Mod Notes" @username = params[:username] - query = ModNote.order('created_at desc').includes(:moderator, :user) + query = ModNote.order("created_at desc").includes(:moderator, :user) if (@username = params[:username]) if (user = User.find_by(username: @username)) @title = "#{@username} Mod Notes" @@ -23,15 +25,15 @@ def create @mod_note = ModNote.new(mod_note_params) @mod_note.moderator = @user if @mod_note.save - redirect_to user_path(@mod_note.user), success: 'Noted' + redirect_to user_path(@mod_note.user), success: "Noted" else # This is bad and needs to change if note ever has non-trivial validation redirect_to user_path(@mod_note.user), - error: "Invalid note and Peter half-assed the error handling" + error: "Invalid note and Peter half-assed the error handling" end end -private + private def mod_note_params params.require(:mod_note).permit(:username, :note) diff --git a/benchmarks/lobsters/app/controllers/moderations_controller.rb b/benchmarks/lobsters/app/controllers/moderations_controller.rb index 3421ee1f..0fc52398 100644 --- a/benchmarks/lobsters/app/controllers/moderations_controller.rb +++ b/benchmarks/lobsters/app/controllers/moderations_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class ModerationsController < ApplicationController ENTRIES_PER_PAGE = 50 @@ -5,55 +7,64 @@ class ModerationsController < ApplicationController def index @title = "Moderation Log" - @moderators = ['(All)', '(Users)'] + User.moderators.map(&:username) + @moderators = ["(All)", "(Users)"] + User.moderators.map(&:username) - @moderator = params.fetch('moderator', '(All)') + @moderator = moderation_params.fetch("moderator", "(All)") @what = { - :stories => params.dig(:what, :stories), - :comments => params.dig(:what, :comments), - :tags => params.dig(:what, :tags), - :users => params.dig(:what, :users), - :domains => params.dig(:what, :domains), - :categories => params.dig(:what, :categories), + stories: moderation_params.dig(:what, :stories), + comments: moderation_params.dig(:what, :comments), + tags: moderation_params.dig(:what, :tags), + users: moderation_params.dig(:what, :users), + domains: moderation_params.dig(:what, :domains), + origins: moderation_params.dig(:what, :origins), + categories: moderation_params.dig(:what, :categories) } @what.transform_values! { true } if @what.values.none? @moderations = Moderation.all.eager_load(:moderator, - :story, - :comment, - :tag, - :user, - :domain, - :category) + :story, + {comment: [:story, :user]}, + :tag, + :user, + :domain, + :origin, + :category) # filter based on target @moderations = case @moderator - when '(All)' + when "(All)" @moderations - when '(Users)' + when "(Users)" @moderations.where("is_from_suggestions = true") else - @moderations.joins(:moderator).where(:users => { :username => @moderator }) + @moderations.joins(:moderator).where(users: {username: @moderator}) end # filter based on type of thing moderated @what.each do |type, checked| next if checked - @moderations = @moderations.where("`moderations`.`#{type.to_s.singularize}_id` is null") + @moderations = @moderations.where("#{type.to_s.singularize}_id": nil) end # paginate @pages = (@moderations.count / ENTRIES_PER_PAGE).ceil - @page = params[:page].to_i + @page = moderation_params[:page].to_i if @page == 0 @page = 1 - elsif @page < 0 || @page > (2 ** 32) || @page > @pages + elsif @page < 0 || @page > (2**32) || @page > @pages raise ActionController::RoutingError.new("page out of bounds") end @moderations = @moderations - .offset((@page - 1) * ENTRIES_PER_PAGE) - .order("moderations.created_at desc") - .limit(ENTRIES_PER_PAGE) + .offset((@page - 1) * ENTRIES_PER_PAGE) + .order("moderations.created_at desc") + .limit(ENTRIES_PER_PAGE) + end + + private + + def moderation_params + @moderation_params ||= params.permit(:moderator, :page, + what: %i[stories comments tags users domains origins categories]) end end diff --git a/benchmarks/lobsters/app/controllers/origins_controller.rb b/benchmarks/lobsters/app/controllers/origins_controller.rb new file mode 100644 index 00000000..a8b3d89c --- /dev/null +++ b/benchmarks/lobsters/app/controllers/origins_controller.rb @@ -0,0 +1,49 @@ +# typed: false + +class OriginsController < ApplicationController + before_action :require_logged_in_moderator, only: [:edit, :update] + before_action :find_or_initialize_origin, only: [:edit, :update] + + def edit + end + + def update + @origin.assign_attributes(origin_params) + if params[:commit] == "Ban" + @origin.ban_by_user_for_reason! @user, origin_params[:banned_reason] + elsif params[:commit] == "Unban" + @origin.unban_by_user_for_reason! @user, origin_params[:banned_reason] + end + if @origin.save + flash[:success] = "Origin edited" + redirect_to origin_path(@origin) + else + render :edit + end + end + + def for_domain + @domain = Domain.find_by!(domain: params[:id]) + @origins = @domain.origins.order(identifier: :asc) + end + + private + + def origin_params + params.require(:origin).permit(:banned_reason) + end + + def find_or_initialize_origin + @origin = Origin.find_by! identifier: params[:identifier] + end + + def caption_of_button(origin) + if origin.new_record? + "Create and Ban" + else + origin.banned_at ? "Unban" : "Ban" + end + end + + helper_method :caption_of_button +end diff --git a/benchmarks/lobsters/app/controllers/replies_controller.rb b/benchmarks/lobsters/app/controllers/replies_controller.rb index a5b58409..b78b185b 100644 --- a/benchmarks/lobsters/app/controllers/replies_controller.rb +++ b/benchmarks/lobsters/app/controllers/replies_controller.rb @@ -1,7 +1,9 @@ +# typed: false + class RepliesController < ApplicationController REPLIES_PER_PAGE = 25 - before_action :require_logged_in_user, :flag_warning, :set_page, :show_title_h1 + before_action :require_logged_in_user, :set_page, :show_title_h1 after_action :update_read_ribbons, only: [:unread] after_action :clear_unread_replies_cache, only: [:comments, :stories] after_action :zero_unread_replies_cache, only: [:all, :unread] @@ -10,9 +12,9 @@ def all @title = "All Your Replies" @replies = ReplyingComment - .for_user(@user.id) - .offset((@page - 1) * REPLIES_PER_PAGE) - .limit(REPLIES_PER_PAGE) + .for_user(@user.id) + .offset((@page - 1) * REPLIES_PER_PAGE) + .limit(REPLIES_PER_PAGE) apply_current_vote render :show end @@ -20,9 +22,9 @@ def all def comments @title = "Your Comment Replies" @replies = ReplyingComment - .comment_replies_for(@user.id) - .offset((@page - 1) * REPLIES_PER_PAGE) - .limit(REPLIES_PER_PAGE) + .comment_replies_for(@user.id) + .offset((@page - 1) * REPLIES_PER_PAGE) + .limit(REPLIES_PER_PAGE) apply_current_vote render :show end @@ -30,9 +32,9 @@ def comments def stories @title = "Your Story Replies" @replies = ReplyingComment - .story_replies_for(@user.id) - .offset((@page - 1) * REPLIES_PER_PAGE) - .limit(REPLIES_PER_PAGE) + .story_replies_for(@user.id) + .offset((@page - 1) * REPLIES_PER_PAGE) + .limit(REPLIES_PER_PAGE) apply_current_vote render :show end @@ -44,17 +46,19 @@ def unread render :show end -private + private # comments/_comment expects Comment objects to have a comment_vote attribute # with the current user's vote added by StoriesController.load_user_votes def apply_current_vote + summaries = Vote.comment_vote_summaries(@replies.map { |r| r.comment.id }) @replies.each do |r| - next unless r.current_vote_vote.present? + next if r.current_vote_vote.blank? r.comment.current_vote = { vote: r.current_vote_vote, - reason: r.current_vote_reason.to_s, + reason: r.current_vote_reason.to_s } + r.comment.vote_summary = summaries[r.comment.id] end end @@ -70,7 +74,7 @@ def set_page @page = params[:page].to_i if @page == 0 @page = 1 - elsif @page < 0 || @page > (2 ** 32) + elsif @page < 0 || @page > (2**32) raise ActionController::RoutingError.new("page out of bounds") end end diff --git a/benchmarks/lobsters/app/controllers/search_controller.rb b/benchmarks/lobsters/app/controllers/search_controller.rb index 2c7a505e..0889ed14 100644 --- a/benchmarks/lobsters/app/controllers/search_controller.rb +++ b/benchmarks/lobsters/app/controllers/search_controller.rb @@ -1,29 +1,43 @@ +# typed: false + class SearchController < ApplicationController before_action :show_title_h1 + before_action :ignore_searx def index @title = "Search" - @search = Search.new - - if params[:q].to_s.present? - @search.q = params[:q].to_s + @search = Search.new(search_params, @user) - if params[:what].present? - @search.what = params[:what] + if @user && @search.results + summaries = {} + if params[:what] == "stories" + votes = Vote.story_votes_by_user_for_story_ids_hash(@user.id, @search.results.map(&:id)) end - if params[:order].present? - @search.order = params[:order] + if params[:what] == "comments" + comment_ids = @search.results.map(&:id) + votes = Vote.comment_votes_by_user_for_comment_ids_hash(@user.id, comment_ids) + summaries = Vote.comment_vote_summaries(comment_ids) end - if params[:page].present? - @search.page = params[:page].to_i - end - - if @search.valid? - @search.search_for_user!(@user) + @search.results.each do |r| + r.current_vote = votes.try(:[], r.id) + r.vote_summary = summaries[r.id] if params[:what] == "comments" end end + end + + private + + # searx is a meta-search engine, instances send endless garbage traffic to our most-expensive + # endpoint https://github.com/searx/searx/blob/master/searx/settings.yml#L807 + # If you are maintaining a searx fork, please don't 'fix' your targeting of this site. + def ignore_searx + return unless params[:utf8] == "✓" + @search = Search.new({results_count: 0}, nil) + render :index + end - render :action => "index" + def search_params + params.permit(:q, :what, :order, :page, :authenticity_token) end end diff --git a/benchmarks/lobsters/app/controllers/settings_controller.rb b/benchmarks/lobsters/app/controllers/settings_controller.rb index 8d1f5b60..39059471 100644 --- a/benchmarks/lobsters/app/controllers/settings_controller.rb +++ b/benchmarks/lobsters/app/controllers/settings_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class SettingsController < ApplicationController before_action :require_logged_in_user, :show_title_h1 @@ -10,7 +12,7 @@ def index end def delete_account - unless params[:user][:i_am_sure] == '1' + unless params[:user][:i_am_sure] == "1" flash[:error] = 'You did not check the "I am sure" checkbox.' return redirect_to settings_path end @@ -21,13 +23,13 @@ def delete_account @user.delete! disown_text = "" - if params[:user][:disown] == '1' + if params[:user][:disown] == "1" disown_text = " and disowned your stories and comments." InactiveUser.disown_all_by_author! @user end reset_session flash[:success] = "You have deleted your account#{disown_text}. Bye." - return redirect_to "/" + redirect_to "/" end def update @@ -35,15 +37,16 @@ def update @edit_user = @user.clone if params[:user][:password].empty? || - @user.authenticate(params[:current_password].to_s) + @user.authenticate(params[:current_password].to_s) @edit_user.roll_session_token if params[:user][:password] if @edit_user.update(user_params) if @edit_user.username != previous_username + # sync this message to username field app/views/settings/index.html Moderation.create!( is_from_suggestions: true, user: @edit_user, - action: "changed own username from \"#{previous_username}\" " << - "to \"#{@edit_user.username}\"", + action: "changed own username from \"#{previous_username}\" " \ + "to \"#{@edit_user.username}\"" ) end session[:u] = @user.session_token if params[:user][:password] @@ -54,7 +57,7 @@ def update flash[:error] = "Your current password was not entered correctly." end - render :action => "index" + render action: "index" end def twofa @@ -69,13 +72,13 @@ def twofa_auth if @user.has_2fa? @user.disable_2fa! flash[:success] = "Two-Factor Authentication has been disabled." - return redirect_to "/settings" + redirect_to "/settings" else - return redirect_to twofa_enroll_url + redirect_to twofa_enroll_url end else flash[:error] = "Your password was not correct." - return redirect_to twofa_url + redirect_to twofa_url end end @@ -91,15 +94,15 @@ def twofa_enroll session[:totp_secret] = ROTP::Base32.random end - totp = ROTP::TOTP.new(session[:totp_secret], :issuer => Rails.application.name) + totp = ROTP::TOTP.new(session[:totp_secret], issuer: Rails.application.name) totp_url = totp.provisioning_uri(@user.email) qrcode = RQRCode::QRCode.new(totp_url) qr = qrcode.as_svg(offset: 0, - fill: "ffffff", - color: "000", - module_size: 5, - shape_rendering: "crispEdges") + fill: "ffffff", + color: "000", + module_size: 5, + shape_rendering: "crispEdges") @qr_secret = totp.secret @qr_svg = "#{qr}" @@ -109,15 +112,15 @@ def twofa_verify @title = "Two-Factor Authentication" if ((Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT) || - !session[:totp_secret] + !session[:totp_secret] flash[:error] = "Your enrollment period timed out." - return redirect_to twofa_url + redirect_to twofa_url end end def twofa_update if ((Time.now.to_i - session[:last_authed].to_i) > TOTP_SESSION_TIMEOUT) || - !session[:totp_secret] + !session[:totp_secret] flash[:error] = "Your enrollment period timed out." return redirect_to twofa_url end @@ -132,38 +135,38 @@ def twofa_update flash[:success] = "Two-Factor Authentication has been enabled on your account." session.delete(:totp_secret) - return redirect_to "/settings" + redirect_to "/settings" else - flash[:error] = "Your TOTP code was invalid, please verify the " << - "current code in your TOTP application." - return redirect_to twofa_verify_url + flash[:error] = "Your TOTP code was invalid, please verify the " \ + "current code in your TOTP application." + redirect_to twofa_verify_url end end # external services def pushover_auth - if !Pushover.SUBSCRIPTION_CODE + if !Pushover.enabled? flash[:error] = "This site is not configured for Pushover" return redirect_to "/settings" end session[:pushover_rand] = SecureRandom.hex - return redirect_to Pushover.subscription_url( - :success => "#{Rails.application.root_url}settings/pushover_callback?" << + redirect_to Pushover.subscription_url( + success: "#{Rails.application.root_url}settings/pushover_callback?" \ "rand=#{session[:pushover_rand]}", - :failure => "#{Rails.application.root_url}settings/", - ) + failure: "#{Rails.application.root_url}settings/" + ), allow_other_host: true end def pushover_callback - if !session[:pushover_rand].to_s.present? + if session[:pushover_rand].to_s.blank? flash[:error] = "No random token present in session" return redirect_to "/settings" end - if !params[:rand].to_s.present? + if params[:rand].to_s.blank? flash[:error] = "No random token present in URL" return redirect_to "/settings" end @@ -175,100 +178,106 @@ def pushover_callback @user.pushover_user_key = params[:pushover_user_key].to_s @user.save! - if @user.pushover_user_key.present? - flash[:success] = "Your account is now setup for Pushover notifications." + flash[:success] = if @user.pushover_user_key.present? + "Your account is now setup for Pushover notifications." else - flash[:success] = "Your account is no longer setup for Pushover notifications." + "Your account is no longer setup for Pushover notifications." end - return redirect_to "/settings" + redirect_to "/settings" end - def github_auth - session[:github_state] = SecureRandom.hex - return redirect_to Github.oauth_auth_url(session[:github_state]) + def mastodon_authentication end - def github_callback - if !session[:github_state].present? || - !params[:code].present? || - (params[:state].to_s != session[:github_state].to_s) - flash[:error] = "Invalid OAuth state" - return redirect_to "/settings" + def mastodon_auth + app = MastodonApp.find_or_register(params[:mastodon_instance_name]) + if app.persisted? + redirect_to app.oauth_auth_url, allow_other_host: true + else + redirect_to settings_path, flash: {error: app.errors.full_messages.join(" ")} end + end - session.delete(:github_state) + def mastodon_callback + if params[:code].blank? + flash[:error] = "Invalid OAuth state" + return redirect_to settings_path + end - tok, username = Github.token_and_user_from_code(params[:code]) + app = MastodonApp.find_or_register(params[:instance]) + tok, username = app.token_and_user_from_code(params[:code]) if tok.present? && username.present? - @user.github_oauth_token = tok - @user.github_username = username + @user.mastodon_oauth_token = tok + @user.mastodon_username = username + @user.mastodon_instance = params[:instance] @user.save! - flash[:success] = "Your account has been linked to GitHub user #{username}." + flash[:success] = "Linked to Mastodon user @#{username}@#{app.name}." else - return github_disconnect + flash[:error] = app.errors.full_messages.join(" ") + return mastodon_disconnect end - return redirect_to "/settings" + redirect_to settings_path end - def github_disconnect - @user.github_oauth_token = nil - @user.github_username = nil + def mastodon_disconnect + if (app = MastodonApp.find_by(name: @user.mastodon_instance)) + app.revoke_token(@user.mastodon_oauth_token) + end + @user.mastodon_instance = nil + @user.mastodon_oauth_token = nil + @user.mastodon_username = nil @user.save! - flash[:success] = "Your GitHub association has been removed." - return redirect_to "/settings" + # action may be called to tear down a failed auth + flash[:success] = "Your Mastodon association has been removed." if flash.empty? + redirect_to settings_path end - def twitter_auth - session[:twitter_state] = SecureRandom.hex - return redirect_to Twitter.oauth_auth_url(session[:twitter_state]) - rescue OAuth::Unauthorized - flash[:error] = "Twitter says we're not authenticating properly, please message the admin" - return redirect_to "/settings" + def github_auth + session[:github_state] = SecureRandom.hex + redirect_to Github.oauth_auth_url(session[:github_state]), allow_other_host: true end - def twitter_callback - if session[:twitter_state].blank? || - (params[:state].to_s != session[:twitter_state].to_s) + def github_callback + if session[:github_state].blank? || + params[:code].blank? || + (params[:state].to_s != session[:github_state].to_s) flash[:error] = "Invalid OAuth state" - return redirect_to "/settings" + return redirect_to settings_path end - session.delete(:twitter_state) + session.delete(:github_state) - tok, sec, username = Twitter.token_secret_and_user_from_token_and_verifier( - params[:oauth_token], params[:oauth_verifier]) + tok, username = Github.token_and_user_from_code(params[:code]) if tok.present? && username.present? - @user.twitter_oauth_token = tok - @user.twitter_oauth_token_secret = sec - @user.twitter_username = username + @user.github_oauth_token = tok + @user.github_username = username @user.save! - flash[:success] = "Your account has been linked to Twitter user @#{username}." + flash[:success] = "Your account has been linked to GitHub user #{username}." else - return twitter_disconnect + return github_disconnect end - return redirect_to "/settings" + redirect_to settings_path end - def twitter_disconnect - @user.twitter_oauth_token = nil - @user.twitter_username = nil - @user.twitter_oauth_token_secret = nil + def github_disconnect + @user.github_oauth_token = nil + @user.github_username = nil @user.save! - flash[:success] = "Your Twitter association has been removed." - return redirect_to "/settings" + flash[:success] = "Your GitHub association has been removed." + redirect_to settings_path end -private + private def user_params params.require(:user).permit( :username, :email, :password, :password_confirmation, :homepage, :about, :email_replies, :email_messages, :email_mentions, :pushover_replies, :pushover_messages, :pushover_mentions, - :mailing_list_mode, :show_avatars, :show_story_previews, + :mailing_list_mode, :show_email, :show_avatars, :show_story_previews, :show_submitted_story_threads, :prefers_color_scheme ) end diff --git a/benchmarks/lobsters/app/controllers/signup_controller.rb b/benchmarks/lobsters/app/controllers/signup_controller.rb index 8b0b7d8d..68c1d009 100644 --- a/benchmarks/lobsters/app/controllers/signup_controller.rb +++ b/benchmarks/lobsters/app/controllers/signup_controller.rb @@ -1,5 +1,7 @@ +# typed: false + class SignupController < ApplicationController - before_action :require_logged_in_user, :check_new_users, :check_can_invite, :only => :invite + before_action :require_logged_in_user, :check_new_users, :check_can_invite, only: :invite before_action :check_for_read_only_mode, :show_title_h1 def index @@ -9,7 +11,7 @@ def index return redirect_to "/" end if Rails.application.open_signups? - redirect_to action: :invited, invitation_code: 'open' and return + redirect_to action: :invited, invitation_code: "open" and return end end @@ -27,7 +29,7 @@ def invited end if !Rails.application.open_signups? - if !(@invitation = Invitation.unused.where(:code => params[:invitation_code].to_s).first) + if !(@invitation = Invitation.unused.where(code: params[:invitation_code].to_s).first) flash[:error] = "Invalid or expired invitation" return redirect_to "/signup" end @@ -40,13 +42,11 @@ def invited if !Rails.application.open_signups? @new_user.email = @invitation.email end - - render :action => "invited" end def signup if !Rails.application.open_signups? - if !(@invitation = Invitation.unused.where(:code => params[:invitation_code].to_s).first) + if !(@invitation = Invitation.unused.where(code: params[:invitation_code].to_s).first) flash[:error] = "Invalid or expired invitation." return redirect_to "/signup" end @@ -61,40 +61,38 @@ def signup end if @new_user.save - if @invitation - @invitation.update(used_at: Time.current, new_user: @new_user) - end + @invitation&.update!(used_at: Time.current, new_user: @new_user) session[:u] = @new_user.session_token - flash[:success] = "Welcome to #{Rails.application.name}, " << - "#{@new_user.username}!" + flash[:success] = "Welcome to #{Rails.application.name}, " \ + "#{@new_user.username}!" if Rails.application.allow_new_users_to_invite? - return redirect_to signup_invite_path + redirect_to signup_invite_path else - return redirect_to root_path + redirect_to root_path end else - render :action => "invited" + render action: "invited" end end -private + private def check_new_users if !Rails.application.allow_new_users_to_invite? && @user.is_new? - redirect_to root_path, flash: { error: "New users cannot send invites" } + redirect_to root_path, flash: {error: "New users cannot send invites"} end end def check_can_invite if !@user.can_invite? - redirect_to root_path, flash: { error: "You can't send invites" } + redirect_to root_path, flash: {error: "You can't send invites"} end end def user_params params.require(:user).permit( - :username, :email, :password, :password_confirmation, :about, + :username, :email, :password, :password_confirmation, :about ) end end diff --git a/benchmarks/lobsters/app/controllers/stats_controller.rb b/benchmarks/lobsters/app/controllers/stats_controller.rb index 91729f1a..3f1ac3af 100644 --- a/benchmarks/lobsters/app/controllers/stats_controller.rb +++ b/benchmarks/lobsters/app/controllers/stats_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class StatsController < ApplicationController FIRST_MONTH = Time.new(2012, 7, 3).utc.freeze TIMESCALE_DIVISIONS = "1 year".freeze @@ -7,52 +9,53 @@ def index @users_graph = monthly_graph("users_graph", { graph_title: "Users joining by month", - scale_y_divisions: 100, + scale_y_divisions: 100 }) { - User.group("strftime('%Y-%m', created_at)").count + User.group("date_format(created_at, '%Y-%m')").count.to_a.flatten } @active_users_graph = monthly_graph("active_users_graph", { graph_title: "Active users by month", - scale_y_divisions: 500, + scale_y_divisions: 500 }) { - User.connection.execute <<~SQL + User.connection.select_all(<<~SQL SELECT ym, count(distinct user_id) FROM ( - SELECT strftime('%Y-%m', created_at) as ym, user_id FROM stories + SELECT date_format(created_at, '%Y-%m') as ym, user_id FROM stories UNION - SELECT strftime('%Y-%m', updated_at) as ym, user_id FROM votes + SELECT date_format(updated_at, '%Y-%m') as ym, user_id FROM votes UNION - SELECT strftime('%Y-%m', created_at) as ym, user_id FROM comments + SELECT date_format(created_at, '%Y-%m') as ym, user_id FROM comments ) as active_users GROUP BY 1 ORDER BY 1 asc; SQL + ).to_a.map(&:values).flatten } @stories_graph = monthly_graph("stories_graph", { graph_title: "Stories submitted by month", - scale_y_divisions: 250, + scale_y_divisions: 250 }) { - Story.group("strftime('%Y-%m', created_at)").count + Story.group("date_format(created_at, '%Y-%m')").count.to_a.flatten } @comments_graph = monthly_graph("comments_graph", { graph_title: "Comments posted by month", - scale_y_divisions: 1_000, + scale_y_divisions: 1_000 }) { - Comment.group("strftime('%Y-%m', created_at)").count + Comment.group("date_format(created_at, '%Y-%m')").count.to_a.flatten } @votes_graph = monthly_graph("votes_graph", { graph_title: "Votes cast by month", - scale_y_divisions: 10_000, + scale_y_divisions: 10_000 }) { - Vote.group("strftime('%Y-%m', updated_at)").count + Vote.group("date_format(updated_at, '%Y-%m')").count.to_a.flatten } end -private + private def monthly_graph(cache_key, opts) Rails.cache.fetch(cache_key, expires_in: 1.day) { @@ -82,12 +85,12 @@ def monthly_graph(cache_key, opts) area_fill: false, min_y_value: 0, number_format: "%d", - show_lines: false, + show_lines: false } graph = TimeSeries.new(defaults.merge(opts)) graph.add_data( - data: yield.to_a.flatten, - template: "%Y-%m", + data: yield, + template: "%Y-%m" ) graph.burn_svg_only } diff --git a/benchmarks/lobsters/app/controllers/stories_controller.rb b/benchmarks/lobsters/app/controllers/stories_controller.rb index 05a4861f..3a803e72 100644 --- a/benchmarks/lobsters/app/controllers/stories_controller.rb +++ b/benchmarks/lobsters/app/controllers/stories_controller.rb @@ -1,15 +1,18 @@ +# typed: false + class StoriesController < ApplicationController + include StoryFinder + caches_page :show, if: CACHE_PAGE before_action :require_logged_in_user_or_400, - :only => [:upvote, :flag, :unvote, :hide, :unhide, :preview, :save, :unsave] + only: [:upvote, :flag, :unvote, :hide, :unhide, :preview, :save, :unsave] before_action :require_logged_in_user, - :only => [:destroy, :create, :edit, :fetch_url_attributes, :new, :suggest] - before_action :verify_user_can_submit_stories, :only => [:new, :create] - before_action :find_user_story, :only => [:destroy, :edit, :undelete, :update] - before_action :find_story!, :only => [:suggest, :submit_suggestions] + only: [:destroy, :create, :edit, :fetch_url_attributes, :new] + before_action :verify_user_can_submit_stories, only: [:new, :create] + before_action :find_user_story, only: [:destroy, :edit, :undelete, :update] around_action :track_story_reads, only: [:show], if: -> { @user.present? } - before_action :show_title_h1, only: [:new, :edit, :suggest] + before_action :show_title_h1, only: [:new, :edit] def create @title = "Submit Story" @@ -17,34 +20,43 @@ def create @story = Story.new(user: @user) @story.attributes = story_params - if @story.valid? && !(@story.already_posted_recently? && !@story.seen_previous) - if ActiveRecord::Base.transaction { @story.save } - ReadRibbon.where(user: @user, story: @story).first_or_create - return redirect_to @story.comments_path + if @story.is_resubmit? + @comment = @story.comments.new(user: @user) + @comment.comment = params[:comment] + @comment.hat = @user.wearable_hats.find_by(short_id: params[:hat_id]) + end + + if @story.valid? && + !@story.already_posted_recently? && + (!@story.is_resubmit? || @comment.valid?) + + Story.transaction do + if @story.save && (!@story.is_resubmit? || @comment.save) + ReadRibbon.where(user: @user, story: @story).first_or_create! + redirect_to @story.comments_path + else + raise ActiveRecord::Rollback + end end + return if @story.persisted? # can't return out of transaction block end - return render :action => "new" + render action: "new" end def destroy - if !@story.is_editable_by_user?(@user) + if !@story.is_editable_by_user?(@user) && !@user.is_moderator? flash[:error] = "You cannot edit that story." return redirect_to "/" end update_story_attributes - - if @story.user_id != @user.id && @user.is_moderator? && !@story.moderation_reason.present? - @story.errors.add(:moderation_reason, message: 'is required') - return render :action => "edit" - end - @story.is_deleted = true @story.editor = @user if @story.save Keystore.increment_value_for("user:#{@story.user.id}:stories_deleted") + Mastodon.delete_post(@story) end redirect_to @story.comments_path @@ -57,11 +69,6 @@ def edit end @title = "Edit Story" - - if @story.merged_into_story - @story.merge_story_short_id = @story.merged_into_story.short_id - User.update_counters @story.user_id, karma: (@story.votes.count * -2) - end end def fetch_url_attributes @@ -69,7 +76,7 @@ def fetch_url_attributes s.fetching_ip = request.remote_ip s.url = params[:fetch_url] - return render :json => s.fetched_attributes + render json: s.fetched_attributes end def new @@ -83,8 +90,8 @@ def new sattrs = @story.fetched_attributes if sattrs[:url].present? && @story.url != sattrs[:url] - flash.now[:notice] = "Note: URL has been changed to fetched " << - "canonicalized version" + flash.now[:notice] = "Note: URL has been changed to fetched " \ + "canonicalized version" @story.url = sattrs[:url] end @@ -95,9 +102,15 @@ def new return redirect_to @story.most_recent_similar.comments_path end + if @story.is_resubmit? + @comment = @story.comments.new(user: @user) + @comment.comment = params[:comment] + @comment.hat = @user.wearable_hats.find_by(short_id: params[:hat_id]) + end + # ignore what the user brought unless we need it as a fallback @story.title = sattrs[:title] - if !@story.title.present? && params[:title].present? + if @story.title.blank? && params[:title].present? @story.title = params[:title] end end @@ -108,14 +121,12 @@ def preview @story.user_id = @user.id @story.previewing = true - @story.vote = Vote.new(:vote => 1) + @story.current_vote = Vote.new(vote: 1) @story.score = 1 @story.valid? - @story.seen_previous = true - - return render :action => "new", :layout => false + render action: "new", layout: false end def show @@ -133,25 +144,27 @@ def show end end + # if asking with a title and it's been edited, 302 + if params[:title] && params[:title] != @story.title_as_url + return redirect_to(@story.comments_path) + end + if @story.is_gone? @moderation = Moderation .where(story: @story, comment: nil) .where("action LIKE '%deleted story%'") - .order('id desc') + .order("id desc") .first end if !@story.can_be_seen_by_user?(@user) respond_to do |format| - format.html { return render action: '_missing', status: 404 } + format.html { return render action: "_missing", status: 404, locals: {story: @story, moderation: @moderation} } format.json { raise ActiveRecord::RecordNotFound } end end - @comments = get_arranged_comments_from_cache(params[:id]) do - @story.merged_comments - .includes(:user, :story, :hat, :votes => :user) - .arrange_for_user(@user) - end + @user.try(:clear_unread_replies!) + @comments = Comment.story_threads(@story).for_presentation @title = @story.title @short_url = @story.short_id_url @@ -165,72 +178,26 @@ def show "twitter:site" => "@lobsters", "twitter:title" => @story.title, "twitter:description" => @story.comments_count.to_s + " " + - 'comment'.pluralize(@story.comments_count), + "comment".pluralize(@story.comments_count), "twitter:image" => Rails.application.root_url + - "apple-touch-icon-144.png", + "touch-icon-144.png" } - if @story.user.twitter_username.present? - @meta_tags["twitter:creator"] = "@" + @story.user.twitter_username + if @story.user.mastodon_username.present? + @meta_tags["twitter:creator"] = @story.user.mastodon_acct end load_user_votes - render :action => "show" + render action: "show" } format.json { - render :json => @story.as_json(:with_comments => @comments) + @comments = @comments.includes(:parent_comment) + render json: @story.as_json(with_comments: @comments) } end end - def suggest - @title = 'Suggest Story Changes' - if !@story.can_have_suggestions_from_user?(@user) - flash[:error] = "You are not allowed to offer suggestions on that story." - return redirect_to @story.comments_path - end - - if (suggested_tags = @story.suggested_taggings.where(:user_id => @user.id)).any? - @story.tags_a = suggested_tags.map {|st| st.tag.tag } - end - if (tt = @story.suggested_titles.where(:user_id => @user.id).first) - @story.title = tt.title - end - end - - def submit_suggestions - if !@story.can_have_suggestions_from_user?(@user) - flash[:error] = "You are not allowed to offer suggestions on that story." - return redirect_to @story.comments_path - end - - ostory = @story.dup - - @story.title = params[:story][:title] - if @story.valid? - dsug = false - if @story.title != ostory.title - @story.save_suggested_title_for_user!(@story.title, @user) - dsug = true - end - - sugtags = params[:story][:tags_a].reject {|t| t.to_s.strip == "" }.sort - if @story.tags_a.sort != sugtags - @story.save_suggested_tags_a_for_user!(sugtags, @user) - dsug = true - end - - if dsug - ostory = @story.reload - flash[:success] = "Your suggested changes have been noted." - end - redirect_to ostory.comments_path - else - render :action => "suggest" - end - end - def undelete if !(@story.is_editable_by_user?(@user) && @story.is_undeletable_by_user?(@user)) @@ -260,211 +227,193 @@ def update update_story_attributes if @story.save - return redirect_to @story.comments_path + redirect_to @story.comments_path else - return render :action => "edit" + render action: "edit" end end def unvote if !(story = find_story) || story.is_gone? - return render :plain => "can't find story", :status => 400 + return render plain: "can't find story", status: 400 end Vote.vote_thusly_on_story_or_comment_for_user_because( 0, story.id, nil, @user.id, nil ) - render :plain => "ok" + render plain: "ok" end def upvote if !(story = find_story) || story.is_gone? - return render :plain => "can't find story", :status => 400 + return render plain: "can't find story", status: 400 end if story.merged_into_story - return render :plain => "story has been merged", :status => 400 + return render plain: "story has been merged", status: 400 end Vote.vote_thusly_on_story_or_comment_for_user_because( 1, story.id, nil, @user.id, nil ) - render :plain => "ok" + render plain: "ok" end def flag if !(story = find_story) || story.is_gone? - return render :plain => "can't find story", :status => 400 + return render plain: "can't find story", status: 400 end if !Vote::STORY_REASONS[params[:reason]] - return render :plain => "invalid reason", :status => 400 + return render plain: "invalid reason", status: 400 end if !@user.can_flag?(story) - return render :plain => "not permitted to flag", :status => 400 + return render plain: "not permitted to flag", status: 400 end Vote.vote_thusly_on_story_or_comment_for_user_because( -1, story.id, nil, @user.id, params[:reason] ) - render :plain => "ok" + render plain: "ok" end def hide if !(story = find_story) - return render :plain => "can't find story", :status => 400 + return render plain: "can't find story", status: 400 end if story.merged_into_story - return render :plain => "story has been merged", :status => 400 + return render plain: "story has been merged", status: 400 end - HiddenStory.hide_story_for_user(story.id, @user.id) + HiddenStory.hide_story_for_user(story, @user) - render :plain => "ok" + render plain: "ok" end def unhide if !(story = find_story) - return render :plain => "can't find story", :status => 400 + return render plain: "can't find story", status: 400 end - HiddenStory.unhide_story_for_user(story.id, @user.id) + HiddenStory.unhide_story_for_user(story, @user) - render :plain => "ok" + render plain: "ok" end def save if !(story = find_story) - return render :plain => "can't find story", :status => 400 + return render plain: "can't find story", status: 400 end if story.merged_into_story - return render :plain => "story has been merged", :status => 400 + return render plain: "story has been merged", status: 400 end SavedStory.save_story_for_user(story.id, @user.id) - render :plain => "ok" + render plain: "ok" end def unsave if !(story = find_story) - return render :plain => "can't find story", :status => 400 + return render plain: "can't find story", status: 400 end - SavedStory.where(:user_id => @user.id, :story_id => story.id).delete_all + SavedStory.where(user_id: @user.id, story_id: story.id).delete_all - render :plain => "ok" + render plain: "ok" end def check_url_dupe - raise ActionController::ParameterMissing.new("No URL") unless story_params[:url].present? + raise ActionController::ParameterMissing.new("No URL") if story_params[:url].blank? @story = Story.new(user: @user) @story.attributes = story_params @story.already_posted_recently? respond_to do |format| + linking_comments = Link.recently_linked_from_comments(@story.url) format.html { - return render :partial => "stories/form_errors", :layout => false, - :content_type => "text/html", :locals => { :story => @story } + return render partial: "stories/form_errors", layout: false, + content_type: "text/html", locals: { + linking_comments: linking_comments, + story: @story + } } # json: https://github.com/lobsters/lobsters/pull/555 format.json { similar_stories = @story.public_similar_stories(@user).map(&:as_json) - - render :json => @story.as_json.merge(similar_stories: similar_stories) + render json: @story.as_json.merge(similar_stories: similar_stories) } end end -private - - def get_arranged_comments_from_cache(short_id, &block) - if Rails.env.development? || @user - yield - else - Rails.cache.fetch("story #{short_id}", expires_in: 60, &block) + def disown + if !((story = find_story) && story.disownable_by_user?(@user)) + return render plain: "can't find story", status: 400 end - end - def story_params - p = params.require(:story).permit( - :title, :url, :description, :moderation_reason, :seen_previous, - :merge_story_short_id, :is_unavailable, :user_is_author, :user_is_following, - :tags_a => [], - ) + InactiveUser.disown! story - if @user && @user.is_moderator? - p - else - p.except(:moderation_reason, :merge_story_short_id, :is_unavailable) - end - end + if request.xhr? + @story = find_story + @comments = Comment.story_threads(@story).for_presentation - def update_story_attributes - if @story.url_is_editable_by_user?(@user) - @story.attributes = story_params + load_user_votes + + render partial: "listdetail", layout: false, content_type: "text/html", locals: {story: @story, single_story: true} else - @story.attributes = story_params.except(:url) + redirect_to story.short_id_path end end - def find_story - story = Story.find_by(:short_id => params[:story_id]) - if @user && story - story.vote = Vote.find_by( - user: @user, - story: story.id, - comment: nil - ).try(:vote) - end + private - story + def story_params + params.require(:story).permit(:title, :url, :description, :user_is_author, :user_is_following, tags_a: []) end - def find_story! - @story = find_story - if !@story - raise ActiveRecord::RecordNotFound + def update_story_attributes + @story.attributes = if @story.url_is_editable_by_user?(@user) + story_params + else + story_params.except(:url) end end def find_user_story - if @user.is_moderator? - @story = Story.where(:short_id => params[:story_id] || params[:id]).first + @story = if @user.is_moderator? + Story.where(short_id: params[:story_id] || params[:id]).first else - @story = Story.where(:user_id => @user.id, :short_id => - (params[:story_id] || params[:id])).first + Story.where(user_id: @user.id, short_id: params[:story_id] || params[:id]).first end if !@story - flash[:error] = "Could not find story or you are not authorized " << - "to manage it." + flash[:error] = "Could not find story or you are not authorized " \ + "to manage it." redirect_to "/" - return false + false end end def load_user_votes if @user - if (v = Vote.where(:user_id => @user.id, :story_id => @story.id, :comment_id => nil).first) - @story.vote = { :vote => v.vote, :reason => v.reason } - end + @story.current_vote = Vote.find_by(user: @user, story: @story, comment: nil) @story.is_hidden_by_cur_user = @story.is_hidden_by_user?(@user) @story.is_saved_by_cur_user = @story.is_saved_by_user?(@user) @votes = Vote.comment_votes_by_user_for_story_hash( - @user.id, (@story.merged_stories.ids).push(@story.id)) + @user.id, @story.merged_stories.ids.push(@story.id) + ) + vote_summaries = Vote.comment_vote_summaries(@comments.map(&:id)) @comments.each do |c| - if @votes[c.id] - c.current_vote = @votes[c.id] - end + c.current_vote = @votes[c.id] + c.vote_summary = vote_summaries[c.id] end end end @@ -472,13 +421,13 @@ def load_user_votes def verify_user_can_submit_stories if !@user.can_submit_stories? flash[:error] = "You are not allowed to submit new stories." - return redirect_to "/" + redirect_to "/" end end def track_story_reads @story = Story.where(short_id: params[:id]).first! - @ribbon = ReadRibbon.where(user: @user, story: @story).first_or_create + @ribbon = ReadRibbon.where(user: @user, story: @story).first_or_initialize yield @ribbon.bump end diff --git a/benchmarks/lobsters/app/controllers/story_urls_controller.rb b/benchmarks/lobsters/app/controllers/story_urls_controller.rb new file mode 100644 index 00000000..96024a26 --- /dev/null +++ b/benchmarks/lobsters/app/controllers/story_urls_controller.rb @@ -0,0 +1,26 @@ +# typed: false + +class StoryUrlsController < ApplicationController + def all + url = params.require(:url) + + respond_to do |format| + format.json { + render json: Story.find_similar_by_url(url).for_presentation + } + end + end + + def latest + url = params.require(:url) + + similar_stories = Story.find_similar_by_url(url) + if similar_stories.any? + redirect_to similar_stories.first.comments_path + elsif @user + redirect_to new_story_path, url: url + else + raise ActiveRecord::RecordNotFound + end + end +end diff --git a/benchmarks/lobsters/app/controllers/suggestions_controller.rb b/benchmarks/lobsters/app/controllers/suggestions_controller.rb new file mode 100644 index 00000000..691c7f81 --- /dev/null +++ b/benchmarks/lobsters/app/controllers/suggestions_controller.rb @@ -0,0 +1,79 @@ +class SuggestionsController < ApplicationController + include StoryFinder + + before_action :find_story!, only: [:new, :create] + before_action :require_logged_in_user, only: [:new] + before_action :show_title_h1, only: [:new] + + def create + if !@story.can_have_suggestions_from_user?(@user) + flash[:error] = "You are not allowed to offer suggestions on that story." + return redirect_to @story.comments_path + end + + story_user = @story.user + inappropriate_tags = Tag + .where(tag: params[:story][:tags_a].reject { |t| t.to_s.blank? }) + .reject { |t| t.can_be_applied_by?(story_user) } + if inappropriate_tags.length > 0 + tag_error = "" + inappropriate_tags.each do |t| + tag_error += if t.privileged? + "User #{story_user.username} cannot apply tag #{t.tag} as they are not a " \ + "moderator so it has been removed from your suggestion.\n" + elsif !t.permit_by_new_users? + "User #{story_user.username} cannot apply tag #{t.tag} due to being a new " \ + "user so it has been removed from your suggestion.\n" + else + "User #{story_user.username} cannot apply tag #{t.tag} " \ + "so it has been removed from your suggestion.\n" + end + end + tag_error += "" + flash[:error] = tag_error + end + + ostory = @story.dup + + @story.title = params[:story][:title] + if @story.valid? + dsug = false + if @story.title != ostory.title + @story.save_suggested_title_for_user!(@story.title, @user) + dsug = true + end + + sugtags = Tag + .where(tag: params[:story][:tags_a].reject { |t| t.to_s.strip.blank? }) + .reject { |t| !t.can_be_applied_by?(story_user) } + .map { |s| s.tag } + if @story.tags_a.sort != sugtags.sort + @story.save_suggested_tags_a_for_user!(sugtags, @user) + dsug = true + end + + if dsug + ostory = @story.reload + flash[:success] = "Your suggested changes have been noted." + end + redirect_to ostory.comments_path + else + render action: "suggest" + end + end + + def new + @title = "Suggest Story Changes" + if !@story.can_have_suggestions_from_user?(@user) + flash[:error] = "You are not allowed to offer suggestions on that story." + return redirect_to @story.comments_path + end + + if (suggested_tags = @story.suggested_taggings.where(user_id: @user.id)).any? + @story.tags_a = suggested_tags.map { |st| st.tag.tag } + end + if (tt = @story.suggested_titles.where(user_id: @user.id).first) + @story.title = tt.title + end + end +end diff --git a/benchmarks/lobsters/app/controllers/tags_controller.rb b/benchmarks/lobsters/app/controllers/tags_controller.rb index e0587d76..76a4d581 100644 --- a/benchmarks/lobsters/app/controllers/tags_controller.rb +++ b/benchmarks/lobsters/app/controllers/tags_controller.rb @@ -1,3 +1,5 @@ +# typed: false + class TagsController < ApplicationController before_action :require_logged_in_admin, except: [:index] before_action :show_title_h1, only: [:new, :edit] @@ -5,18 +7,18 @@ class TagsController < ApplicationController def index @title = "Tags" - @categories = Category.all.order('category asc').includes(:tags) + @categories = Category.all.order("category asc").includes(:tags) @tags = Tag.all - if @user - @filtered_tags = @user.tag_filter_tags.index_by(&:id) + @filtered_tags = if @user + @user.tag_filter_tags.index_by(&:id) else - @filtered_tags = tags_filtered_by_cookie.index_by(&:id) + tags_filtered_by_cookie.index_by(&:id) end respond_to do |format| - format.html { render :action => "index" } - format.json { render :json => @tags } + format.html { render action: "index" } + format.json { render json: @tags } end end @@ -26,34 +28,34 @@ def new end def create - @title = 'Create Tag' + @title = "Create Tag" tag = Tag.create(tag_params) - if tag.valid? + if tag.persisted? flash[:success] = "Tag #{tag.tag} has been created" redirect_to tags_path else - flash[:error] = "New tag not created: #{tag.errors.full_messages.join(', ')}" + flash[:error] = "New tag not created: #{tag.errors.full_messages.join(", ")}" redirect_to new_tag_path end end def edit - @tag = Tag.where(:tag => params[:tag_name]).first! + @tag = Tag.where(tag: params[:tag_name]).first! @title = "Edit Tag" end def update - tag = Tag.where(:tag => params[:tag_name]).first! + tag = Tag.where(tag: params[:tag_name]).first! if tag.update(tag_params) flash[:success] = "Tag #{tag.tag} has been updated" redirect_to tags_path else - flash[:error] = "Tag not updated: #{tag.errors.full_messages.join(', ')}" + flash[:error] = "Tag not updated: #{tag.errors.full_messages.join(", ")}" redirect_to edit_tag_path end end -private + private def tag_params params.require(:tag).permit( @@ -65,7 +67,7 @@ def tag_params :privileged, :is_media, :active, - :hotness_mod, + :hotness_mod ).merge(edit_user_id: @user.id) end end diff --git a/benchmarks/lobsters/app/controllers/users_controller.rb b/benchmarks/lobsters/app/controllers/users_controller.rb index 56e16f38..31c64091 100644 --- a/benchmarks/lobsters/app/controllers/users_controller.rb +++ b/benchmarks/lobsters/app/controllers/users_controller.rb @@ -1,8 +1,9 @@ +# typed: false + class UsersController < ApplicationController - before_action :load_showing_user, :only => [:show, :standing] - before_action :require_logged_in_moderator, - :only => [:enable_invitation, :disable_invitation, :ban, :unban] - before_action :flag_warning, only: [:show] + before_action :load_showing_user, only: [:show, :standing] + before_action :require_logged_in_admin, + only: [:enable_invitation, :disable_invitation, :ban, :unban] before_action :require_logged_in_user, only: [:standing] before_action :only_user_or_moderator, only: [:standing] before_action :show_title_h1, only: [:show] @@ -21,8 +22,8 @@ def show end respond_to do |format| - format.html { render :action => "show" } - format.json { render :json => @showing_user } + format.html { render action: "show" } + format.json { render json: @showing_user } end end @@ -31,41 +32,38 @@ def tree newest_user = User.last.id # pulling 10k+ users is significant enough memory pressure this is worthwhile - attrs = %w{banned_at created_at deleted_at id invited_by_user_id is_admin is_moderator karma - username} + attrs = %w[banned_at created_at deleted_at id invited_by_user_id is_admin is_moderator karma + username] if params[:by].to_s == "karma" - content = Rails.cache.fetch("users_by_karma_#{newest_user}", :expires_in => (60 * 60 * 24)) { + content = Rails.cache.fetch("users_by_karma_#{newest_user}", expires_in: (60 * 60 * 24)) { @users = User.select(*attrs).order("karma DESC, id ASC").to_a @user_count = @users.length @title << " By Karma" - render_to_string :action => "list", :layout => nil + render_to_string action: "list", layout: nil } - render :html => content.html_safe, :layout => "application" + render html: content.html_safe, layout: "application" elsif params[:moderators] @users = User.select(*attrs).where("is_admin = ? OR is_moderator = ?", true, true) .order("id ASC").to_a @user_count = @users.length @title = "Moderators and Administrators" - render :action => "list" + render action: "list" else - content = Rails.cache.fetch("users_tree_#{newest_user}", :expires_in => (60 * 60 * 24)) { + # Mod::ReparentsController#create knows this key + content = Rails.cache.fetch("users_tree_#{newest_user}", expires_in: 12.hours) { users = User.select(*attrs).order("id DESC").to_a @user_count = users.length @users_by_parent = users.group_by(&:invited_by_user_id) @newest = User.select(*attrs).order("id DESC").limit(10) - render_to_string :action => "tree", :layout => nil + render_to_string action: "tree", layout: nil } - render :html => content.html_safe, :layout => "application" + render html: content.html_safe, layout: "application" end end - def invite - @title = "Pass Along an Invitation" - end - def disable_invitation - target = User.where(:username => params[:username]).first + target = User.where(username: params[:username]).first if !target flash[:error] = "Invalid user." redirect_to "/" @@ -73,12 +71,12 @@ def disable_invitation target.disable_invite_by_user_for_reason!(@user, params[:reason]) flash[:success] = "User has had invite capability disabled." - redirect_to user_path(:user => target.username) + redirect_to user_path(user: target.username) end end def enable_invitation - target = User.where(:username => params[:username]).first + target = User.where(username: params[:username]).first if !target flash[:error] = "Invalid user." redirect_to "/" @@ -86,30 +84,30 @@ def enable_invitation target.enable_invite_by_user!(@user) flash[:success] = "User has had invite capability enabled." - redirect_to user_path(:user => target.username) + redirect_to user_path(user: target.username) end end def ban - buser = User.where(:username => params[:username]).first + buser = User.where(username: params[:username]).first if !buser flash[:error] = "Invalid user." return redirect_to "/" end - if !params[:reason].present? + if params[:reason].blank? flash[:error] = "You must give a reason for the ban." - return redirect_to user_path(:user => buser.username) + return redirect_to user_path(user: buser.username) end buser.ban_by_user_for_reason!(@user, params[:reason]) flash[:success] = "User has been banned." - return redirect_to user_path(:user => buser.username) + redirect_to user_path(user: buser.username) end def unban - buser = User.where(:username => params[:username]).first + buser = User.where(username: params[:username]).first if !buser flash[:error] = "Invalid user." return redirect_to "/" @@ -118,15 +116,14 @@ def unban buser.unban_by_user!(@user, params[:reason]) flash[:success] = "User has been unbanned." - return redirect_to user_path(:user => buser.username) + redirect_to user_path(user: buser.username) end def standing - flag_warning - int = @flag_warning_int + @interval = time_interval("1m") - fc = FlaggedCommenters.new(int[:param], 1.day) - @fc_flagged = fc.commenters.map {|_, c| c[:n_flags] }.sort + fc = FlaggedCommenters.new(@interval[:param], 1.day) + @fc_flagged = fc.commenters.map { |_, c| c[:n_flags] }.sort @flagged_user_stats = fc.check_list_for(@showing_user) rows = ActiveRecord::Base.connection.exec_query(" @@ -138,25 +135,25 @@ def standing from comments where - comments.created_at >= now() - interval #{int[:dur]} #{int[:intv]} + comments.created_at >= now() - interval #{@interval[:dur]} #{@interval[:intv]} group by comments.user_id) count_by_user group by 1 order by 1 asc; ").rows users = Array.new(@fc_flagged.last.to_i + 1, 0) - rows.each {|r| users[r.first] = r.last } + rows.each { |r| users[r.first] = r.last } @lookup = rows.to_h @flagged_comments = @showing_user.comments .where(" comments.flags > 0 and - comments.created_at >= now() - interval #{int[:dur]} #{int[:intv]}") + comments.created_at >= now() - interval #{@interval[:dur]} #{@interval[:intv]}") .order("id DESC") - .includes(:user, :hat, :story => :user) + .includes(:user, :hat, story: :user) .joins(:story) end -private + private def load_showing_user # case-insensitive search by username @@ -171,7 +168,7 @@ def load_showing_user # now a case-sensitive check if params[:username] != @showing_user.username redirect_to username: @showing_user.username - return false + false end end diff --git a/benchmarks/lobsters/app/helpers/application_helper.rb b/benchmarks/lobsters/app/helpers/application_helper.rb index cfcbd8f2..ff1278de 100644 --- a/benchmarks/lobsters/app/helpers/application_helper.rb +++ b/benchmarks/lobsters/app/helpers/application_helper.rb @@ -1,3 +1,5 @@ +# typed: false + module ApplicationHelper include TimeAgoInWords @@ -6,25 +8,30 @@ module ApplicationHelper def avatar_img(user, size) image_tag( user.avatar_path(size), - :srcset => "#{user.avatar_path(size)} 1x, #{user.avatar_path(size * 2)} 2x", - :class => "avatar", - :size => "#{size}x#{size}", - :alt => "#{user.username} avatar", - :loading => "lazy", - :decoding => "async", + srcset: "#{user.avatar_path(size)} 1x, #{user.avatar_path(size * 2)} 2x", + class: "avatar", + size: "#{size}x#{size}", + alt: "#{user.username} avatar", + loading: "lazy", + decoding: "async" ) end def errors_for(object) - html = "" + html = +"" unless object.errors.blank? html << "
" - html << "

#{pluralize(object.errors.count, 'error')} prohibited this \ + html << "

#{pluralize(object.errors.count, "error")} prohibited this \ #{object.class.name.downcase} from being saved

" html << "

There were the problems with the following fields:

" html << "
" end @@ -32,31 +39,70 @@ def errors_for(object) raw(html) end + def excerpt_fragment_around_link(html, url) + words = 8 + url = Utils.normalize(url) + parsed = Nokogiri::HTML5.fragment(html) + + # first loop: remove other tags by replacing them with their children + is_first_link = true + parsed.search("*").each do |tag| + if tag.name == "a" && Utils.normalize(tag["href"]) == url && is_first_link + # do not remove it, just mark that we've seen it + is_first_link = false + else + tag.replace(tag.children) + end + end + # link not found, return start of input + if parsed.search("a").empty? + return parsed.to_html.split.first(words * 2).join(" ") + end + + # merge adjacent text nodes by reparsing + parsed = Nokogiri::HTML5.fragment(parsed.to_html) + + # locate the html tag + index = parsed.children.find_index { |node| node.name == "a" } + # if link hast text to the left + if index != 0 + t = parsed.children.first.text + parsed.children.first.replace(t.split.last(words).join(" ") + " ") + end + # if link has text to the right + if index != parsed.children.count - 1 + t = parsed.children.last.text + parsed.children.last.replace(" " + t.split.last(words).join(" ")) + end + + parsed.to_html + end + # limitation: this can't handle generating links based on a hash of options, # like { controller: ..., action: ... } def link_to_different_page(text, path, options = {}) - current = request.path.sub(/\/page\/\d+$/, '') - path.sub!(/\/page\/\d+$/, '') + current = request.path.sub(/\/page\/\d+$/, "") + path.sub!(/\/page\/\d+$/, "") options[:class] = :current_page if current == path link_to text, path, options end def link_post button_label, link, options = {} options.reverse_merge class_name: nil, confirm: nil - render partial: 'helpers/link_post', locals: { + render partial: "helpers/link_post", locals: { button_label: button_label, link: link, class_name: options[:class_name], - confirm: options[:confirm], + confirm: options[:confirm] } end def page_numbers_for_pagination(max, cur) if max <= MAX_PAGES - return (1 .. max).to_a + return (1..max).to_a end - pages = (cur - (MAX_PAGES / 2) + 1 .. cur + (MAX_PAGES / 2) - 1).to_a + pages = (cur - (MAX_PAGES / 2) + 1..cur + (MAX_PAGES / 2) - 1).to_a while pages[0] < 1 pages.push pages.last + 1 @@ -87,12 +133,22 @@ def page_numbers_for_pagination(max, cur) pages end + def possible_flag_warning(showing_user, user) + return render partial: "users/dev_flag_warning" unless Rails.env.production? + return unless self_or_mod(showing_user, user) + + interval = time_interval("1m") + if FlaggedCommenters.new(interval[:param], 1.day).check_list_for(showing_user) + render partial: "users/flag_warning", locals: {showing_user: showing_user, interval: interval} + end + end + def tag_link(tag) link_to tag.tag, tag_path(tag), class: tag.css_class, title: tag.description end - def time_ago_in_words_label(time) - ago = time_ago_in_words(time) + def how_long_ago_label(time) + ago = how_long_ago(time) content_tag(:span, ago, title: time.strftime("%F %T %z")) end end diff --git a/benchmarks/lobsters/app/helpers/interval_helper.rb b/benchmarks/lobsters/app/helpers/interval_helper.rb index 76b6f535..65426f5f 100644 --- a/benchmarks/lobsters/app/helpers/interval_helper.rb +++ b/benchmarks/lobsters/app/helpers/interval_helper.rb @@ -1,21 +1,29 @@ +# typed: false + module IntervalHelper - TIME_INTERVALS = { "h" => "Hour", - "d" => "Day", - "w" => "Week", - "m" => "Month", - "y" => "Year", }.freeze + PLACEHOLDER = {param: "1w", dur: 1, intv: "Week", human: "week"} + TIME_INTERVALS = {"h" => "Hour", + "d" => "Day", + "w" => "Week", + "m" => "Month", + "y" => "Year"}.freeze + # security: must restrict user input to valid values def time_interval(param) if (m = param.to_s.match(/\A(\d+)([#{TIME_INTERVALS.keys.join}])\z/)) dur = m[1].to_i + return PLACEHOLDER unless dur > 0 + return PLACEHOLDER unless TIME_INTERVALS.include? m[2] + intv = TIME_INTERVALS[m[2]] { - param: param, + # recreate param with parsed values to prevent passing malicious user input + param: "#{dur}#{m[2]}", dur: dur, - intv: TIME_INTERVALS[m[2]], - human: "#{dur == 1 ? '' : dur} #{TIME_INTERVALS[m[2]]}".downcase.pluralize(dur).chomp, + intv: intv, + human: "#{(dur == 1) ? "" : dur} #{intv}".downcase.pluralize(dur).chomp } else - { input: '1w', dur: 1, intv: "Week", human: 'week' } + PLACEHOLDER end end end diff --git a/benchmarks/lobsters/app/helpers/keybase_proofs_helper.rb b/benchmarks/lobsters/app/helpers/keybase_proofs_helper.rb index 74b34cc7..7763209f 100644 --- a/benchmarks/lobsters/app/helpers/keybase_proofs_helper.rb +++ b/benchmarks/lobsters/app/helpers/keybase_proofs_helper.rb @@ -1,9 +1,11 @@ +# typed: false + module KeybaseProofsHelper def keybase_user_link(kb_sig) - File.join Keybase.BASE_URL, kb_sig[:kb_username] + File.join Rails.application.credentials.keybase.base_url, kb_sig[:kb_username] end def keybase_proof_link(kb_sig) - File.join Keybase.BASE_URL, kb_sig[:kb_username], "sigchain\##{kb_sig[:sig_hash]}" + File.join Rails.application.credentials.keybase.base_url, kb_sig[:kb_username], "sigchain##{kb_sig[:sig_hash]}" end end diff --git a/benchmarks/lobsters/app/helpers/stories_helper.rb b/benchmarks/lobsters/app/helpers/stories_helper.rb index f4a20f54..1850a50e 100644 --- a/benchmarks/lobsters/app/helpers/stories_helper.rb +++ b/benchmarks/lobsters/app/helpers/stories_helper.rb @@ -1,30 +1,24 @@ +# typed: false + module StoriesHelper - def show_guidelines? - if !@user + def show_guidelines?(user) + if !user return true end - if @user.stories_submitted_count <= 5 + if user.stories_submitted_count <= 5 return true end if Moderation.joins(:story) - .where( - "stories.user_id = ? AND moderations.created_at > ?", - @user.id, - 5.days.ago - ).exists? + .where( + "stories.user_id = ? AND moderations.created_at > ?", + user.id, + 5.days.ago + ).exists? return true end false end - - def is_unread?(comment) - if !@user || !@ribbon - return false - end - - (comment.created_at > @ribbon.updated_at) && (comment.user_id != @user.id) - end end diff --git a/benchmarks/lobsters/app/helpers/suggestions_helper.rb b/benchmarks/lobsters/app/helpers/suggestions_helper.rb new file mode 100644 index 00000000..0e358ddc --- /dev/null +++ b/benchmarks/lobsters/app/helpers/suggestions_helper.rb @@ -0,0 +1,2 @@ +module SuggestionsHelper +end diff --git a/benchmarks/lobsters/app/helpers/traffic_helper.rb b/benchmarks/lobsters/app/helpers/traffic_helper.rb index cc4d5940..539b1bce 100644 --- a/benchmarks/lobsters/app/helpers/traffic_helper.rb +++ b/benchmarks/lobsters/app/helpers/traffic_helper.rb @@ -1,3 +1,5 @@ +# typed: false + # activity: is the weighted sum of votes, comments, and stories in some time period # period: 15 minute block of time # range: high and low of periods in the last few months @@ -9,7 +11,7 @@ module TrafficHelper def self.traffic_range div = PERIOD_LENGTH * 60 start_at = 90.days.ago - result = ActiveRecord::Base.connection.execute <<-SQL + result = ActiveRecord::Base.connection.select_all <<-SQL select min(activity) as low, max(activity) as high @@ -30,15 +32,15 @@ def self.traffic_range end def self.cache_traffic! - low, high = self.traffic_range - Keystore.put('traffic:low', low) - Keystore.put('traffic:high', high) - Keystore.put('traffic:intensity', current_intensity(low, high)) + low, high = traffic_range + Keystore.put("traffic:low", low) + Keystore.put("traffic:high", high) + Keystore.put("traffic:intensity", current_intensity(low, high)) end def self.current_activity start_at = PERIOD_LENGTH.minutes.ago.utc - result = ActiveRecord::Base.connection.execute <<-SQL + result = ActiveRecord::Base.connection.select_all <<-SQL select (SELECT count(1) AS n_votes FROM votes WHERE updated_at >= '#{start_at}') + (SELECT count(1) AS n_comment FROM comments WHERE created_at >= '#{start_at}') * 10 + @@ -50,24 +52,50 @@ def self.current_activity def self.current_intensity(low, high) return 0.5 if low.nil? || high.nil? || high == low activity = [low, current_activity, high].sort[1] - [0, ((activity - low)*1.0/(high - low) * 100).round, 100].sort[1] + [0, ((activity - low) * 1.0 / (high - low) * 100).round, 100].sort[1] end def self.cached_current_intensity - Keystore.value_for('traffic:intensity') || 0.5 + Keystore.value_for("traffic:intensity") || 0.5 end - # rubocop:disable Layout/LineLength def self.novelty_logo time = Time.current + h = ActionController::Base.helpers - if time.month == 6 && time.day == 28 # Stonewall riots - return "background: linear-gradient(180deg, #FE0000 16.66%, #FD8C00 16.66%, 33.32%, #FFE500 33.32%, 49.98%, #119F0B 49.98%, 66.64%, #0644B3 66.64%, 83.3%, #C22EDC 83.3%);" + if time.month == 3 && time.day <= 7 && time.monday? + return h.content_tag(:a, + "", + href: "https://en.wikipedia.org/wiki/Casimir_Pulaski_Day", + class: "casimir", + style: " + width: 17px; + height: 32px; + padding: 1px; + margin-left: -21px; + margin-bottom: -16px; + top: 16px; + background-image: + radial-gradient(circle at 18% 63%, var(--color-bg) 15%, transparent 12.8%), + radial-gradient(circle at 23% 70%, var(--color-fg) 15%, transparent 12.8%), + radial-gradient(circle at 82% 63%, var(--color-bg) 15%, transparent 12.8%), + radial-gradient(circle at 77% 70%, var(--color-fg) 15%, transparent 12.8%), + linear-gradient(180deg, transparent 0, transparent 100%); + ") << + h.content_tag(:style, "@media only screen and (max-width: 480px) {.casimir { + background-image: + radial-gradient(circle at 18% 63%, var(--color-box-bg-shaded) 15%, transparent 12.8%), + radial-gradient(circle at 23% 70%, var(--color-fg) 15%, transparent 12.8%), + radial-gradient(circle at 82% 63%, var(--color-box-bg-shaded) 15%, transparent 12.8%), + radial-gradient(circle at 77% 70%, var(--color-fg) 15%, transparent 12.8%), + linear-gradient(180deg, transparent 0, transparent 100%) !important; +} }") + elsif time.month == 6 && time.day == 28 # Stonewall riots + return h.content_tag :style, "#logo { background: linear-gradient(180deg, #FE0000 16.66%, #FD8C00 16.66%, 33.32%, #FFE500 33.32%, 49.98%, #119F0B 49.98%, 66.64%, #0644B3 66.64%, 83.3%, #C22EDC 83.3%); }" elsif time.month == 12 && time.day == 25 # Christmas - return "background: conic-gradient(at 50% 0, #9f3631 157.5deg, #01c94f 0, #01c94f 202.5deg, #9f3631 0);" + return h.content_tag :style, "#logo { background: conic-gradient(at 50% 0, #9f3631 157.5deg, #01c94f 0, #01c94f 202.5deg, #9f3631 0); }" end nil end - # rubocop:enable Layout/LineLength end diff --git a/benchmarks/lobsters/app/helpers/users_helper.rb b/benchmarks/lobsters/app/helpers/users_helper.rb index 34dba763..5463723f 100644 --- a/benchmarks/lobsters/app/helpers/users_helper.rb +++ b/benchmarks/lobsters/app/helpers/users_helper.rb @@ -1,5 +1,11 @@ +# typed: false + module UsersHelper - def stories_submitted_content(showing_user) + def self_or_mod(showing_user, user) + user == showing_user || user.try(:is_moderator?) + end + + def stories_submitted_content(user, showing_user) tag = showing_user.most_common_story_tag stories_submitted = showing_user.stories_submitted_count @@ -7,9 +13,9 @@ def stories_submitted_content(showing_user) stories_displayed = stories_submitted - stories_deleted capture do - concat link_to(stories_displayed, "/newest/#{showing_user.username}") + concat link_to(stories_displayed, newest_by_user_path(showing_user)) - concat(" (+#{stories_deleted} deleted)") if user_is_moderator? && stories_deleted > 0 + concat(" (+#{stories_deleted} deleted)") if user&.is_moderator? && stories_deleted > 0 if tag concat ", most commonly tagged " @@ -18,21 +24,45 @@ def stories_submitted_content(showing_user) end end - def comments_posted_content(showing_user) + def comments_posted_content(user, showing_user) comments_deleted = showing_user.comments_deleted_count capture do - concat link_to(showing_user.comments_posted_count, "/threads/#{showing_user.username}") + concat link_to(showing_user.comments_posted_count, user_threads_path(showing_user)) - if user_is_moderator? && comments_deleted > 0 + if user&.is_moderator? && comments_deleted > 0 concat " (+#{comments_deleted} deleted)" end end end -private + def styled_user_link user, content = nil, html_options = {} + html_options[:class] ||= [] + if content.is_a?(Story) && content.user_is_author? + html_options[:class].push "user_is_author" + end + if content.is_a?(Comment) && content.story&.user_is_author? && content.story.user_id == user.id + html_options[:class].push "user_is_author" + end - def user_is_moderator? - @user && @user.is_moderator? + if !user.is_active? + html_options[:class].push "inactive_user" + end + if user.is_new? + html_options[:class].push "new_user" + end + html_options.delete(:class) if html_options[:class].empty? + + link_to(user.username, user, html_options) + end + + def user_karma(user) + if user.is_admin? + "(administrator)" + elsif user.is_moderator? + "(moderator)" + else + "(#{user.karma})" + end end end diff --git a/benchmarks/lobsters/app/javascript/application.js b/benchmarks/lobsters/app/javascript/application.js new file mode 100644 index 00000000..e8f788e0 --- /dev/null +++ b/benchmarks/lobsters/app/javascript/application.js @@ -0,0 +1,814 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails + +"use strict"; + +import "autosize" + +import "TomSelect" +import "TomSelect_remove_button" +import "TomSelect_caret_position" +import "TomSelect_input_autogrow" + +const csrfToken = () => { + return qS('meta[name="csrf-token"]').getAttribute('content'); +} + +function on(eventTypes, selector, callback) { + eventTypes.split(/ /).forEach( (eventType) => { + document.addEventListener(eventType, event => { + if (event.target.matches(selector)) { + callback(event); + } + }); + }); +} + +const onPageLoad = (callback) => { + document.addEventListener('DOMContentLoaded', callback); +}; + +const parentSelector = (target, selector) => { + let parent = target; + while (!parent.matches(selector)) { + parent = parent.parentElement; + if (parent === null) { + throw new Error(`Did not match a parent of ${target} with the selector ${selector}`); + } + } + return parent; +}; + +const qS = (context, selector) => { + if (selector === undefined) { + selector = context; + context = document; + } + return context.querySelector(selector); +}; + +const qSA = (context, selector) => { + if (selector === undefined) { + selector = context; + context = document; + } + return context.querySelectorAll(selector); +}; + +const replace = (oldElement, newHTMLString) => { + const placeHolder = document.createElement('div'); + placeHolder.insertAdjacentHTML('afterBegin', newHTMLString); + const newElements = placeHolder.childNodes.values(); + oldElement.replaceWith(...newElements); + removeExtraInputs(); +} + +const slideDownJS = (element) => { + if (element.classList.contains('slide-down')) + return; + + element.classList.add('slide-down'); + const cs = getComputedStyle(element); + const paddingHeight = parseInt(cs.paddingTop) + parseInt(cs.paddingBottom); + const height = (element.clientHeight - paddingHeight) + 'px'; + element.style.height = '0px'; + setTimeout(() => { element.style.height = height; }, 0); +}; + +const fetchWithCSRF = (url, params) => { + params = params || {}; + params['headers'] = params['headers'] || new Headers; + params['headers'].append('X-CSRF-Token', csrfToken()); + params['headers'].append('X-Requested-With', 'XMLHttpRequest'); // request.xhr? + return fetch(url, params); +} + +const removeExtraInputs = () => { + // This deletion will resolve a bug that creates an extra hidden input when rendering the comment elements + const extraInputs = qSA('.comment_folder_button + .comment_folder_button'); + for (const i of extraInputs) { + i.remove(); + } +} + +class _LobstersFunction { + constructor (username) { + this.curUser = null; + + this.storyFlagReasons = JSON.parse(qS('meta[name="story-flags"]').getAttribute('content')); + + this.commentFlagReasons = JSON.parse(qS('meta[name="comment-flags"]').getAttribute('content')); + } + + bounceToLogin() { + document.location = "/login?return=" + encodeURIComponent(document.location); + } + + modalFlaggingDropDown(flaggedItemType, voterEl, reasons) { + if (!Lobster.curUser) return Lobster.bounceToLogin(); + + const li = parentSelector(voterEl, '.story, .comment'); + if (li.classList.contains('flagged')) { + /* already upvoted, neutralize */ + if (li.classList.contains('story')) { + Lobster.voteStory(voterEl, -1, null); + } else { + Lobster.voteComment(voterEl, -1, null); + } + return + } + + if (qS('#flag_dropdown') || qS('#modal_backdrop')) { + Lobster.removeFlagModal() + } + + const modalDiv = document.createElement("div"); + modalDiv.setAttribute('id', 'modal_backdrop'); + document.body.appendChild(modalDiv); + + const flaggingDropDown = document.createElement('div'); + flaggingDropDown.setAttribute('id', 'flag_dropdown'); + voterEl.after(flaggingDropDown); + + Object.keys(reasons).map(function(k, v) { + let a = document.createElement('a') + a.textContent = reasons[k] + a.setAttribute('data', k) + a.setAttribute('href', '#') + if (k === '') { + a.classList.add('cancel-reason') + } + flaggingDropDown.append(a); + }); + } + + checkStoryDuplicate(form) { + const formData = new FormData(form); + const action = '/stories/check_url_dupe'; + fetchWithCSRF(action, { + method: 'post', + headers: new Headers({'X-Requested-With': 'XMLHttpRequest'}), + body: formData, + }).then (response => { + response.text().then(text => { + // FIXME on second click of 'fetch title', this doesn't run + qS('.form_errors_header').innerHTML = text; + }); + }); + } + + checkStoryTitle() { + const titleLocation = qS('#story_title'); + if (!titleLocation) return; + + const title = titleLocation.value; + if (!title) return; + + // Check for common prefixes like "ask lobsters:", remove it, and add the appropriate tag + const m = title.match(/^(show|ask) lobste\.?rs:? (.+)$/i); + if (m) { + const titleEl = qS('#story_title'); + Lobster.tom.addItem(m[1].toLowerCase()); + titleEl.value = m[2]; + } + + // common separators or (parens) that don't enclose a 4-digit year + if (title.match(/: | - | – | — | \| | · | • | by /) || + (title.match(/\([^\)]*\)/g) || []).some(function (p) { return !p.match(/\(\d{4}\)/) })) { + slideDownJS(qS('.title-reminder')); + + // else if the title doesn't contain concerns and reminder is visible + } else if (qS('.title-reminder').classList.contains('slide-down')) { + qS('.title-reminder-thanks').style.display = 'inline'; + } + } + + fetchURLTitle(button) { + const url_field = qS('#story_url'); + const targetUrl = url_field.value; + const title_field = qS('#story_title'); + const formData = new FormData(); + const old_text = button.textContent; + + if (targetUrl == "") + return; + + button.setAttribute("disabled", true); + button.textContent = "Fetching..."; + formData.append('fetch_url', targetUrl); + + fetchWithCSRF('/stories/fetch_url_attributes', { + method: 'post', + headers: new Headers({'X-Requested-With': 'XMLHttpRequest'}), + body: formData,}) + .then (response => response.json()) + .then (data => { + title_field.value = data.title + if (url_field.value != data.url) { + slideDownJS(qS('.url-updated')); + } + url_field.value = data.url + button.textContent = old_text + }); + button.removeAttribute("disabled"); + Lobster.checkStoryTitle(); + } + + hideStory(hiderEl) { + if (!Lobster.curUser) return Lobster.bounceToLogin(); + + const li = parentSelector(hiderEl, ".story, .comment"); + let act; + if (li.classList.contains("hidden")) { + act = "unhide"; + li.classList.remove("hidden"); + hiderEl.innerHTML = "hide"; + } else { + act = "hide"; + li.classList.add("hidden"); + hiderEl.innerHTML = "unhide"; + } + fetchWithCSRF("/stories/" + li.getAttribute("data-shortid") + "/" + act, {method: 'post'}); + } + + removeFlagModal() { + qS('#flag_dropdown').remove(); + qS('#modal_backdrop').remove(); + } + + postComment(form) { + const formData = new FormData(form); + const action = form.getAttribute('action'); + formData.append('show_tree_lines', true); + fetchWithCSRF (action, { + method: 'post', + headers: new Headers({'X-Requested-With': 'XMLHttpRequest'}), + body: formData + }) + .then(response => { + response.text().then(text => replace(form.parentElement, text)); + }) + } + + previewComment(form) { + const formData = new FormData(form); + const action = form.getAttribute('action'); + formData.append('preview', 'true'); + formData.append('show_tree_lines', 'true'); + fetchWithCSRF(action, { + method: 'post', + headers: new Headers({'X-Requested-With': 'XMLHttpRequest'}), + body: formData + }) + .then(response => { + response.text().then(text => { + replace(form.parentElement, text); + autosize(qSA('textarea')); + }); + }); + } + + previewStory(formElement) { + if (!Lobster.curUser) return Lobster.bounceToLogin(); + + const formData = new FormData(formElement); + const previewElement = qS('#inside'); + fetchWithCSRF('/stories/preview', { + method: 'post', + headers: new Headers({'X-Requested-With': 'XMLHttpRequest'}), + body: formData + }).then(response => { + response.text().then(text => { + previewElement.innerHTML = text; + Lobster.tomSelect(); + }); + }); + } + + saveStory(saverEl) { + if (!Lobster.curUser) return Lobster.bounceToLogin(); + + const li = parentSelector(saverEl, ".story, .comment"); + let act; + + if (li.classList.contains("saved")) { + act = "unsave"; + li.classList.remove("saved"); + saverEl.innerHTML = "save"; + } else { + act = "save"; + li.classList.add("saved"); + saverEl.innerHTML = "unsave"; + } + fetchWithCSRF("/stories/" + li.getAttribute("data-shortid") + "/" + act, {method: 'post'}); + } + + tomSelect(item) { + if (!qS('#story_tags_a')) { + return + } + + TomSelect.define('caret_position', caret_position); + TomSelect.define('input_autogrow', input_autogrow); + TomSelect.define('remove_button', remove_button); + this.tom = new TomSelect('#story_tags_a', { + plugins: ['caret_position', 'input_autogrow', 'remove_button'], + maxOptions: 200, + maxItems: 10, + hideSelected: true, + closeAfterSelect: true, + selectOnTab: true, + sortField: {field: "data-value"}, + onInitialize: function() { + const parent = qS('.ts-control'); + parent.appendChild(qS('.ts-dropdown')); + }, + render: { + option: function(data) { + return '
' + + '' + data.title + '' + + '
'; + }, + item: function(data) { + return '' + data.value + ''; + } + } + }); + } + + upvoteComment(voterEl) { + Lobster.voteComment(voterEl, 1); + } + + upvoteStory(voterEl) { + Lobster.voteStory(voterEl, 1); + } + + voteStory(voterEl, point, reason) { + if (!Lobster.curUser) return Lobster.bounceToLogin(); + + const li = parentSelector(voterEl, '.story'); + const scoreDiv = qS(li, 'div.score'); + const formData = new FormData(); + formData.append('reason', reason || ''); + let showScore = true; + let score = parseInt(scoreDiv.innerHTML); + let action = ""; + + if (isNaN(score)) { + showScore = false; + score = 0; + } + + if (li.classList.contains("upvoted") && point > 0) { + /* already upvoted, neutralize */ + li.classList.remove("upvoted"); + score--; + action = "unvote"; + } else if (li.classList.contains("flagged") && point < 0) { + /* already flagged, neutralize */ + li.classList.remove("flagged"); + score++; + action = "unvote"; + } else if (point > 0) { + if (li.classList.contains("flagged")) { + /* Give back the lost flagged point */ + score++; + } + li.classList.remove("flagged"); + li.classList.add("upvoted"); + score++; + action = "upvote"; + } else if (point < 0) { + if (li.classList.contains("upvoted")) { + /* Removes the upvote point this user already gave the story*/ + score--; + } + li.classList.remove("upvoted"); + li.classList.add("flagged"); + if (qS(li.parentElement, '.comment_folder_button')) { + qS(li.parentElement, '.comment_folder_button').setAttribute('checked', true); + }; + showScore = false; + score--; + action = "flag"; + } + if (showScore) { + scoreDiv.innerHTML = score; + } else { + scoreDiv.innerHTML = '~'; + } + if (action == "upvote" || action == "unvote") { + if (qS(li, '.reason')) { + qS(li, '.reason').innerHTML = ''; + }; + + if (action == "unvote" && point < 0) + qS(li, '.flagger').textContent = 'flag'; + } else if (action == "flag") { + qS(li, '.flagger').textContent = 'unflag'; + } + + fetchWithCSRF("/stories/" + li.getAttribute("data-shortid") + "/" + action, { + method: 'post', + body: formData }); + } + + voteComment(voterEl, point, reason) { + if (!Lobster.curUser) return Lobster.bounceToLogin(); + + const li = parentSelector(voterEl, ".comment"); + const scoreDiv = qS(li, 'div.score'); + const formData = new FormData(); + formData.append('reason', reason || ''); + let showScore = true; + let score = parseInt(scoreDiv.innerHTML); + let action = ""; + + if (isNaN(score)) { + showScore = false; + score = 0; + } + + if (li.classList.contains("upvoted") && point > 0) { + /* already upvoted, neutralize */ + li.classList.remove("upvoted"); + score--; + action = "unvote"; + } else if (li.classList.contains("flagged") && point < 0) { + /* already flagged, neutralize */ + li.classList.remove("flagged"); + score++; + action = "unvote"; + } else if (point > 0) { + if (li.classList.contains("flagged")) { + /* Give back the lost flagged point */ + score++; + } + li.classList.remove("flagged"); + li.classList.add("upvoted"); + score++; + action = "upvote"; + } else if (point < 0) { + if (li.classList.contains("upvoted")) { + /* Removes the upvote point this user already gave the story*/ + score--; + } + li.classList.remove("upvoted"); + li.classList.add("flagged"); + li.parentElement.querySelector('.comment_folder_button').setAttribute("checked", true); + showScore = false; + score--; + action = "flag"; + } + if (showScore) { + scoreDiv.innerHTML = score; + } else { + scoreDiv.innerHTML = '~'; + } + + if (action == "upvote" || action == "unvote") { + qS(li, '.reason').innerHTML = ''; + } + + if (action == "unvote" && point < 0) { + qS(li, '.flagger').textContent = 'flag'; + } else if (action == "flag") { + qS(li, '.flagger').textContent = 'unflag'; + qS(li, '.reason').innerHTML = "| " + Lobster.commentFlagReasons[reason].toLowerCase(); + } + + fetchWithCSRF("/comments/" + li.getAttribute("data-shortid") + "/" + action, { + method: 'post', + body: formData }); + } +} + +const Lobster = new _LobstersFunction(); + +onPageLoad(() => { + Lobster.curUser = document.body.getAttribute('data-username'); // hack + autosize(qSA('textarea')); + + // replace csrf token in forms that may be fragment caches with page token + for (const i of qSA('form input[name="authenticity_token"]')) { + i.value = csrfToken(); + } + + // Global + + on('click', '.markdown_help_label', (event) => { + qS(parentSelector(event.target, '.markdown_help_toggler'), '.markdown_help').classList.toggle('display-block'); + }); + + on('click', '#modal_backdrop', () => { + Lobster.removeFlagModal() + }); + + on('click', '[data-confirm]', (event) => { + if (!confirm(event.target.dataset.confirm)) { + event.preventDefault(); + } + }); + + // Account Settings + + on('focusout', '#user_homepage', (event) => { + const homePage = event.target + if (homePage.value.trim() !== '' && !homePage.value.match('^[a-z]+:\/\/')) + homePage.value = 'https://' + homePage.value + }); + + // Inbox + + on('change', '#message_hat_id', (event) => { + let selectedOption = event.target.selectedOptions[0]; + qS('#message_mod_note').checked = (selectedOption.getAttribute('data-modnote') === 'true'); + }); + + // Story + + Lobster.checkStoryTitle() + + Lobster.tomSelect(); + + if (qS('#story_url') && qS('#story_preview') && !qS('#story_preview').firstElementChild) { + qS('#story_url').focus() + } + + on('change', '#story_title', Lobster.checkStoryTitle); + + on('click', '.story #flag_dropdown a', (event) => { + event.preventDefault(); + if (event.target.getAttribute('data') != '') { + Lobster.voteStory(parentSelector(event.target, '.story'), -1, event.target.getAttribute('data')); + } + Lobster.removeFlagModal(); + }); + + on('click', '#story_fetch_title', (event) => { + Lobster.fetchURLTitle(event.target); + }); + + on('click', 'li.story a.upvoter', (event) => { + event.preventDefault(); + Lobster.upvoteStory(event.target); + }); + + on('click', 'li.story a.flagger', (event) => { + event.preventDefault(); + const reasons = Lobster.storyFlagReasons; + Lobster.modalFlaggingDropDown("story", event.target, reasons); + }); + + on('click', 'li.story a.hider', (event) => { + event.preventDefault(); + Lobster.hideStory(event.target); + }); + + on('click', 'li.story a.saver', (event) => { + event.preventDefault(); + Lobster.saveStory(event.target); + }); + + on('click', 'button.story-preview', (event) => { + Lobster.previewStory(parentSelector(event.target, 'form')); + }); + + on('focusout', '#story_url', () => { + let url_tags = { + "\.pdf$": "pdf", + "[\/\.](asciinema\.org|(youtube|vimeo)\.com|youtu\.be|twitch.tv)\/": "video", + "[\/\.](slideshare\.net|speakerdeck\.com)\/": "slides", + "[\/\.](soundcloud\.com)\/": "audio", + }; + + const storyUrlEl = qS('#story_url'); + for (const [match, tag] of Object.entries(url_tags)) { + if (storyUrlEl.value.match(new RegExp(match, "i"))) { + Lobster.tom.addItem(tag.toLowerCase()); + } + } + + // check for dupe if there's a URL, but not when editing existing + if (storyUrlEl.value !== "" && + (!qS('input[name="_method"]') || + qS('input[name="_method"]').getAttribute('value') === 'put')) { + Lobster.checkStoryDuplicate(parentSelector(storyUrlEl, 'form')); + } + }); + + // Disown + + on('submit', 'form.disowner-form', (event) => { + event.preventDefault(); + + let type = event.target.elements['type'].value; + + if (confirm(`Are you sure you want to disown this ${type}?`)) { + let li = parentSelector(event.target, `.${type}`); + + fetchWithCSRF(event.target.action, { method: 'post', body: new FormData(event.target) }) + .then(response => { + response.text().then(text => replace(li, text)); + }); + } + }); + + // Comment + + // Remember story collapses; this is stored in localStorage for every story ID as an object + on('change', '.comment_folder_button', (e) => { + const commentId = e.target.getAttribute('data-shortid'); + const storyId = qS('.story')?.getAttribute('data-shortid'); + if (!storyId) return; // only remember or read these on story pages + + var collapse = JSON.parse(localStorage.getItem("collapse_" + storyId) || '{}'); + if (e.target.checked) { + collapse[commentId] = 1; // value unused, just truthy and short to serialize + } else { + delete collapse[commentId]; + } + localStorage.setItem("collapse_" + storyId, JSON.stringify(collapse)); + }); + + // Collapse stories on load; the actual hiding is done in CSS; just need to switch the checkbox + (function() { + const storyId = qS('.story')?.getAttribute('data-shortid'); + if (!storyId) return; // only remember or read these on story pages + const collapse = JSON.parse(localStorage.getItem("collapse_" + storyId) || '{}'); + + for (var k in collapse) { + var folder = qS('input#comment_folder_' + k); + // comment may have been deleted + if (folder) + folder.checked = true; + } + })(); + + on("click", "a.comment_replier", (event) => { + event.preventDefault(); + if (!Lobster.curUser) return Lobster.bounceToLogin(); + + const comment = parentSelector(event.target, '.comment'); + const commentId = comment.getAttribute('id'); + + // guard: don't create multiple reply boxes to one comment + if (qS('#reply_form_' + commentId)) { return false; } + + // Inserts "> " on quoted text + let sel = document.getSelection().toString(); + if (sel != "") { + sel = sel.split("\n").map(s => "> " + s + '\n\n').join(''); + sel += "\n"; + } + + let div = document.createElement('div'); + div.innerHTML = ''; + comment.lastElementChild.append(div); + + fetchWithCSRF('/comments/' + comment.getAttribute('data-shortid') + '/reply') + .then(response => { + response.text().then(text => { + // guard: don't create multiple reply boxes to one comment + if (qS('#reply_form_' + commentId)) { return false; } + + div.innerHTML = text; + div.setAttribute('id', 'reply_form_' + commentId); + + var ta = qS(div, 'textarea'); + ta.textContent = sel; + // place the cursor at the end of the quoted string + ta.setSelectionRange(sel.length, sel.length); + ta.focus(); + autosize(ta); + }) + }); + }); + + on('click', '.comment a.flagger', (event) => { + event.preventDefault(); + const reasons = Lobster.commentFlagReasons + Lobster.modalFlaggingDropDown("comment", event.target, reasons); + }); + + on('click', '.comment #flag_dropdown a', (event) => { + event.preventDefault(); + if (event.target.getAttribute('data') != '') { + Lobster.voteComment(parentSelector(event.target, '.comment'), -1, event.target.getAttribute('data')); + } + Lobster.removeFlagModal() + }); + + on("click", '.comment a.upvoter', (event) => { + event.preventDefault(); + Lobster.upvoteComment(event.target); + }); + + on('click', 'button.comment-preview', (event) => { + Lobster.previewComment(parentSelector(event.target, 'form')); + }); + + on('submit', '.comment_form_container form', (event) => { + event.preventDefault(); + Lobster.postComment(event.target); + }); + + on('keydown', 'textarea#comment', (event) => { + if ((event.metaKey || event.ctrlKey) && event.keyCode == 13) { + Lobster.postComment(parentSelector(event.target, 'form')); + } + }); + + on('click', 'button.comment-cancel', (event) => { + const comment = (parentSelector(event.target, '.comment')); + const commentId = comment.getAttribute('data-shortid'); + if (commentId !== null && commentId !== '') { + fetch('/comments/' + commentId + '?show_tree_lines=true') + .then(response => { + response.text().then(text => replace(comment, text)); + }); + } else { + comment.parentElement.remove(); + } + }); + + on('click', 'a.comment_editor', (event) => { + let comment = parentSelector(event.target, '.comment'); + const commentId = comment.getAttribute('data-shortid') + fetch('/comments/' + commentId + '/edit') + .then(response => { + response.text().then(text => { + replace(comment, text); + autosize(qSA('textarea')); + }); + }); + autosize(qSA('textarea')); + }); + + on("click", "a.comment_deletor", (event) => { + event.preventDefault(); + if (confirm("Are you sure you want to delete this comment?")) { + const comment = parentSelector(event.target, '.comment'); + const commentId = comment.getAttribute('data-shortid'); + fetchWithCSRF('/comments/' + commentId + '/delete',{method: 'post'}) + .then(response => { + response.text().then(text => replace(comment, text)); + }); + } + }); + + on('click', 'a.comment_undeletor', (event) => { + event.preventDefault(); + if (confirm("Are you sure you want to undelete this comment?")) { + const comment = parentSelector(event.target, '.comment'); + const commentId = comment.getAttribute('data-shortid'); + fetchWithCSRF('/comments/' + commentId + '/undelete', {method: 'post'}) + .then(response => { + response.text().then(text => replace(comment, text)); + }); + } + }); + + on('click', 'a.comment_moderator', (event) => { + const reason = prompt("Moderation reason:"); + if (reason == null || reason == '') + return false; + + const formData = new FormData(); + formData.append('reason', reason); + const comment = parentSelector(event.target, '.comment'); + const commentId = comment.getAttribute('data-shortid'); + fetchWithCSRF('/comments/' + commentId + '/delete', { method: 'post', body: formData }) + .then(response => { + response.text().then(text => replace(comment, text)); + }); + }); + + on('click', '.comment_unread', (event) => { + const nodes = qSA('.comment_unread') + const foundIndex = Array.from(nodes).findIndex(node => node === event.target) + const targetIndex = (foundIndex + 1) % nodes.length; + const targetY = nodes[targetIndex].getBoundingClientRect().top + window.scrollY + const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + window.scrollTo({ top: targetY, behavior: reducedMotion ? 'instant' : 'smooth' }) + }); + + // Private messages + + // inject js-only UI + const select_all = qSA('.with_select_all tr:first-child th:first-child'); + for (const th of select_all) { + const checkbox = document.createElement('input'); + checkbox.setAttribute('type', 'checkbox'); + checkbox.classList.add('select_all'); + th.append(checkbox); + } + + on('click', '.select_all', (event) => { + const table = parentSelector(event.target, 'table'); + const checkboxes = qSA(table, 'input[type=checkbox]'); + for (const checkbox of checkboxes) { + checkbox.checked = event.target.checked; + } + }); +}); diff --git a/benchmarks/lobsters/app/jobs/application_job.rb b/benchmarks/lobsters/app/jobs/application_job.rb new file mode 100644 index 00000000..d394c3d1 --- /dev/null +++ b/benchmarks/lobsters/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/benchmarks/lobsters/app/jobs/expire_old_ribbons_job.rb b/benchmarks/lobsters/app/jobs/expire_old_ribbons_job.rb new file mode 100644 index 00000000..79567176 --- /dev/null +++ b/benchmarks/lobsters/app/jobs/expire_old_ribbons_job.rb @@ -0,0 +1,7 @@ +class ExpireOldRibbonsJob < ApplicationJob + queue_as :default + + def perform(*args) + ReadRibbon.expire_old_ribbons! + end +end diff --git a/benchmarks/lobsters/app/jobs/populate_comment_stats_job.rb b/benchmarks/lobsters/app/jobs/populate_comment_stats_job.rb new file mode 100644 index 00000000..e840d333 --- /dev/null +++ b/benchmarks/lobsters/app/jobs/populate_comment_stats_job.rb @@ -0,0 +1,7 @@ +class PopulateCommentStatsJob < ApplicationJob + queue_as :default + + def perform(*args) + CommentStat.daily_fill! + end +end diff --git a/benchmarks/lobsters/app/mailboxes/application_mailbox.rb b/benchmarks/lobsters/app/mailboxes/application_mailbox.rb new file mode 100644 index 00000000..a0260c5f --- /dev/null +++ b/benchmarks/lobsters/app/mailboxes/application_mailbox.rb @@ -0,0 +1,6 @@ +# typed: false + +class ApplicationMailbox < ActionMailbox::Base + # routing /something/i => :somewhere + routing(/^#{Rails.application.shortname}-/ => :inbox) +end diff --git a/benchmarks/lobsters/app/mailboxes/inbox_mailbox.rb b/benchmarks/lobsters/app/mailboxes/inbox_mailbox.rb new file mode 100644 index 00000000..7765f02f --- /dev/null +++ b/benchmarks/lobsters/app/mailboxes/inbox_mailbox.rb @@ -0,0 +1,59 @@ +# typed: false + +class InboxMailbox < ApplicationMailbox + before_processing :required_info + + def process + c = Comment.new(user: sending_user, comment: mail.decoded, is_from_email: true) + if parent.is_a?(Comment) + c.story_id = parent.story_id + c.parent_comment_id = parent.id + else + c.story_id = parent.id + end + + # If we fail to parse the comment, just throw an exception. + c.save! + end + + private + + def required_info + if mail.decoded == "" || sending_user.nil? || parent.nil? + # We could email the user about the bounce, but that doesn't seem + # to align with current behaviour. + bounced! + end + end + + def parent + return @parent if @parent + + # Emails In-Reply-To formatted like + # story..@ + # We throw away the timestamp, since we only care about the short ID. + irt = mail.in_reply_to.to_s.gsub(/[^A-Za-z0-9@\.]/, "") + + if (m = irt.match(/^comment\.([^\.]+)\.\d+@/)) + @parent = Comment.where(short_id: m[1]).first + elsif (m = irt.match(/^story\.([^\.]+)\.\d+@/)) + @parent = Story.where(short_id: m[1]).first + end + + @parent + end + + def user_token + (mail.to.find { |e| e =~ /^#{Rails.application.shortname}-/ } || "")[/-([^@]*)@/, 1] + end + + def sending_user + return @sending_user if @sending_user + + if (user = User.find_by(mailing_list_token: user_token)) && + user.is_active? + @sending_user = user + user + end + end +end diff --git a/benchmarks/lobsters/app/mailers/application_mailer.rb b/benchmarks/lobsters/app/mailers/application_mailer.rb index 7a574000..bf2f2add 100644 --- a/benchmarks/lobsters/app/mailers/application_mailer.rb +++ b/benchmarks/lobsters/app/mailers/application_mailer.rb @@ -1,3 +1,5 @@ +# typed: false + class ApplicationMailer < ActionMailer::Base - default :from => "#{Rails.application.name} " + default from: "#{Rails.application.name} " end diff --git a/benchmarks/lobsters/app/mailers/ban_notification.rb b/benchmarks/lobsters/app/mailers/ban_notification.rb deleted file mode 100644 index c31974fe..00000000 --- a/benchmarks/lobsters/app/mailers/ban_notification.rb +++ /dev/null @@ -1,13 +0,0 @@ -class BanNotification < ApplicationMailer - def notify(user, banner, reason) - @banner = banner - @reason = reason - - mail( - :from => "#{@banner.username} ", - :replyto => "#{@banner.username} <#{@banner.email}>", - :to => user.email, - :subject => "[#{Rails.application.name}] You have been banned" - ) - end -end diff --git a/benchmarks/lobsters/app/mailers/ban_notification_mailer.rb b/benchmarks/lobsters/app/mailers/ban_notification_mailer.rb new file mode 100644 index 00000000..e35fbc34 --- /dev/null +++ b/benchmarks/lobsters/app/mailers/ban_notification_mailer.rb @@ -0,0 +1,15 @@ +# typed: false + +class BanNotificationMailer < ApplicationMailer + def notify(user, banner, reason) + @banner = banner + @reason = reason + + mail( + from: "#{@banner.username} ", + replyto: "#{@banner.username} <#{@banner.email}>", + to: user.email, + subject: "[#{Rails.application.name}] You have been banned" + ) + end +end diff --git a/benchmarks/lobsters/app/mailers/email_message.rb b/benchmarks/lobsters/app/mailers/email_message_mailer.rb similarity index 51% rename from benchmarks/lobsters/app/mailers/email_message.rb rename to benchmarks/lobsters/app/mailers/email_message_mailer.rb index 566274b8..b0a19db8 100644 --- a/benchmarks/lobsters/app/mailers/email_message.rb +++ b/benchmarks/lobsters/app/mailers/email_message_mailer.rb @@ -1,11 +1,13 @@ -class EmailMessage < ApplicationMailer +# typed: false + +class EmailMessageMailer < ApplicationMailer def notify(message, user) @message = message @user = user mail( - :to => user.email, - :subject => "[#{Rails.application.name}] Private Message from " << + to: user.email, + subject: "[#{Rails.application.name}] Private Message from " \ "#{message.author_username}: #{message.subject}" ) end diff --git a/benchmarks/lobsters/app/mailers/email_reply.rb b/benchmarks/lobsters/app/mailers/email_reply.rb deleted file mode 100644 index 3622b09d..00000000 --- a/benchmarks/lobsters/app/mailers/email_reply.rb +++ /dev/null @@ -1,23 +0,0 @@ -class EmailReply < ApplicationMailer - def reply(comment, user) - @comment = comment - @user = user - - mail( - :to => user.email, - :subject => "[#{Rails.application.name}] Reply from " << - "#{comment.user.username} on #{comment.story.title}" - ) - end - - def mention(comment, user) - @comment = comment - @user = user - - mail( - :to => user.email, - :subject => "[#{Rails.application.name}] Mention from " << - "#{comment.user.username} on #{comment.story.title}" - ) - end -end diff --git a/benchmarks/lobsters/app/mailers/email_reply_mailer.rb b/benchmarks/lobsters/app/mailers/email_reply_mailer.rb new file mode 100644 index 00000000..5008a371 --- /dev/null +++ b/benchmarks/lobsters/app/mailers/email_reply_mailer.rb @@ -0,0 +1,50 @@ +# typed: false + +class EmailReplyMailer < ApplicationMailer + def reply(comment, user) + @comment = comment + @user = user + + @replied_to = "you" + if @comment.parent_comment.nil? + @replied_to = "your story" + elsif @comment.parent_comment.user != @user + @replied_to = @comment.parent_comment.user.username + end + + # threading + set_headers + + mail( + to: user.email, + subject: "[#{Rails.application.name}] Reply from " \ + "#{comment.user.username} on #{comment.story.title}" + ) + end + + def mention(comment, user) + @comment = comment + @user = user + + set_headers + + mail( + to: user.email, + subject: "[#{Rails.application.name}] Mention from " \ + "#{comment.user.username} on #{comment.story.title}" + ) + end + + private + + def set_headers + headers "Message-Id" => @comment.mailing_list_message_id, + "References" => ( + ([@comment.story.mailing_list_message_id] + @comment.parents.map(&:mailing_list_message_id)) + .map { |r| "<#{r}>" } + ), + "In-Reply-To" => @comment.parent_comment.present? ? + @comment.parent_comment.mailing_list_message_id : + @comment.story.mailing_list_message_id + end +end diff --git a/benchmarks/lobsters/app/mailers/invitation_mailer.rb b/benchmarks/lobsters/app/mailers/invitation_mailer.rb index a583cd7c..3e344d0a 100644 --- a/benchmarks/lobsters/app/mailers/invitation_mailer.rb +++ b/benchmarks/lobsters/app/mailers/invitation_mailer.rb @@ -1,3 +1,5 @@ +# typed: false + class InvitationMailer < ApplicationMailer def invitation(invitation) @invitation = invitation diff --git a/benchmarks/lobsters/app/mailers/invitation_request_mailer.rb b/benchmarks/lobsters/app/mailers/invitation_request_mailer.rb index 27e18147..da6ac118 100644 --- a/benchmarks/lobsters/app/mailers/invitation_request_mailer.rb +++ b/benchmarks/lobsters/app/mailers/invitation_request_mailer.rb @@ -1,10 +1,12 @@ +# typed: false + class InvitationRequestMailer < ApplicationMailer def invitation_request(invitation_request) @invitation_request = invitation_request mail( to: invitation_request.email, - subject: "[#{Rails.application.name}] Confirm your invitation " << + subject: "[#{Rails.application.name}] Confirm your invitation " \ "request to " << Rails.application.name ) end diff --git a/benchmarks/lobsters/app/mailers/password_reset.rb b/benchmarks/lobsters/app/mailers/password_reset.rb deleted file mode 100644 index 750804f7..00000000 --- a/benchmarks/lobsters/app/mailers/password_reset.rb +++ /dev/null @@ -1,11 +0,0 @@ -class PasswordReset < ApplicationMailer - def password_reset_link(user, ip) - @user = user - @ip = ip - - mail( - :to => user.email, - :subject => "[#{Rails.application.name}] Reset your password" - ) - end -end diff --git a/benchmarks/lobsters/app/mailers/password_reset_mailer.rb b/benchmarks/lobsters/app/mailers/password_reset_mailer.rb new file mode 100644 index 00000000..03041699 --- /dev/null +++ b/benchmarks/lobsters/app/mailers/password_reset_mailer.rb @@ -0,0 +1,13 @@ +# typed: false + +class PasswordResetMailer < ApplicationMailer + def password_reset_link(user, ip) + @user = user + @ip = ip + + mail( + to: user.email, + subject: "[#{Rails.application.name}] Reset your password" + ) + end +end diff --git a/benchmarks/lobsters/app/models/application_record.rb b/benchmarks/lobsters/app/models/application_record.rb index c3877798..64e947f5 100644 --- a/benchmarks/lobsters/app/models/application_record.rb +++ b/benchmarks/lobsters/app/models/application_record.rb @@ -1,6 +1,8 @@ +# typed: false + class ApplicationRecord < ActiveRecord::Base self.abstract_class = true # https://stackoverflow.com/questions/50026344/composing-activerecord-scopes-with-selects - scope :select_fix, -> { select(self.arel_table.project(Arel.star)) } + scope :select_fix, -> { select(arel_table.project(Arel.star)) } end diff --git a/benchmarks/lobsters/app/models/category.rb b/benchmarks/lobsters/app/models/category.rb index 7637d759..8d3bccbd 100644 --- a/benchmarks/lobsters/app/models/category.rb +++ b/benchmarks/lobsters/app/models/category.rb @@ -1,33 +1,35 @@ +# typed: false + class Category < ApplicationRecord has_many :tags, - -> { order('tag asc') }, - dependent: :restrict_with_error, - inverse_of: :category + -> { order("tag asc") }, + dependent: :restrict_with_error, + inverse_of: :category has_many :stories, through: :tags after_save :log_modifications attr_accessor :edit_user_id - validates :category, length: { maximum: 25 }, presence: true, - uniqueness: { case_sensitive: false }, - format: { with: /\A[A-Za-z0-9_\-]+\z/ } + validates :category, length: {maximum: 25}, presence: true, + uniqueness: {case_sensitive: false}, + format: {with: /\A[A-Za-z0-9_\-]+\z/} def to_param - self.category + category end def log_modifications Moderation.create do |m| - if self.id_previously_changed? - m.action = 'Created new category ' + - self.attributes.map {|f, c| "with #{f} '#{c}'" }.join(', ') + m.action = if id_previously_changed? + "Created new category " + + attributes.map { |f, c| "with #{f} '#{c}'" }.join(", ") else - m.action = "Updating category #{self.category}, " + self.saved_changes - .map {|f, c| "changed #{f} from '#{c[0]}' to '#{c[1]}'" } .join(', ') + "Updating category #{category}, " + saved_changes + .map { |f, c| "changed #{f} from '#{c[0]}' to '#{c[1]}'" }.join(", ") end m.moderator_user_id = @edit_user_id - m.category_id = self.id + m.category_id = id end end end diff --git a/benchmarks/lobsters/app/models/comment.rb b/benchmarks/lobsters/app/models/comment.rb index 19182738..bdb6f6d7 100644 --- a/benchmarks/lobsters/app/models/comment.rb +++ b/benchmarks/lobsters/app/models/comment.rb @@ -1,45 +1,71 @@ -require 'set' +# typed: false class Comment < ApplicationRecord belongs_to :user belongs_to :story, - :inverse_of => :comments + inverse_of: :comments + # has_one :comment_stat, -> { where("date(created_at)", } has_many :votes, - :dependent => :delete_all + dependent: :delete_all belongs_to :parent_comment, - :class_name => "Comment", - :inverse_of => false, - :optional => true + class_name: "Comment", + inverse_of: false, + optional: true has_one :moderation, - :class_name => "Moderation", - :inverse_of => :comment, - :dependent => :destroy + class_name: "Moderation", + inverse_of: :comment, + dependent: :destroy belongs_to :hat, - :optional => true + optional: true has_many :taggings, through: :story - - attr_accessor :current_vote, :previewing, :indent_level - - before_validation :on => :create do - self.assign_short_id_and_score - self.assign_initial_confidence - self.assign_thread_id - end - after_create :record_initial_upvote, :mark_submitter, :deliver_reply_notifications, - :deliver_mention_notifications, :log_hat_use + has_many :links, inverse_of: :from_comment, dependent: :destroy + has_many :incoming_links, + class_name: "Link", + inverse_of: :to_comment, + dependent: :destroy + + attr_accessor :current_vote, :previewing, :vote_summary + attribute :depth, :integer + attribute :reply_count, :integer + + before_validation on: :create do + assign_short_id_and_score + assign_initial_confidence + assign_thread_id + end + after_create :record_initial_upvote, :mark_submitter, :deliver_notifications, + :log_hat_use after_destroy :unassign_votes + after_save :recreate_links scope :deleted, -> { where(is_deleted: true) } scope :not_deleted, -> { where(is_deleted: false) } scope :not_moderated, -> { where(is_moderated: false) } scope :active, -> { not_deleted.not_moderated } - scope :accessible_to_user, ->(user) { user && user.is_moderator? ? all : active } + scope :accessible_to_user, ->(user) { (user && user.is_moderator?) ? all : active } + scope :recent, -> { where(created_at: (6.months.ago..)) } + scope :above_average, -> { + joins(:story) + .joins("left outer join comment_stats on date(comments.created_at) = comment_stats.date") + .where("comments.score > coalesce(comment_stats.`average`, 3)") + } + scope :on_stories_not_authored_by, ->(user) { + joins(:story) + .merge(Story.where.not(user: user, user_is_author: true)) # TODO: authorship/beneficial idea + } + scope :for_presentation, -> { + includes(:user, :hat, moderation: :moderator, story: :user, votes: :user) + } scope :not_on_story_hidden_by, ->(user) { user ? where.not( - HiddenStory.select('TRUE') - .where(Arel.sql('hidden_stories.story_id = stories.id')) + HiddenStory.select("TRUE") + .where(Arel.sql("hidden_stories.story_id = stories.id")) .by(user).arel.exists - ) : where('true') + ) : where("true") + } + # workaround: if this select is in #parents, calling .count produces invalid SQL + scope :with_thread_attributes, -> { + select("comments.*, comments_recursive.depth as depth, comments_recursive.reply_count") } FLAGGABLE_DAYS = 7 @@ -54,100 +80,56 @@ class Comment < ApplicationRecord # after this many minutes old, a comment cannot be edited MAX_EDIT_MINS = (60 * 6) - SCORE_RANGE_TO_HIDE = (-2 .. 4).freeze + # story_threads builds a confidence_order_path in SQL this many characters long: + # the longest reply chain in prod data is 31 comments (so, depth 30) * 3b confidence_order + COP_LENGTH = 31 * 3 + # Stop accepting replies this deep. Recursive CTE requires a fixed max (COP_LENGTH), + # but in practice all deep reply chains have gone off-topic and/or tuned into flamewars. + MAX_DEPTH = 18 + + SCORE_RANGE_TO_HIDE = (-2..4) - validates :short_id, length: { maximum: 10 } - validates :user_id, presence: true - validates :story_id, presence: true - validates :markeddown_comment, length: { maximum: 16_777_215 } - validates :comment, presence: { with: true, message: "cannot be empty." } + validates :short_id, length: {maximum: 10}, presence: true + validates :markeddown_comment, length: {maximum: 16_777_215} + validates :comment, + presence: {with: true, message: "cannot be empty."}, + length: {maximum: 16_777_215} + validates :confidence, :confidence_order, :flags, :score, presence: true + validates :is_deleted, :is_moderated, :is_from_email, inclusion: {in: [true, false]} validate do - self.parent_comment && self.parent_comment.is_gone? && + parent_comment&.is_gone? && errors.add(:base, "Comment was deleted by the author or a mod while you were writing.") - (m = self.comment.to_s.strip.match(/\A(t)his([\.!])?$\z/i)) && - errors.add(:base, (m[1] == "T" ? "N" : "n") + "ope" + m[2].to_s) + parent_comment && !parent_comment.depth_permits_reply? && + ModNote.tattle_on_max_depth_limit(user, parent_comment) && + errors.add(:base, "You have replied too greedily and too deep.") - self.comment.to_s.strip.match(/\Atl;?dr.?$\z/i) && + (m = comment.to_s.strip.match(/\A(t)his([\.!])?$\z/i)) && + errors.add(:base, ((m[1] == "T") ? "N" : "n") + "ope" + m[2].to_s) + + comment.to_s.strip.match(/\Atl;?dr.?$\z/i) && errors.add(:base, "Wow! A blue car!") - self.comment.to_s.strip.match(/\A([[[:upper:]][[:punct:]]] )+[[[:upper:]][[:punct:]]]?$\z/) && + comment.to_s.strip.match(/\Abump/i) && + errors.add(:base, "Don't bump threads.") + + comment.to_s.strip.match(/\A([[[:upper:]][[:punct:]]] )+[[[:upper:]][[:punct:]]]?$\z/) && errors.add(:base, "D O N ' T") - self.comment.to_s.strip.match(/\A(me too|nice)([\.!])?\z/i) && + comment.to_s.strip.match(/\A(me too|nice|\+1)([\.!])?\z/i) && errors.add(:base, "Please just upvote the parent post instead.") - self.hat.present? && self.user.wearable_hats.exclude?(self.hat) && + hat.present? && user.wearable_hats.exclude?(hat) && errors.add(:hat, "not wearable by user") # .try so tests don't need to persist a story and user - self.story.try(:accepting_comments?) || - errors.add(:base, "Story is no longer accepting comments.") - end - - def self.arrange_for_user(user) - # This function is always used when presenting threads. The calling - # controllers advance the user's ReadRibbon, which may reduce the number - # of ReplyingComments, invalidating the User.unread_replies_count cache. - # The controller clearing that cache on every view of any thread would be - # wasteful because users read many more threads than they participate in, - # the controller making an extra loop over all comments would be wasteful, - # so this does a couple checks (without replicating all the predicates in - # replying_comments view, which would be brittle) and may clear the cache. - # - # This whole function should be done in the DB using a common-table - # expression. When that happens the cache clear probably needs to move up - # to the controller, which means extra clears, but that's probably a win - # because this function is the site's core functionality and it's - # expensive in both CPU + redundant RAM for the web workers. - clear_replies_cache = false - - parents = self.order( - Arel.sql("comments.score < 0 ASC, comments.confidence DESC") - ) - .group_by(&:parent_comment_id) - - # top-down list of comments, regardless of indent level - ordered = [] - - ancestors = [nil] # nil sentinel so indent_level starts at 1 without add op. - subtree = parents[nil] - - while subtree - if (node = subtree.shift) - children = parents[node.id] - - clear_replies_cache = true if user && node.user_id == user.id - - # for deleted comments, if they have no children, they can be removed - # from the tree. otherwise they have to stay and a "[deleted]" stub - # will be shown - if node.is_gone? && # deleted or moderated - !children.present? && # don't have child comments - (!user || (!user.is_moderator? && node.user_id != user.id)) - # admins and authors should be able to see their deleted comments - next - end - - node.indent_level = ancestors.length - ordered << node - - # no children to recurse - next unless children - - # drill down a level - ancestors << subtree - subtree = children - else - # climb back out - subtree = ancestors.pop - end - end - - Rails.cache.delete("user:#{user.id}:unread_replies") if clear_replies_cache + new_record? && (story.try(:accepting_comments?) || + errors.add(:base, "Story is no longer accepting comments.")) + end - ordered + def self./(short_id) + find_by! short_id: end def self.regenerate_markdown @@ -155,7 +137,7 @@ def self.regenerate_markdown Comment.all.find_each do |c| c.markeddown_comment = c.generated_markeddown_comment - c.save(:validate => false) + c.save!(validate: false) end Comment.record_timestamps = true @@ -173,23 +155,23 @@ def as_json(_options = {}) :is_moderated, :score, :flags, - { :parent_comment => self.parent_comment && self.parent_comment.short_id }, - { :comment => (self.is_gone? ? "#{self.gone_text}" : :markeddown_comment) }, - { :comment_plain => (self.is_gone? ? self.gone_text : :comment) }, + {parent_comment: parent_comment&.short_id}, + {comment: (is_gone? ? "#{gone_text}" : :markeddown_comment)}, + {comment_plain: (is_gone? ? gone_text : :comment)}, :url, - :indent_level, - { :commenting_user => :user }, + :depth, + {commenting_user: user.username} ] js = {} h.each do |k| if k.is_a?(Symbol) - js[k] = self.send(k) + js[k] = send(k) elsif k.is_a?(Hash) - if k.values.first.is_a?(Symbol) - js[k.keys.first] = self.send(k.values.first) + js[k.keys.first] = if k.values.first.is_a?(Symbol) + send(k.values.first) else - js[k.keys.first] = k.values.first + k.values.first end end end @@ -198,7 +180,10 @@ def as_json(_options = {}) end def assign_initial_confidence - self.confidence = self.calculated_confidence + self.confidence = calculated_confidence + # 3 byte placeholder, immediately replaced by after_create callback calling + # update_score_and_recalculate! to fill in the autogenerated 'id' value + self.confidence_order = [0, 0, 0].pack("CCC") end def assign_short_id_and_score @@ -207,33 +192,75 @@ def assign_short_id_and_score end def assign_thread_id - if self.parent_comment_id.present? - self.thread_id = self.parent_comment.thread_id + self.thread_id = if parent_comment.present? + parent_comment.thread_id else - self.thread_id = Keystore.incremented_value_for("thread_id") + Keystore.incremented_value_for("thread_id") end end # http://evanmiller.org/how-not-to-sort-by-average-rating.html # https://github.com/reddit/reddit/blob/master/r2/r2/lib/db/_sorts.pyx def calculated_confidence - n = (self.score + self.flags * 2).to_f - return 0 if n == 0.0 + return 0 if is_deleted? || is_moderated? + # matching the reddit implementation by backing out these numbers from how we cache + ups = self.score + flags + downs = flags + n = BigDecimal(ups + downs) # the float version can accumulate enough error to go out of range + return 0 if n == 0 + raise ArgumentError, "n should count number of upvotes + flags; that can't be a negative number" if n < 0 + + z = BigDecimal("1.281551565545") # 80% confidence + p = BigDecimal(ups) / n + + left = p + (1 / (2 * n) * z * z) + right = z * Math.sqrt((p * ((1 - p) / n)) + (z * z / (4 * n * n))) + under = 1.0 + ((1.0 / n) * z * z) - upvotes = self.score + self.flags - z = 1.281551565545 # 80% confidence - p = upvotes.to_f / n + confidence = (left - right) / under + # raise "Comment #{id} calculated_confidence #{confidence} out of range (0..1)" unless (0..1).cover? confidence + confidence.clamp(0..1) # a handful of comments generate tiny negative numbers + end - left = p + (1 / ((2.0 * n) * z * z)) - right = z * Math.sqrt((p * ((1.0 - p) / n)) + (z * (z / (4.0 * n * n)))) - under = 1.0 + ((1.0 / n) * z * z) + # rate-limit users in heated reply chains, called by controller so that authors can preview + # without seeing the message + def breaks_speed_limit? + return false unless parent_comment_id + return false if user.is_moderator? + + parent_comment_ids = parent_comment.parents.ids.append(parent_comment.id) + flag_count = Vote.comments_flags(parent_comment_ids).count + commenter_flag_count = Vote.comments_flags(parent_comment_ids, user).count # double-count author + delay = (2 + flag_count + commenter_flag_count).minutes - return (left - right) / under + recent = Comment.where("created_at >= ?", delay.ago) + .find_by(user: user, thread_id: parent_comment.thread_id) + + return false if recent.blank? + + wait = ActionController::Base.helpers + .distance_of_time_in_words(Time.zone.now, (recent.created_at + delay)) + Rails.logger.info "breaks_speed_limit: #{user.username} replying to https://lobste.rs/c/#{parent_comment.short_id} parent_comment_ids (#{parent_comment_ids.join(" ")}) flags #{flag_count} commenter_flag_count #{commenter_flag_count} delay #{delay} delay.ago #{delay.ago} recent #{recent.id}" + errors.add( + :comment, + "Thread speed limit reached, next comment allowed in #{wait}. " + + (flag_count.zero? ? "" : "Is this thread getting heated? ") + + (commenter_flag_count.zero? ? "" : "You flagged here, you can leave it for mods.") + ) end def comment=(com) self[:comment] = com.to_s.rstrip - self.markeddown_comment = self.generated_markeddown_comment + self.markeddown_comment = generated_markeddown_comment + end + + # current_vote is the vote loaded for the currently-viewing user + def current_flagged? + current_vote.try(:[], :vote) == -1 + end + + def current_upvoted? + current_vote.try(:[], :vote) == 1 end def delete_for_user(user, reason = nil) @@ -241,11 +268,11 @@ def delete_for_user(user, reason = nil) self.is_deleted = true - if user.is_moderator? && user.id != self.user_id + if user.is_moderator? && user.id != user_id self.is_moderated = true m = Moderation.new - m.comment_id = self.id + m.comment_id = id m.moderator_user_id = user.id m.action = "deleted comment" @@ -253,56 +280,56 @@ def delete_for_user(user, reason = nil) m.reason = reason end - m.save + m.save! - User.update_counters self.user_id, karma: (self.votes.count * -2) + User.update_counters user_id, karma: (votes.count * -2) end - self.save(:validate => false) + save!(validate: false) Comment.record_timestamps = true - self.story.update_comments_count! + story.update_cached_columns self.user.refresh_counts! end - def deliver_mention_notifications - self.plaintext_comment.scan(/\B\@([\w\-]+)/).flatten.uniq.each do |mention| - if (u = User.active.find_by(:username => mention)) - if u.id == self.user.id - next - end + def deliver_notifications + notified = deliver_reply_notifications + deliver_mention_notifications(notified) + end - if u.email_mentions? - begin - EmailReply.mention(self, u).deliver_now - rescue => e - Rails.logger.error "error e-mailing #{u.email}: #{e}" - end + def deliver_mention_notifications(notified = []) + to_notify = plaintext_comment.scan(/\B[@~]([\w\-]+)/).flatten.uniq - notified - [user.username] + User.active.where(username: to_notify).find_each do |u| + if u.email_mentions? + begin + EmailReplyMailer.mention(self, u).deliver_now + rescue => e + Rails.logger.error "error e-mailing #{u.email}: #{e}" end + end - if u.pushover_mentions? - u.pushover!( - :title => "#{Rails.application.name} mention by " << - "#{self.user.username} on #{self.story.title}", - :message => self.plaintext_comment, - :url => self.url, - :url_title => "Reply to #{self.user.username}", - ) - end + if u.pushover_mentions? + u.pushover!( + title: "#{Rails.application.name} mention by " \ + "#{user.username} on #{story.title}", + message: plaintext_comment, + url: url, + url_title: "Reply to #{user.username}" + ) end end end def users_following_thread users_following_thread = Set.new - if self.user.id != self.story.user.id && self.story.user_is_following - users_following_thread << self.story.user + if user.id != story.user.id && story.user_is_following + users_following_thread << story.user end - if self.parent_comment_id && - (u = self.parent_comment.try(:user)) && - u.id != self.user.id && - u.is_active? + if parent_comment_id && + (u = parent_comment.try(:user)) && + u.id != user.id && + u.is_active? users_following_thread << u end @@ -310,10 +337,13 @@ def users_following_thread end def deliver_reply_notifications + notified = [] + users_following_thread.each do |u| if u.email_replies? begin - EmailReply.reply(self, u).deliver_now + EmailReplyMailer.reply(self, u).deliver_now + notified << u.username rescue => e Rails.logger.error "error e-mailing #{u.email}: #{e}" end @@ -321,18 +351,34 @@ def deliver_reply_notifications if u.pushover_replies? u.pushover!( - :title => "#{Rails.application.name} reply from " << - "#{self.user.username} on #{self.story.title}", - :message => self.plaintext_comment, - :url => self.url, - :url_title => "Reply to #{self.user.username}", + title: "#{Rails.application.name} reply from " \ + "#{user.username} on #{story.title}", + message: plaintext_comment, + url: url, + url_title: "Reply to #{user.username}" ) + notified << u.username end end + + notified + end + + def depth_permits_reply? + # Top-level replies (eg parent_comment_id == null) have depth 0, then each reply is +1. + # Alternate definition: depth is the number of ancestor comments. + + return false if new_record? # can't reply to unsaved comments + + # Most commonly, depth is set by merged_comments. But we need to count parents when executing as + # a validation on reply. + self.depth ||= parents.count + + depth < MAX_DEPTH end def generated_markeddown_comment - Markdowner.to_html(self.comment) + Markdowner.to_html(comment) end # TODO: race condition: if two votes arrive at the same time, the second one @@ -340,22 +386,36 @@ def generated_markeddown_comment def update_score_and_recalculate!(score_delta, flag_delta) self.score += score_delta self.flags += flag_delta + new_confidence = calculated_confidence + # confidence_order allows sorting sibling comments by confidence in queries like story_threads. + # confidence_order must sort in ascending order so that it's in the right order when + # concatenated into confidence_order_path, which the database sorts lexiographically. It is 3 + # bytes wide. The first two bytes map confidence to a big-endian unsigned integer, inverted so + # that high-confidence have low values. confidence is based on the number of upvotes and flags, + # so some values (like the one for 1 vote, 0 flags) are very common, causing sibling comments to + # tie. If we don't specify a tiebreaker, the database will return results in an arbitrary order, + # which means sibling comments will swap positions on page reloads (infrequently and + # intermittently, real fun to debug). So the third byte is the low byte of the comment id. Being + # assigned sequentially, mostly the tiebreaker sorts earlier comments sooner. We average ~200 + # comments per weekday so seeing rollover between sibling comments is rare. Importantly, even + # when it is 'wrong', it gives a stable sort. Comment.connection.execute <<~SQL UPDATE comments SET score = (select coalesce(sum(vote), 0) from votes where comment_id = comments.id), flags = (select count(*) from votes where comment_id = comments.id and vote = -1), - confidence = #{self.calculated_confidence} - WHERE id = #{self.id.to_i} + confidence = #{new_confidence}, + confidence_order = concat(lpad(char(65535 - floor(#{new_confidence} * 65535) using binary), 2, '\0'), char(id & 0xff using binary)) + WHERE id = #{id.to_i} SQL - self.story.recalculate_hotness! + story.update_cached_columns end def gone_text - if self.is_moderated? + if is_moderated? "Comment removed by moderator " << - self.moderation.try(:moderator).try(:username).to_s << ": " << - (self.moderation.try(:reason) || "No reason given") - elsif self.user.is_banned? + moderation.try(:moderator).try(:username).to_s << ": " << + (moderation.try(:reason) || "No reason given") + elsif user.is_banned? "Comment from banned user removed" else "Comment removed by author" @@ -363,55 +423,41 @@ def gone_text end def has_been_edited? - self.updated_at && (self.updated_at - self.created_at > 1.minute) - end - - def html_class_for_user - c = [] - if !self.user.is_active? - c.push "inactive_user" - elsif self.user.is_new? - c.push "new_user" - elsif self.story && self.story.user_is_author? && - self.story.user_id == self.user_id - c.push "user_is_author" - end - - c.join("") + updated_at && (updated_at - created_at > 1.minute) end def is_deletable_by_user?(user) - if user && user.is_moderator? - return true - elsif user && user.id == self.user_id - return self.created_at >= DELETEABLE_DAYS.days.ago + if user&.is_moderator? + true + elsif user && user.id == user_id + created_at >= DELETEABLE_DAYS.days.ago else - return false + false end end def is_disownable_by_user?(user) - user && user.id == self.user_id && self.created_at && self.created_at < DELETEABLE_DAYS.days.ago + user && user.id == user_id && created_at && created_at < DELETEABLE_DAYS.days.ago end def is_flaggable? - if self.created_at && self.score > FLAGGABLE_MIN_SCORE - Time.current - self.created_at <= FLAGGABLE_DAYS.days + if created_at && self.score > FLAGGABLE_MIN_SCORE + Time.current - created_at <= FLAGGABLE_DAYS.days else false end end def is_editable_by_user?(user) - if user && user.id == self.user_id - if self.is_moderated? - return false + if user && user.id == user_id + if is_gone? + false else - return (Time.current.to_i - (self.updated_at ? self.updated_at.to_i : - self.created_at.to_i) < (60 * MAX_EDIT_MINS)) + (Time.current.to_i - (updated_at ? updated_at.to_i : + created_at.to_i) < (60 * MAX_EDIT_MINS)) end else - return false + false end end @@ -420,41 +466,72 @@ def is_gone? end def is_undeletable_by_user?(user) - if user && user.is_moderator? - return true - elsif user && user.id == self.user_id && !self.is_moderated? - return true + if user&.is_moderator? + true + elsif user && user.id == user_id && !is_moderated? + true else - return false + false end end def log_hat_use - return unless self.hat && self.hat.modlog_use + return unless hat&.modlog_use m = Moderation.new - m.created_at = self.created_at - m.comment_id = self.id + m.created_at = created_at + m.comment_id = id m.moderator_user_id = user.id - m.action = "used #{self.hat.hat} hat" + m.action = "used #{hat.hat} hat" m.save! end def mark_submitter - Keystore.increment_value_for("user:#{self.user_id}:comments_posted") + Keystore.increment_value_for("user:#{user_id}:comments_posted") end def mailing_list_message_id [ "comment", - self.short_id, - self.is_from_email ? "email" : nil, - created_at.to_i, + short_id, + is_from_email ? "email" : nil, + created_at.to_i ].reject(&:!).join(".") << "@" << Rails.application.domain end + # all direct ancestors of this comment, oldest first + def parents + return Comment.none if parent_comment_id.nil? + + # starts from parent_comment_id so it works on new records + @parents ||= Comment + .joins(<<~SQL + inner join ( + with recursive parents as ( + select + id target_id, + id, + parent_comment_id, + 0 as depth, + (select count(*) from comments where parent_comment_id = id) as reply_count + from comments where id = #{parent_comment_id} + union all + select + parents.target_id, + c.id, + c.parent_comment_id, + depth - 1, + (select count(*) from comments where parent_comment_id = c.id) + from comments c join parents on parents.parent_comment_id = c.id + ) select id, depth, reply_count from parents + ) as comments_recursive on comments.id = comments_recursive.id + SQL + ) + .order("id asc") + end + def path - self.story.comments_path + "#c_#{self.short_id}" + story.comments_path + "#c_#{short_id}" end def plaintext_comment @@ -463,17 +540,18 @@ def plaintext_comment end def record_initial_upvote - Vote.vote_thusly_on_story_or_comment_for_user_because( - 1, self.story_id, self.id, self.user_id, nil, false - ) + # not calling vote_thusly to save round-trips of validations + Vote.create! story: story, comment: self, user: user, vote: 1 + + update_score_and_recalculate! 0, 0 # trigger db calculation - self.story.update_comments_count! + story.update_cached_columns end def score_for_user(u) - if self.show_score_to_user?(u) + if show_score_to_user?(u) score - elsif u && u.can_flag?(self) + elsif u&.can_flag?(self) "~" else " ".html_safe @@ -481,76 +559,171 @@ def score_for_user(u) end def short_id_url - Rails.application.root_url + "c/#{self.short_id}" + Rails.application.root_url + "c/#{short_id}" end def show_score_to_user?(u) - return true if u && u.is_moderator? + return true if u&.is_moderator? # hide score on new/near-zero comments to cut down on threads about voting # also hide if user has flagged the story/comment to make retaliatory flagging less fun ( - (self.created_at && self.created_at < 36.hours.ago) || + (created_at && created_at < 36.hours.ago) || !SCORE_RANGE_TO_HIDE.include?(self.score) - ) && (!current_vote || current_vote[:vote] >= 0) + ) && !current_flagged? end def to_param - self.short_id + short_id end - def unassign_votes - self.story.update_comments_count! + # this is less evil than it looks because commonmark produces consistent html: + # example + def parsed_links + markeddown_comment + .scan(/]*>([^<]+)<\/a>/) + .map { |url, title| + Link.new({ + from_comment_id: id, + url: url, + title: (url == title) ? nil : title + }) + }.compact end - def url - self.story.comments_url + "#c_#{self.short_id}" + def recreate_links + Link.recreate_from_comment!(self) if saved_change_to_attribute? :comment end - def vote_summary_for_user(u) - r_counts = {} - r_users = {} - # don't includes(:user) here and assume the caller did this already - self.votes.each do |v| - r_counts[v.reason.to_s] ||= 0 - r_counts[v.reason.to_s] += v.vote + def unassign_votes + story.update_cached_columns + end - r_users[v.reason.to_s] ||= [] - r_users[v.reason.to_s].push v.user.username - end + def url + story.comments_url + "#c_#{short_id}" + end - r_counts.keys.map {|k| - next if k == "" + def vote_summary_for_user + (vote_summary || []).map { |v| "#{v.count} #{v.reason_text.downcase}" }.join(", ") + end - o = "#{r_counts[k]} #{Vote::ALL_COMMENT_REASONS[k]}" - if u && u.is_moderator? && self.user_id != u.id - o << " (#{r_users[k].join(', ')})" - end - o - }.compact.join(", ") + def vote_summary_for_moderator + (vote_summary || []).map { |v| "#{v.count} #{v.reason_text.downcase} (#{v.usernames})" }.join(", ") end def undelete_for_user(user) Comment.record_timestamps = false self.is_deleted = false + update_score_and_recalculate!(0, 0) # restore score for sorting if user.is_moderator? self.is_moderated = false - if user.id != self.user_id + if user.id != user_id m = Moderation.new - m.comment_id = self.id + m.comment_id = id m.moderator_user_id = user.id m.action = "undeleted comment" - m.save + m.save! end end - self.save(:validate => false) + save!(validate: false) Comment.record_timestamps = true - self.story.update_comments_count! + story.update_cached_columns self.user.refresh_counts! end + + def self.recent_threads(user) + return Comment.none unless user.try(:id) + + thread_ids = Comment + .where(user: user) + .distinct(:thread_id) + .order(thread_id: :desc) + .limit(20) + .pluck(:thread_id) + return Comment.none if thread_ids.empty? + + Comment + .joins(<<~SQL + inner join ( + with recursive discussion as ( + select + c.id, + 0 as depth, + (select count(*) from comments where parent_comment_id = c.id) as reply_count, + cast(confidence_order as char(#{Comment::COP_LENGTH})) as confidence_order_path + from comments c + where + thread_id in (#{thread_ids.join(", ")}) and + parent_comment_id is null + union all + select + c.id, + discussion.depth + 1, + (select count(*) from comments where parent_comment_id = c.id), + cast(concat( + discussion.confidence_order_path, 3 * (depth + 1), + c.confidence_order + ) as char(#{Comment::COP_LENGTH})) + from comments c join discussion on c.parent_comment_id = discussion.id + ) + select * from discussion as comments + ) as comments_recursive on comments.id = comments_recursive.id + SQL + ) + .order("comments.thread_id desc, comments_recursive.confidence_order_path") + .select(' + comments.*, + comments_recursive.depth as depth, + comments_recursive.reply_count as reply_count + ') + end + + # select in thread order with preloading for _comment.html.erb + # eventually confidence_order_path can go away: https://modern-sql.com/caniuse/search_(recursion) + def self.story_threads(story) + return Comment.none unless story.id # unsaved Stories have no comments + + # If the story_ids predicate is in the outer select the query planner doesn't push it down into + # the recursive CTE, so that subquery would build the tree for the entire comments table. + Comment + .joins(<<~SQL + inner join ( + with recursive discussion as ( + select + c.id, + 0 as depth, + (select count(*) from comments where parent_comment_id = c.id) as reply_count, + cast(confidence_order as char(#{Comment::COP_LENGTH})) as confidence_order_path + from comments c + join stories on stories.id = c.story_id + where + (stories.id = #{story.id} or stories.merged_story_id = #{story.id}) and + parent_comment_id is null + union all + select + c.id, + discussion.depth + 1, + (select count(*) from comments where parent_comment_id = c.id), + cast(concat( + discussion.confidence_order_path, 3 * (depth + 1), + c.confidence_order + ) as char(#{Comment::COP_LENGTH})) + from comments c join discussion on c.parent_comment_id = discussion.id + ) + select * from discussion as comments + ) as comments_recursive on comments.id = comments_recursive.id + SQL + ) + .order("comments_recursive.confidence_order_path") + .select(' + comments.*, + comments_recursive.depth as depth, + comments_recursive.reply_count as reply_count + ') + end end diff --git a/benchmarks/lobsters/app/models/comment_stat.rb b/benchmarks/lobsters/app/models/comment_stat.rb new file mode 100644 index 00000000..22bdb5b4 --- /dev/null +++ b/benchmarks/lobsters/app/models/comment_stat.rb @@ -0,0 +1,29 @@ +# typed: false + +# Comment.above_average needs to compare against the average, which is an expensive dependent +# subquery. CommentStat calculates and stores that once for a fast join. +class CommentStat < ApplicationRecord + # has_many :comments # date(comments.created_at) + + validates :date, presence: true + validates :average, presence: true + + # Fills daily records for the last 30 days, updating existing rows (in case the job runs don't + # line up to date boundaries). + def self.daily_fill! + Comment.connection.execute <<~SQL + insert low_priority into comment_stats (`date`, `average`) + with avg_by_date as ( + select + date(created_at - interval 5 hour) as date, avg(score) as a + from comments + where + (comments.created_at - interval 5 hour) >= now() - interval 30 day and + comments.is_deleted = false + group by date(created_at - interval 5 hour) + ) + select date, a from avg_by_date + on duplicate key update `average` = `a` + SQL + end +end diff --git a/benchmarks/lobsters/app/models/domain.rb b/benchmarks/lobsters/app/models/domain.rb index 21f214cb..65074cb8 100644 --- a/benchmarks/lobsters/app/models/domain.rb +++ b/benchmarks/lobsters/app/models/domain.rb @@ -1,23 +1,92 @@ +# typed: false + class Domain < ApplicationRecord - has_many :stories # rubocop:disable Rails/HasManyOrHasOneDependent + has_many :stories belongs_to :banned_by_user, - :class_name => "User", - :inverse_of => false, - :optional => true - validates :banned_reason, :length => { :maximum => 200 } + class_name: "User", + inverse_of: false, + optional: true + has_many :origins + + validates :banned_reason, length: {maximum: 200} + validates :domain, presence: true, length: {maximum: 255}, uniqueness: {case_sensitive: false} + validates :selector, length: {maximum: 255} + validates :replacement, length: {maximum: 255} + validates :stories_count, numericality: {only_integer: true, greater_than_or_equal_to: 0}, presence: true + + validate :valid_selector + + after_save :update_origins + + def valid_selector + return if selector.nil? + + if selector.include? "\n" + errors.add(:selector, "no newlines") + return + end + + selector_regexp + rescue RegexpError => e + errors.add(:selector, "is an invalid Regexp: #{e.message}") + end + + def self./(domain) + find_by! domain: + end - validates :domain, presence: true + def selector=(s) + s = s.strip + s = "\\A#{s}" unless s.starts_with?("\\A") + s = "#{s}\\z" unless s.ends_with?("\\z") + super + end + + def selector_regexp + Regexp.new(selector, Regexp::IGNORECASE, timeout: 0.1) + end + + def update_origins + return unless saved_change_to_selector? || saved_change_to_replacement? + + # only happens for rare, manual mod edits + # Prosopite.pause do + stories.find_each do |story| + story.update_column(:origin_id, story.domain.find_or_create_origin(story.url)&.id) + end + # end + end + + def find_or_create_origin(url) + return nil if selector.blank? || replacement.blank? + valid? + raise ArgumentError, "Domain not valid: #{errors.full_messages.join(", ")}" if errors.any? + raise ArgumentError, "Can't create Origin until Domain is persisted" if new_record? + + # github.com/foo -> github.com/foo + # github.com/foo/bar -> github.com/foo + identifier = if url.match?(selector_regexp) + url.sub(selector_regexp, replacement) + else + # if the URL isn't matched, the identifier is the bare domain (handles root + partial regexps) + domain + end.downcase + + # because of rails associations, `origins` is scoped to current domain object + # find_or_create_by! returns the origin record, or raises if validations fail + origins.find_or_create_by!(identifier: identifier) + end def ban_by_user_for_reason!(banner, reason) self.banned_at = Time.current self.banned_by_user_id = banner.id self.banned_reason = reason - self.save! + save! m = Moderation.new m.moderator_user_id = banner.id m.domain = self - m.action = 'Banned' + m.action = "Banned" m.reason = reason m.save! end @@ -26,12 +95,12 @@ def unban_by_user_for_reason!(banner, reason) self.banned_at = nil self.banned_by_user_id = nil self.banned_reason = nil - self.save! + save! m = Moderation.new m.moderator_user_id = banner.id m.domain = self - m.action = 'Unbanned' + m.action = "Unbanned" m.reason = reason m.save! end @@ -41,7 +110,7 @@ def banned? end def n_submitters - self.stories.count('distinct user_id') + stories.count("distinct user_id") end def to_param diff --git a/benchmarks/lobsters/app/models/flagged_commenters.rb b/benchmarks/lobsters/app/models/flagged_commenters.rb index 329ec11b..5575378c 100644 --- a/benchmarks/lobsters/app/models/flagged_commenters.rb +++ b/benchmarks/lobsters/app/models/flagged_commenters.rb @@ -1,3 +1,5 @@ +# typed: false + # Finds the consistent most-heavily-flagged commenters. Requires flags to be spread over # several comments and stories because anyone can have a bad thread or a bad day. @@ -19,71 +21,73 @@ def check_list_for(showing_user) # aggregates for all commenters; not just those receiving flags def aggregates - Rails.cache.fetch("aggregates_#{interval}_#{cache_time}", expires_in: self.cache_time) { - ActiveRecord::Base.connection.exec_query(" - select - stddev(sum_flags) as stddev, - sum(sum_flags) as sum, - avg(sum_flags) as avg, - avg(n_comments) as n_comments, - count(*) as n_commenters - from ( - select - sum(flags) as sum_flags, - count(*) as n_comments - from comments join users on comments.user_id = users.id - where - (comments.created_at >= '#{period}') and - users.banned_at is null and - users.deleted_at is null - GROUP BY comments.user_id - ) sums; - ").first.symbolize_keys! + Rails.cache.fetch("aggregates_#{interval}_#{cache_time}", expires_in: cache_time) { + # ActiveRecord::Base.connection.exec_query(" + # select + # stddev(sum_flags) as stddev, + # sum(sum_flags) as sum, + # avg(sum_flags) as avg, + # avg(n_comments) as n_comments, + # count(*) as n_commenters + # from ( + # select + # sum(flags) as sum_flags, + # count(*) as n_comments + # from comments join users on comments.user_id = users.id + # where + # (comments.created_at >= '#{period}') and + # users.banned_at is null and + # users.deleted_at is null + # GROUP BY comments.user_id + # ) sums; + # ").first.symbolize_keys! + {} } end def stddev_sum_flags - aggregates[:stddev].to_i + aggregates[:stddev].to_f end def avg_sum_flags - aggregates[:avg].to_i + aggregates[:avg].to_f end def commenters Rails.cache.fetch("flagged_commenters_#{interval}_#{cache_time}", - expires_in: self.cache_time) { + expires_in: cache_time) { rank = 0 - User.active.joins(:comments) - .where("comments.created_at >= ?", period) - .group("comments.user_id") - .select(" - users.id, users.username, - (sum(flags) - #{avg_sum_flags})/#{stddev_sum_flags} as sigma, - count(distinct if(flags > 0, comments.id, null)) as n_comments, - count(distinct if(flags > 0, story_id, null)) as n_stories, - sum(flags) as n_flags, - sum(flags)/count(distinct comments.id) as average_flags, - ( - count(distinct if(flags > 0, comments.id, null)) / - count(distinct comments.id) - ) * 100 as percent_flagged") - .having("n_comments > 4 and n_stories > 1 and n_flags >= 10 and percent_flagged > 10") - .order("sigma desc") - .limit(30) - .each_with_object({}) {|u, hash| - hash[u.id] = { - username: u.username, - rank: rank += 1, - sigma: u.sigma, - n_comments: u.n_comments, - n_stories: u.n_stories, - n_flags: u.n_flags, - average_flags: u.average_flags, - stddev: 0, - percent_flagged: u.percent_flagged, - } - } + # User.active.joins(:comments) + # .where("comments.created_at >= ?", period) + # .group("comments.user_id") + # .select(" + # users.id, users.username, + # (sum(flags) - #{avg_sum_flags})/#{stddev_sum_flags} as sigma, + # count(distinct if(flags > 0, comments.id, null)) as n_comments, + # count(distinct if(flags > 0, story_id, null)) as n_stories, + # sum(flags) as n_flags, + # sum(flags)/count(distinct comments.id) as average_flags, + # ( + # count(distinct if(flags > 0, comments.id, null)) / + # count(distinct comments.id) + # ) * 100 as percent_flagged") + # .having("n_comments > 4 and n_stories > 1 and n_flags >= 10 and percent_flagged > 10") + # .order("sigma desc") + # .limit(30) + # .each_with_object({}) { |u, hash| + # hash[u.id] = { + # username: u.username, + # rank: rank += 1, + # sigma: u.sigma, + # n_comments: u.n_comments, + # n_stories: u.n_stories, + # n_flags: u.n_flags, + # average_flags: u.average_flags, + # stddev: 0, + # percent_flagged: u.percent_flagged + # } + # } + {} } end end diff --git a/benchmarks/lobsters/app/models/hat.rb b/benchmarks/lobsters/app/models/hat.rb index 847ee353..a3331969 100644 --- a/benchmarks/lobsters/app/models/hat.rb +++ b/benchmarks/lobsters/app/models/hat.rb @@ -1,54 +1,63 @@ +# typed: false + class Hat < ApplicationRecord belongs_to :user belongs_to :granted_by_user, class_name: "User", inverse_of: false + before_validation :assign_short_id, on: :create after_create :log_moderation - validates :user, :granted_by_user, :hat, presence: true - validates :hat, :link, length: { maximum: 255 } + validates :hat, presence: true + validates :hat, :link, length: {maximum: 255} + validates :modlog_use, inclusion: {in: [true, false]} + validates :short_id, length: {maximum: 10}, presence: true scope :active, -> { joins(:user).where(doffed_at: nil).merge(User.active) } + def assign_short_id + self.short_id = ShortId.new(self.class).generate + end + def doff_by_user_with_reason(user, reason) m = Moderation.new - m.user_id = self.user_id + m.user_id = user_id m.moderator_user_id = user.id - m.action = "Doffed hat \"#{self.hat}\": #{reason}" + m.action = "Doffed hat \"#{hat}\": #{reason}" m.save! self.doffed_at = Time.current - self.save! + save! end def destroy_by_user_with_reason(user, reason) m = Moderation.new - m.user_id = self.user_id + m.user_id = user_id m.moderator_user_id = user.id - m.action = "Revoked hat \"#{self.hat}\": #{reason}" + m.action = "Revoked hat \"#{hat}\": #{reason}" m.save! - self.destroy + destroy! end def to_html_label - hl = (self.link.present? && self.link.match(/^https?:\/\//)) + hl = link.present? && link.match(/^https?:\/\//) - h = "" << + h << "\">" \ "" if hl - h << "" + h << "" end - h << ERB::Util.html_escape(self.hat) + h << ERB::Util.html_escape(hat) if hl h << "" @@ -60,17 +69,17 @@ def to_html_label end def to_txt - "(#{self.hat}) " + "(#{hat}) " end def log_moderation m = Moderation.new - m.created_at = self.created_at - m.user_id = self.user_id - m.moderator_user_id = self.granted_by_user_id - m.action = "Granted hat \"#{self.hat}\"" + (self.link.present? ? - " (#{self.link})" : "") - m.save + m.created_at = created_at + m.user_id = user_id + m.moderator_user_id = granted_by_user_id + m.action = "Granted hat \"#{hat}\"" + (link.present? ? + " (#{link})" : "") + m.save! end def sanitized_link @@ -81,4 +90,8 @@ def sanitized_link link end end + + def to_param + short_id + end end diff --git a/benchmarks/lobsters/app/models/hat_request.rb b/benchmarks/lobsters/app/models/hat_request.rb index de6b65d9..9ad98d57 100644 --- a/benchmarks/lobsters/app/models/hat_request.rb +++ b/benchmarks/lobsters/app/models/hat_request.rb @@ -1,42 +1,44 @@ +# typed: false + class HatRequest < ApplicationRecord belongs_to :user - validates :hat, presence: true, length: { maximum: 255 } - validates :link, presence: true, length: { maximum: 255 } - validates :comment, presence: true, length: { maximum: 65_535 } + validates :hat, presence: true, length: {maximum: 255} + validates :link, presence: true, length: {maximum: 255} + validates :comment, presence: true, length: {maximum: 65_535} attr_accessor :rejection_comment def approve_by_user_for_reason!(user, reason) - self.transaction do + transaction do h = Hat.new - h.user_id = self.user_id + h.user_id = user_id h.granted_by_user_id = user.id - h.hat = self.hat - h.link = self.link + h.hat = hat + h.link = link h.save! m = Message.new m.author_user_id = user.id - m.recipient_user_id = self.user_id - m.subject = "Your hat \"#{self.hat}\" has been approved" + m.recipient_user_id = user_id + m.subject = "Your hat \"#{hat}\" has been approved" m.body = reason m.save! - self.destroy + destroy! end end def reject_by_user_for_reason!(user, reason) - self.transaction do + transaction do m = Message.new m.author_user_id = user.id - m.recipient_user_id = self.user_id - m.subject = "Your request for hat \"#{self.hat}\" has been rejected" + m.recipient_user_id = user_id + m.subject = "Your request for hat \"#{hat}\" has been rejected" m.body = reason m.save! - self.destroy + destroy! end end end diff --git a/benchmarks/lobsters/app/models/hidden_story.rb b/benchmarks/lobsters/app/models/hidden_story.rb index 6198ec05..3a706b00 100644 --- a/benchmarks/lobsters/app/models/hidden_story.rb +++ b/benchmarks/lobsters/app/models/hidden_story.rb @@ -1,18 +1,20 @@ +# typed: false + class HiddenStory < ApplicationRecord belongs_to :user belongs_to :story scope :by, ->(user) { where(user: user) } - def self.hide_story_for_user(story_id, user_id) - HiddenStory.where(:user_id => user_id, :story_id => - story_id).first_or_initialize.save! - ReadRibbon.hide_replies_for(story_id, user_id) + def self.hide_story_for_user(story, user) + HiddenStory.where(story: story, user: user).first_or_initialize.save! + story.update_score_and_recalculate!(0, 0) + ReadRibbon.hide_replies_for(story.id, user.id) end - def self.unhide_story_for_user(story_id, user_id) - HiddenStory.where(:user_id => user_id, :story_id => - story_id).delete_all - ReadRibbon.unhide_replies_for(story_id, user_id) + def self.unhide_story_for_user(story, user) + HiddenStory.where(story: story, user: user).delete_all + story.update_score_and_recalculate!(0, 0) + ReadRibbon.unhide_replies_for(story.id, user.id) end end diff --git a/benchmarks/lobsters/app/models/inactive_user.rb b/benchmarks/lobsters/app/models/inactive_user.rb index 05490753..27795133 100644 --- a/benchmarks/lobsters/app/models/inactive_user.rb +++ b/benchmarks/lobsters/app/models/inactive_user.rb @@ -1,6 +1,8 @@ +# typed: false + module InactiveUser def self.inactive_user - @inactive_user ||= User.find_by!(username: 'inactive-user') + @inactive_user ||= User.find_by!(username: "inactive-user") end def self.disown! comment_or_story @@ -12,13 +14,13 @@ def self.disown! comment_or_story def self.disown_all_by_author! author # leave attribution on deleted stuff, which is generally very relevant to mods # when looking back at returning users - author.stories.not_deleted.update_all(:user_id => inactive_user.id) - author.comments.active.update_all(:user_id => inactive_user.id) + author.stories.not_deleted(nil).update_all(user_id: inactive_user.id) + author.comments.active.update_all(user_id: inactive_user.id) refresh_counts! author end def self.refresh_counts! user - user.refresh_counts! if user + user&.refresh_counts! inactive_user.refresh_counts! end end diff --git a/benchmarks/lobsters/app/models/invitation.rb b/benchmarks/lobsters/app/models/invitation.rb index 6c58aea4..3405c9b0 100644 --- a/benchmarks/lobsters/app/models/invitation.rb +++ b/benchmarks/lobsters/app/models/invitation.rb @@ -1,24 +1,27 @@ +# typed: false + class Invitation < ApplicationRecord belongs_to :user - belongs_to :new_user, class_name: 'User', inverse_of: nil, optional: true + belongs_to :new_user, class_name: "User", inverse_of: nil, optional: true - scope :used, -> { where.not(:used_at => nil) } - scope :unused, -> { where(:used_at => nil) } + scope :used, -> { where.not(used_at: nil) } + scope :unused, -> { where(used_at: nil) } validate do - unless email.to_s.match(/\A[^@ ]+@[^ @]+\.[^ @]+\z/) + unless /\A[^@ ]+@[^ @]+\.[^ @]+\z/.match?(email.to_s) errors.add(:email, "is not valid") end end - validates :code, :email, :memo, length: { maximum: 255 } + validates :code, :email, length: {maximum: 255} + validates :memo, length: {maximum: 375} - before_validation :create_code, :on => :create + before_validation :create_code, on: :create def create_code 10.times do self.code = Utils.random_str(15) - return unless Invitation.exists?(:code => self.code) + return unless Invitation.exists?(code: code) end raise "too many hash collisions" end diff --git a/benchmarks/lobsters/app/models/invitation_request.rb b/benchmarks/lobsters/app/models/invitation_request.rb index 68c27b74..8a88683b 100644 --- a/benchmarks/lobsters/app/models/invitation_request.rb +++ b/benchmarks/lobsters/app/models/invitation_request.rb @@ -1,33 +1,36 @@ +# typed: false + class InvitationRequest < ApplicationRecord validates :name, - :presence => true, - :length => { maximum: 255 } + presence: true, + length: {maximum: 255} validates :email, - :format => { :with => /\A[^@ ]+@[^@ ]+\.[^@ ]+\Z/ }, - :presence => true, - :length => { maximum: 255 } + format: {with: /\A[^@ ]+@[^@ ]+\.[^@ ]+\Z/}, + presence: true, + length: {maximum: 255} validates :memo, - :format => { :with => /https?:\/\// }, - :length => { maximum: 255 } - validates :code, :ip_address, :length => { maximum: 255 } + format: {with: Utils::URL_RE}, + length: {maximum: 255} + validates :code, :ip_address, length: {maximum: 255} + validates :is_verified, inclusion: {in: [true, false]} before_validation :create_code after_create :send_email def self.verified_count - InvitationRequest.where(:is_verified => true).count + InvitationRequest.where(is_verified: true).count end def create_code 10.times do self.code = Utils.random_str(15) - return unless InvitationRequest.exists?(:code => self.code) + return unless InvitationRequest.exists?(code: code) end raise "too many hash collisions" end def markeddown_memo - Markdowner.to_html(self.memo) + Markdowner.to_html(memo) end def send_email diff --git a/benchmarks/lobsters/app/models/keystore.rb b/benchmarks/lobsters/app/models/keystore.rb index dfeca970..ccc0eade 100644 --- a/benchmarks/lobsters/app/models/keystore.rb +++ b/benchmarks/lobsters/app/models/keystore.rb @@ -1,71 +1,45 @@ +# typed: false + class Keystore < ApplicationRecord MAX_KEY_LENGTH = 50 self.primary_key = "key" - validates :key, presence: true, length: { maximum: MAX_KEY_LENGTH } + validates :key, presence: true, length: {maximum: MAX_KEY_LENGTH} def self.get(key) - self.find_by(key: key) + find_by(key: key) end def self.value_for(key) - self.where(key: key).limit(1).pluck(:value).first + where(key: key).pick(:value) end def self.put(key, value) validate_input_key(key) - if Keystore.connection.adapter_name == "SQLite" - Keystore.connection.execute("INSERT OR REPLACE INTO " << - "#{Keystore.table_name} (`key`, `value`) VALUES " << - "(#{q(key)}, #{q(value)})") - elsif Keystore.connection.adapter_name =~ /Mysql/ - Keystore.connection.execute("INSERT INTO #{Keystore.table_name} (" + - "`key`, `value`) VALUES (#{q(key)}, #{q(value)}) ON DUPLICATE KEY " + - "UPDATE `value` = #{q(value)}") - else - kv = self.find_or_create_key_for_update(key, value) - kv.value = value - kv.save! - end + Keystore.upsert({key: key, value: value}, returning: false) true end def self.increment_value_for(key, amount = 1) - self.incremented_value_for(key, amount) + incremented_value_for(key, amount) end def self.incremented_value_for(key, amount = 1) validate_input_key(key) Keystore.transaction do - if Keystore.connection.adapter_name == "SQLite" - Keystore.connection.execute("INSERT OR IGNORE INTO " << - "#{Keystore.table_name} (`key`, `value`) VALUES " << - "(#{q(key)}, 0)") - Keystore.connection.execute("UPDATE #{Keystore.table_name} " << - "SET `value` = `value` + #{q(amount)} WHERE `key` = #{q(key)}") - elsif Keystore.connection.adapter_name =~ /Mysql/ - Keystore.connection.execute("INSERT INTO #{Keystore.table_name} (" + - "`key`, `value`) VALUES (#{q(key)}, #{q(amount)}) ON DUPLICATE KEY " + - "UPDATE `value` = `value` + #{q(amount)}") - else - kv = self.find_or_create_key_for_update(key, 0) - kv.value = kv.value.to_i + amount - kv.save! - return kv.value - end - - self.value_for(key) + Keystore.upsert({key: key, value: amount}, on_duplicate: Arel.sql("value = value + 1")) + value_for(key) end end def self.find_or_create_key_for_update(key, init = nil) loop do - found = self.lock(true).find_by(:key => key) + found = lock(true).find_by(key: key) return found if found begin - self.create! do |kv| + create! do |kv| kv.key = key kv.value = init kv.save! @@ -77,11 +51,11 @@ def self.find_or_create_key_for_update(key, init = nil) end def self.decrement_value_for(key, amount = -1) - self.increment_value_for(key, amount) + increment_value_for(key, amount) end def self.decremented_value_for(key, amount = -1) - self.incremented_value_for(key, amount) + incremented_value_for(key, amount) end # deliberately no lock/transaction as TrafficHelper is on the hot path of every request diff --git a/benchmarks/lobsters/app/models/link.rb b/benchmarks/lobsters/app/models/link.rb new file mode 100644 index 00000000..d794a43a --- /dev/null +++ b/benchmarks/lobsters/app/models/link.rb @@ -0,0 +1,86 @@ +# typed: false + +class Link < ApplicationRecord + belongs_to :from_story, class_name: "Story", optional: true + belongs_to :from_comment, class_name: "Comment", optional: true + belongs_to :to_story, class_name: "Story", optional: true + belongs_to :to_comment, class_name: "Comment", optional: true + + validates :url, length: {maximum: 250, allow_nil: false}, presence: true + validate :valid_url + validates :normalized_url, length: {maximum: 255, allow_nil: false}, presence: true + validates :title, length: {maximum: 255} + validate :validate_from_presence_xor + validate :validate_only_one_to + + # rubocop:disable Rails/UniqueValidationWithoutIndex + # no database-level index because, per https://mariadb.com/kb/en/getting-started-with-indexes/ + # > In SQL any NULL is never equal to anything, not even to another NULL. Consequently, a UNIQUE + # > constraint will not prevent one from storing duplicate rows if they contain null values: + validates :url, uniqueness: {scope: [:from_story, :from_comment]} + validates :to_comment, uniqueness: {scope: [:from_story, :from_comment]}, if: ->(l) { l.to_comment.present? } + validates :to_story, uniqueness: {scope: [:from_story, :from_comment]}, if: ->(l) { l.to_story.present? } + # rubocop:enable Rails/UniqueValidationWithoutIndex + + scope :recently_linked_from_comments, ->(url) { + joins(:from_comment).includes(:from_comment) + .where(from_comment: {created_at: (7.days.ago)..}) + .where(normalized_url: Utils.normalize(url)) + } + + def url=(u) + return if u.blank? + + super(u.to_s.strip) + self.normalized_url = Utils.normalize(url) + + if normalized_url.starts_with? Rails.application.domain + path = normalized_url.delete_prefix(Rails.application.domain) + r = Rails.application.routes.recognize_path(path, method: :get) + + if r[:controller] == "comments" && %w[show redirect_from_short_id show_short_id].include?(r[:action]) + self.to_comment = Comment.find_by(short_id: r[:id]) + elsif r[:controller] == "stories" && r[:action] == "show" + # check if the url ends with a comment anchor + if (m = url.to_s.match(/#c_([0-9A-Za-z]{6,8}\z)/)) + self.to_comment = Comment.find_by(short_id: m[1]) + else + self.to_story = Story.find_by(short_id: r[:id]) + end + end + end + rescue ActionController::RoutingError + # ignore invalid URLs from recognize_path + end + + # acts idempotently, ignoring multiple Links to the same url/story/comment + # ignores validation errors; some comments have bad links ('#', 'https://foo/', 'https://http://apple.com') + def self.recreate_from_comment! c + Link.transaction do + Link.where(from_comment_id: c.id).delete_all + c.parsed_links.each(&:save) + end + end + + # acts idempotently, ignoring multiple Links to the same url/story/comment + def self.recreate_from_story! s + Link.transaction do + Link.where(from_story_id: s.id).delete_all + s.parsed_links.each(&:save) + end + end + + private + + def valid_url + errors.add(:url, "is not valid") unless url.match?(Utils::URL_RE) + end + + def validate_from_presence_xor + errors.add(:base, "from_story xor from_comment must be present") unless from_story.present? ^ from_comment.present? + end + + def validate_only_one_to + errors.add(:base, "Both to_story and to_comment cannot be set") if to_story.present? && to_comment.present? + end +end diff --git a/benchmarks/lobsters/app/models/mastodon_app.rb b/benchmarks/lobsters/app/models/mastodon_app.rb new file mode 100644 index 00000000..e51393ff --- /dev/null +++ b/benchmarks/lobsters/app/models/mastodon_app.rb @@ -0,0 +1,143 @@ +# typed: false + +# https://docs.joinmastodon.org/methods/apps/ +class MastodonApp < ApplicationRecord + validates :name, :client_id, :client_secret, + presence: true, + length: {maximum: 255} + + # https://docs.joinmastodon.org/methods/oauth/ + def oauth_auth_url + "https://#{name}/oauth/authorize?response_type=code&client_id=#{client_id}&scope=read:accounts&redirect_uri=" + + CGI.escape(redirect_uri) + end + + def redirect_uri + "https://#{Rails.application.domain}/settings/mastodon_callback?instance=#{name}" + end + + # this (if needed) adds errors to the model or saves on success because calling .save after it + # runs will clear these errors + def register_app! + raise "already registered, delete and recreate" if client_id.present? + + s = Sponge.new + url = "https://#{name}/api/v1/apps" + res = s.fetch( + url, + :post, + client_name: Rails.application.domain, + redirect_uris: [ + "https://#{Rails.application.domain}/settings", + redirect_uri + ].join("\n"), + scopes: "read:accounts", + website: "https://#{Rails.application.domain}" + ) + if res.nil? || res.body.blank? + errors.add :base, "App registration failed, is #{name} a Mastodon instance?" + return + end + js = JSON.parse(res.body) + if js && js["client_id"].present? && js["client_secret"].present? + self.client_id = js["client_id"] + self.client_secret = js["client_secret"] + return save! + end + errors.add :base, "Mastodon instance didn't return a client_id and client_secret" + rescue DNSError, NoIPsError + errors.add :base, "#{name} isn't resolving to an IP, check for typos?" + rescue JSON::ParserError + errors.add :base, "#{name} responded with non-parseable JSON" + rescue OpenSSL::SSL::SSLError + errors.add :base, "#{name} isn't a working SSL server" + rescue URI::InvalidURIError + errors.add :base, "#{name} isn't a valid URL" + end + + def token_and_user_from_code(code) + s = Sponge.new + res = s.fetch( + "https://#{name}/oauth/token", + :post, + client_id: client_id, + client_secret: client_secret, + redirect_uri: redirect_uri, + grant_type: "authorization_code", + code: code, + scope: "read:account" + ) + if res.nil? || res.body.nil? + errors.add :base, "#{name} errored instead of giving an access token, is it a Mastodon instance?" + return + end + ps = JSON.parse(res.body) + tok = ps["access_token"] + + if tok.present? + headers = {"Authorization" => "Bearer #{tok}"} + res = s.fetch( + "https://#{name}/api/v1/accounts/verify_credentials", + :get, + nil, + nil, + headers + ) + if res.nil? || res.body.nil? + errors.add :base, "#{name} errored instead of giving a user token, is it a Mastodon instance?" + return + end + js = JSON.parse(res.body) + if js && js["username"].present? + return [tok, js["username"]] + end + end + + [nil, nil] + rescue OpenSSL::SSL::SSLError + errors.add :base, "#{name} isn't a working SSL server when fetching user token" + rescue JSON::ParserError + errors.add :base, "#{name} responded with non-parseable JSON for user token" + end + + # https://docs.joinmastodon.org/methods/oauth/#revoke + def revoke_token(token) + return if token.blank? + + s = Sponge.new + res = s.fetch( + "https://#{name}/oauth/revoke", + :post, + client_id: client_id, + client_secret: client_secret, + token: token + ) + ps = JSON.parse(res.body) + if ps != {} + Rails.logger.info "Unexpected failure revoking token from #{name}, response was #{res.body}" + end + ps == {} + end + + def self.find_or_register(instance_name) + name = sanitized_instance_name(instance_name) + return nil if name.blank? + existing = find_by name: name + return existing if existing.present? + + app = new name: name + app.register_app! + app + end + + # user may input hostname (foo.social), url (https://foo.social/@user), or user (@user@foo.social) + # extract hostname from possible URL + def self.sanitized_instance_name(instance_name) + instance_name + .to_s + .strip + .delete_prefix("https://") + .split("/").first + .split("@").last + end +end diff --git a/benchmarks/lobsters/app/models/message.rb b/benchmarks/lobsters/app/models/message.rb index 5c6e254f..3458fae9 100644 --- a/benchmarks/lobsters/app/models/message.rb +++ b/benchmarks/lobsters/app/models/message.rb @@ -1,45 +1,50 @@ +# typed: false + class Message < ApplicationRecord belongs_to :recipient, - :class_name => "User", - :foreign_key => "recipient_user_id", - :inverse_of => :received_messages + class_name: "User", + foreign_key: "recipient_user_id", + inverse_of: :received_messages belongs_to :author, - :class_name => "User", - :foreign_key => "author_user_id", - :inverse_of => :sent_messages, - :optional => true + class_name: "User", + foreign_key: "author_user_id", + inverse_of: :sent_messages, + optional: true belongs_to :hat, - :optional => true + optional: true attribute :mod_note, :boolean attr_reader :recipient_username - validates :subject, length: { :in => 1..100 } - validates :body, length: { :maximum => (64 * 1024) } - validates :short_id, length: { maximum: 30 } + validates :subject, length: {in: 1..100} + validates :body, length: {maximum: 70_000}, on: :update # for weird old data + validates :body, length: {maximum: 8_192}, on: :create # for weird old data + validates :short_id, length: {maximum: 30} + validates :has_been_read, :deleted_by_author, :deleted_by_recipient, inclusion: {in: [true, false]} validate :hat do next if hat.blank? if author.blank? || author.wearable_hats.exclude?(hat) - errors.add(:hat, 'not wearable by author') + errors.add(:hat, "not wearable by author") end end scope :inbox, ->(user) { where( recipient: user, - deleted_by_recipient: false, - ).preload(:author, :hat, :recipient).order('id asc') + deleted_by_recipient: false + ).preload(:author, :hat, :recipient).order("id asc") } scope :outbox, ->(user) { where( author: user, - deleted_by_author: false, - ).preload(:author, :hat, :recipient).order('id asc') + deleted_by_author: false + ).preload(:author, :hat, :recipient).order("id asc") } - scope :unread, -> { where(:has_been_read => false, :deleted_by_recipient => false) } + scope :unread, -> { where(has_been_read: false, deleted_by_recipient: false) } - before_validation :assign_short_id, :on => :create + before_validation :assign_short_id, on: :create after_create :deliver_email_notifications + after_destroy :update_unread_counts after_save :update_unread_counts after_save :check_for_both_deleted @@ -51,13 +56,13 @@ def as_json(_options = {}) :subject, :body, :deleted_by_author, - :deleted_by_recipient, + :deleted_by_recipient ] - h = super(:only => attrs) + h = super(only: attrs) - h[:author_username] = self.author.try(:username) - h[:recipient_username] = self.recipient.try(:username) + h[:author_username] = author.try(:username) + h[:recipient_username] = recipient.try(:username) h end @@ -67,42 +72,42 @@ def assign_short_id end def author_username - if self.author - self.author.username + if author + author.username else "System" end end def check_for_both_deleted - if self.deleted_by_author? && self.deleted_by_recipient? - self.destroy + if deleted_by_author? && deleted_by_recipient? + destroy! end end def update_unread_counts - self.recipient.update_unread_message_count! + recipient.update_unread_message_count! end def deliver_email_notifications - return if Rails.env.development? - - if self.recipient.email_messages? + if recipient.email_messages? begin - EmailMessage.notify(self, self.recipient).deliver_now + EmailMessageMailer.notify(self, recipient).deliver_now rescue => e - Rails.logger.error "error e-mailing #{self.recipient.email}: #{e}" + Rails.logger.error "error e-mailing #{recipient.email}: #{e}" end end - if self.recipient.pushover_messages? - self.recipient.pushover!( - :title => "#{Rails.application.name} message from " << - "#{self.author_username}: #{self.subject}", - :message => self.plaintext_body, - :url => self.url, - :url_title => (self.author ? "Reply to #{self.author_username}" : - "View message"), + return if Rails.env.development? + + if recipient.pushover_messages? + recipient.pushover!( + title: "#{Rails.application.name} message from " \ + "#{author_username}: #{subject}", + message: plaintext_body, + url: url, + url_title: (author ? "Reply to #{author_username}" : + "View message") ) end end @@ -110,7 +115,7 @@ def deliver_email_notifications def recipient_username=(username) self.recipient_user_id = nil - if (u = User.find_by(:username => username)) + if (u = User.find_by(username: username)) self.recipient_user_id = u.id @recipient_username = username else @@ -119,19 +124,19 @@ def recipient_username=(username) end def linkified_body - Markdowner.to_html(self.body) + Markdowner.to_html(body) end def plaintext_body # TODO: linkify then strip tags and convert entities back - self.body.to_s + body.to_s end def to_param - self.short_id + short_id end def url - Rails.application.root_url + "messages/#{self.short_id}" + Rails.application.root_url + "messages/#{short_id}" end end diff --git a/benchmarks/lobsters/app/models/mod_note.rb b/benchmarks/lobsters/app/models/mod_note.rb index 410586f8..4c991579 100644 --- a/benchmarks/lobsters/app/models/mod_note.rb +++ b/benchmarks/lobsters/app/models/mod_note.rb @@ -1,17 +1,19 @@ +# typed: false + class ModNote < ApplicationRecord extend TimeAgoInWords belongs_to :moderator, - class_name: "User", - foreign_key: "moderator_user_id", - inverse_of: :moderations + class_name: "User", + foreign_key: "moderator_user_id", + inverse_of: :moderations belongs_to :user, - inverse_of: :mod_notes + inverse_of: :mod_notes - scope :recent, -> { where('created_at >= ?', 1.week.ago).order('created_at desc') } - scope :for, ->(user) { includes(:moderator).where('user_id = ?', user).order('created_at desc') } + scope :recent, -> { where("created_at >= ?", 1.week.ago).order("created_at desc") } + scope :for, ->(user) { includes(:moderator).where(user_id: user).order("created_at desc") } - validates :note, :markeddown_note, presence: true, length: { maximum: 65_535 } + validates :note, :markeddown_note, presence: true, length: {maximum: 65_535} delegate :username, to: :user @@ -28,15 +30,15 @@ def username=(username) def note=(n) self[:note] = n.to_s.strip - self.markeddown_note = self.generated_markeddown + self.markeddown_note = generated_markeddown end def generated_markeddown - Markdowner.to_html(self.note) + Markdowner.to_html(note) end def self.create_from_message(message, moderator) - user = moderator.id == message.recipient.id && message.author ? + user = (moderator.id == message.recipient.id && message.author) ? message.author : message.recipient ModNote.create!( @@ -44,13 +46,55 @@ def self.create_from_message(message, moderator) user: user, created_at: message.created_at, note: <<~NOTE - *#{message.author ? message.author.username : '(System)'} #{message.hat ? message.hat.to_txt : ''}-> #{message.recipient.username}*: #{message.subject} + *#{message.author ? message.author.username : "(System)"} #{message.hat ? message.hat.to_txt : ""}-> #{message.recipient.username}*: #{message.subject} #{message.body} NOTE ) end + def self.record_reparent!(reparent_user, mod, reason) + old_inviter_url = Rails.application.routes.url_helpers.user_url( + reparent_user.invited_by_user, + host: Rails.application.domain + ) + create_without_dupe!( + moderator: mod, + user: reparent_user, + note: "Reparented from [#{reparent_user.invited_by_user.username}](#{old_inviter_url}) to #{mod.username} with reason: #{reason}" + ) + + reparent_user_url = Rails.application.routes.url_helpers.user_url( + reparent_user, + host: Rails.application.domain + ) + create_without_dupe!( + moderator: mod, + user: reparent_user.invited_by_user, + note: "Admin reparented their invitee [#{reparent_user.username}](#{reparent_user_url}) to #{mod.username} with reason: #{reason}" + ) + end + + def self.tattle_on_banned_login(user) + # rubocop:disable Rails/SaveBang + create( + moderator: InactiveUser.inactive_user, + user: user, + note: "Attempted to log in while banned." + ) + # rubocop:enable Rails/SaveBang + end + + def self.tattle_on_deleted_login(user) + # rubocop:disable Rails/SaveBang + create( + moderator: InactiveUser.inactive_user, + user: user, + note: "Attempted to log in after deleting their account." + ) + # rubocop:enable Rails/SaveBang + end + def self.tattle_on_invited(redeemer, invitation_code) invitation = Invitation.find_by(code: invitation_code) return unless invitation @@ -62,44 +106,62 @@ def self.tattle_on_invited(redeemer, invitation_code) host: Rails.application.domain ) redeemer_url = Rails.application.routes.url_helpers.user_url( - sender, + redeemer, host: Rails.application.domain ) create_without_dupe!( moderator: InactiveUser.inactive_user, user: redeemer, created_at: Time.current, - note: "Attempted to redeem invitation code #{invitation.code} while logged in:\n" + - "- sent by: [#{sender.username}](#{sender_url})\n" + - "- created_at: #{invitation.created_at}\n" + - "- used_at: #{invitation.used_at || 'unused'}\n" + - "- email: #{invitation.email}\n" + + note: "Attempted to redeem invitation code #{invitation.code} while logged in:\n" \ + "- sent by: [#{sender.username}](#{sender_url})\n" \ + "- created_at: #{invitation.created_at}\n" \ + "- used_at: #{invitation.used_at || "unused"}\n" \ + "- email: #{invitation.email}\n" \ "- memo: #{invitation.memo}" ) create_without_dupe!( moderator: InactiveUser.inactive_user, user: sender, created_at: Time.current, - note: "Sent invitation #{invitation.code} another user tried to redeem while logged in:\n" + - "- attempted redeemer: [#{redeemer.username}](#{redeemer_url})\n" + - "- created_at: #{invitation.created_at}\n" + - "- used_at: #{invitation.used_at || 'unused'}\n" + - "- email: #{invitation.email}\n" + + note: "Sent invitation #{invitation.code} another user tried to redeem while logged in:\n" \ + "- attempted redeemer: [#{redeemer.username}](#{redeemer_url})\n" \ + "- created_at: #{invitation.created_at}\n" \ + "- used_at: #{invitation.used_at || "unused"}\n" \ + "- email: #{invitation.email}\n" \ "- memo: #{invitation.memo}" ) end + def self.tattle_on_max_depth_limit(user, parent_comment) + parent_author_url = Rails.application.routes.url_helpers.user_url( + parent_comment.user, + host: Rails.application.domain + ) + comment_url = Rails.application.routes.url_helpers.comment_url( + parent_comment, + host: Rails.application.domain + ) + create_without_dupe!( + moderator: InactiveUser.inactive_user, + user: user, + created_at: Time.current, + note: "Hit max comment depth replying to [#{parent_comment.short_id}](#{comment_url}) " \ + "by [#{parent_comment.user.username}](#{parent_author_url})" + ) + end + def self.tattle_on_new_user_tagging!(story) create_without_dupe!( moderator: InactiveUser.inactive_user, user: story.user, created_at: Time.current, - note: "Attempted to submit a story with tag(s) not allowed to new users:\n" + - "- user joined: #{time_ago_in_words(story.user.created_at)}\n" + - "- url: #{story.url}\n" + - "- title: #{story.title}\n" + - "- user_is_author: #{story.user_is_author}\n" + - "- tags: #{story.tags_a.join(' ')}\n" + + note: "Attempted to submit a story with tag(s) not allowed to new users:\n" \ + "- user joined: #{how_long_ago(story.user.created_at)}\n" \ + "- url: #{story.url}\n" \ + "- title: #{story.title}\n" \ + "- user_is_author: #{story.user_is_author}\n" \ + "- tags: #{story.tags_a.join(" ")}\n" \ "- description: #{story.description}\n" ) end @@ -109,12 +171,43 @@ def self.tattle_on_story_domain!(story, reason) moderator: InactiveUser.inactive_user, user: story.user, created_at: Time.current, - note: "Attempted to post a story from a #{reason} domain:\n" + - "- user joined: #{time_ago_in_words(story.user.created_at)}\n" + - "- url: #{story.url}\n" + - "- title: #{story.title}\n" + - "- user_is_author: #{story.user_is_author}\n" + - "- tags: #{story.tags_a.join(' ')}\n" + + note: "Attempted to post a story from a #{reason} domain:\n" \ + "- user joined: #{how_long_ago(story.user.created_at)}\n" \ + "- url: #{story.url}\n" \ + "- title: #{story.title}\n" \ + "- user_is_author: #{story.user_is_author}\n" \ + "- tags: #{story.tags_a.join(" ")}\n" \ + "- description: #{story.description}\n" + ) + end + + def self.tattle_on_story_origin!(story, reason) + create_without_dupe!( + moderator: InactiveUser.inactive_user, + user: story.user, + created_at: Time.current, + note: "Attempted to post a story from a #{reason} origin:\n" \ + "- user joined: #{how_long_ago(story.user.created_at)}\n" \ + "- url: #{story.url}\n" \ + "- origin: #{story.origin.identifier}\n" \ + "- title: #{story.title}\n" \ + "- user_is_author: #{story.user_is_author}\n" \ + "- tags: #{story.tags_a.join(" ")}\n" \ + "- description: #{story.description}\n" + ) + end + + def self.tattle_on_traffic_attribution!(story) + create_without_dupe!( + moderator: InactiveUser.inactive_user, + user: story.user, + created_at: Time.current, + note: "Attempted to submit a URL attributing traffic:\n" \ + "- user joined: #{how_long_ago(story.user.created_at)}\n" \ + "- url: #{story.url}\n" \ + "- title: #{story.title}\n" \ + "- user_is_author: #{story.user_is_author}\n" \ + "- tags: #{story.tags_a.join(" ")}\n" \ "- description: #{story.description}\n" ) end diff --git a/benchmarks/lobsters/app/models/moderation.rb b/benchmarks/lobsters/app/models/moderation.rb index 154ce45a..cd971bd2 100644 --- a/benchmarks/lobsters/app/models/moderation.rb +++ b/benchmarks/lobsters/app/models/moderation.rb @@ -1,73 +1,95 @@ +# typed: false + class Moderation < ApplicationRecord belongs_to :moderator, - :class_name => "User", - :foreign_key => "moderator_user_id", - :inverse_of => :moderations, - :optional => true + class_name: "User", + foreign_key: "moderator_user_id", + inverse_of: :moderations, + optional: true belongs_to :comment, - :optional => true + optional: true belongs_to :domain, - :optional => true + optional: true + belongs_to :origin, + optional: true belongs_to :story, - :optional => true + optional: true belongs_to :tag, - :optional => true + optional: true belongs_to :user, - :optional => true + optional: true belongs_to :category, - :optional => true + optional: true - scope :for, ->(user) { - left_outer_joins(:story, :comment) .where(" - moderations.user_id = ? OR - stories.user_id = ? OR - comments.user_id = ?", user, user, user) + scope :for_user, ->(user) { + left_outer_joins(:story, :comment) + .includes(:moderator, comment: [:user, :story], story: :user) + .where(" + moderations.user_id = ? OR + stories.user_id = ? OR + comments.user_id = ?", user, user, user) + .order(id: :desc) + .limit(20) + } + scope :for_story, ->(story) { + left_outer_joins(:story, :comment) + .includes(:moderator, comment: [:user, :story], story: :user) + .where(" + moderations.user_id = ? OR + stories.user_id = ? OR + comments.user_id = ? OR + moderations.story_id = ? OR + comments.story_id = ? ", + story.user, story.user, story.user, story, story) + .order(id: :desc) + .limit(20) } - validates :action, :reason, length: { maximum: 16_777_215 } + validates :action, :reason, length: {maximum: 16_777_215} + validates :is_from_suggestions, inclusion: {in: [true, false]} validate :one_foreign_key_present after_create :send_message_to_moderated def send_message_to_moderated m = Message.new - m.author_user_id = self.moderator_user_id + m.author_user_id = moderator_user_id # mark as deleted by author so they don't fill up moderator message boxes m.deleted_by_author = true - if self.story - m.recipient_user_id = self.story.user_id - m.subject = "Your story has been edited by " << - (self.is_from_suggestions? ? "user suggestions" : "a moderator") - m.body = "Your story [#{self.story.title}](" << - "#{self.story.comments_url}) has been edited with the following " << - "changes:\n" << - "\n" << - "> *#{self.action}*\n" - - if self.reason.present? - m.body << "\n" << - "The reason given:\n" << - "\n" << - "> *#{self.reason}*\n" << - "\n" << + if story + m.recipient_user_id = story.user_id + m.subject = "Your story has been edited by " + + (is_from_suggestions? ? "user suggestions" : "a moderator") + m.body = "Your story [#{story.title}](" \ + "#{story.comments_url}) has been edited with the following " \ + "changes:\n" \ + "\n" \ + "> *#{action}*\n" + + if reason.present? + m.body << "\n" \ + "The reason given:\n" \ + "\n" \ + "> *#{reason}*\n" \ + "\n" \ "Maybe the guidelines on topicality are useful: https://lobste.rs/about#topicality" end - elsif self.comment - m.recipient_user_id = self.comment.user_id + elsif comment + m.recipient_user_id = comment.user_id m.subject = "Your comment has been moderated" - m.body = "Your comment on [#{self.comment.story.title}](" << - "#{self.comment.story.comments_url}) has been moderated:\n" << - "\n" << - self.comment.comment.split("\n").map {|l| "> " << l }.join("\n") - - if self.reason.present? - m.body << "\n" << - "The reason given:\n" << - "\n" << - "> *#{self.reason}*\n" + m.body = "Your comment on [#{comment.story.title}](" \ + "#{comment.story.comments_url}) has been moderated:\n" \ + "\n" << + comment.comment.split("\n").map { |l| "> #{l}" }.join("\n") + + if reason.present? + m.body << "\n" \ + "The reason given:\n" \ + "\n" \ + "> *#{reason}*\n" end else @@ -77,16 +99,16 @@ def send_message_to_moderated return if m.recipient_user_id == m.author_user_id - m.body << "\n" << + m.body << "\n" \ "*This is an automated message.*" - m.save + m.save! end -protected + protected def one_foreign_key_present - fks = [comment_id, domain_id, story_id, category_id, tag_id, user_id].compact.length + fks = [comment_id, domain_id, origin_id, story_id, category_id, tag_id, user_id].compact.length errors.add(:base, "moderation should be linked to only one object") if fks != 1 end end diff --git a/benchmarks/lobsters/app/models/origin.rb b/benchmarks/lobsters/app/models/origin.rb new file mode 100644 index 00000000..77d244f5 --- /dev/null +++ b/benchmarks/lobsters/app/models/origin.rb @@ -0,0 +1,62 @@ +# typed: false + +# The unique value to identify an Origin is 'identifier', not the tuple (domain, identifier). +# Origin.domain is set from the identifier to support sharing an Origin between Domains. The URLs +# foo.github.io and github.com/foo have two different Domains that both produce the Origin with +# identifier github.com/foo. That Origin's domain is set to github.com. +class Origin < ApplicationRecord + belongs_to :domain, optional: false + has_many :stories + belongs_to :banned_by_user, class_name: "User", inverse_of: false, optional: true + + validates :identifier, presence: true, length: {maximum: 255}, uniqueness: true + validates :stories_count, numericality: {only_integer: true, greater_than_or_equal_to: 0}, presence: true + validates :banned_reason, length: {maximum: 200} + + # weird that this isn't automatic for new records + after_create { Origin.reset_counters(id, :stories) } + + def self./(identifier) + find_by! identifier: + end + + def ban_by_user_for_reason!(banner, reason) + self.banned_at = Time.current + self.banned_by_user_id = banner.id + self.banned_reason = reason + save! + + m = Moderation.new + m.moderator_user_id = banner.id + m.origin = self + m.action = "Banned" + m.reason = reason + m.save! + end + + def unban_by_user_for_reason!(banner, reason) + self.banned_at = nil + self.banned_by_user_id = nil + self.banned_reason = nil + save! + + m = Moderation.new + m.moderator_user_id = banner.id + m.origin = self + m.action = "Unbanned" + m.reason = reason + m.save! + end + + def banned? + banned_at? + end + + def n_submitters + stories.count("distinct user_id") + end + + def to_param + identifier + end +end diff --git a/benchmarks/lobsters/app/models/read_ribbon.rb b/benchmarks/lobsters/app/models/read_ribbon.rb index 7ff23a48..5326ca19 100644 --- a/benchmarks/lobsters/app/models/read_ribbon.rb +++ b/benchmarks/lobsters/app/models/read_ribbon.rb @@ -1,14 +1,28 @@ +# typed: false + class ReadRibbon < ApplicationRecord belongs_to :user belongs_to :story - validates :user, :story, presence: true + validates :is_following, inclusion: {in: [true, false]} + + def is_unread? comment + return false if !user || new_record? + + (comment.created_at > updated_at) && (comment.user_id != user.id) + end + + # I mostly extracted this method so there's an easy seam for /s/fieikd + # For perf the count needs to get pushed up to fetching the list of stories. + def unread_count comments + @unread_count ||= comments.count { |c| is_unread?(c) } + end # don't add callbacks to this model; for performance the read tracking in # StoriesController uses .bump and RepliesController uses update_all, etc. def self.expire_old_ribbons! - self.where("updated_at < ?", 1.year.ago).delete_all + where("updated_at < ?", 1.year.ago).delete_all end def self.hide_replies_for(story_id, user_id) @@ -28,9 +42,9 @@ def self.unhide_replies_for(story_id, user_id) # save without callbacks, validation, or transaction def bump if new_record? - save + save! else - self.update_column(:updated_at, Time.now.utc) + update_column(:updated_at, Time.now.utc) end end end diff --git a/benchmarks/lobsters/app/models/replying_comment.rb b/benchmarks/lobsters/app/models/replying_comment.rb index 4dd3d824..2b7e772a 100644 --- a/benchmarks/lobsters/app/models/replying_comment.rb +++ b/benchmarks/lobsters/app/models/replying_comment.rb @@ -1,3 +1,5 @@ +# typed: false + class ReplyingComment < ApplicationRecord attribute :is_unread, :boolean @@ -6,14 +8,14 @@ class ReplyingComment < ApplicationRecord scope :for_user, ->(user_id) { where(user_id: user_id) .order(comment_created_at: :desc) - .preload(:comment => [:story, :user]) + .preload(comment: [:hat, {story: :user}, :user]) } scope :unread_replies_for, ->(user_id) { for_user(user_id).where(is_unread: true) } scope :comment_replies_for, - ->(user_id) { for_user(user_id).where('parent_comment_id is not null') } - scope :story_replies_for, ->(user_id) { for_user(user_id).where('parent_comment_id is null') } + ->(user_id) { for_user(user_id).where.not(parent_comment_id: nil) } + scope :story_replies_for, ->(user_id) { for_user(user_id).where(parent_comment_id: nil) } -protected + protected # This is a view, not a real table def readonly? diff --git a/benchmarks/lobsters/app/models/saved_story.rb b/benchmarks/lobsters/app/models/saved_story.rb index 9730c6a4..f3111695 100644 --- a/benchmarks/lobsters/app/models/saved_story.rb +++ b/benchmarks/lobsters/app/models/saved_story.rb @@ -1,3 +1,5 @@ +# typed: false + class SavedStory < ApplicationRecord belongs_to :user belongs_to :story @@ -5,7 +7,6 @@ class SavedStory < ApplicationRecord scope :by, ->(user) { where(user: user) } def self.save_story_for_user(story_id, user_id) - SavedStory.where(:user_id => user_id, :story_id => - story_id).first_or_initialize.save! + SavedStory.where(user_id: user_id, story_id: story_id).first_or_initialize.save! end end diff --git a/benchmarks/lobsters/app/models/search.rb b/benchmarks/lobsters/app/models/search.rb index 4b930546..68217d48 100644 --- a/benchmarks/lobsters/app/models/search.rb +++ b/benchmarks/lobsters/app/models/search.rb @@ -1,225 +1,348 @@ +# typed: false + +# results: +# not performed +# nothing found +# invalid: negative w/o positive term +# invalid: multiple domains +# invalid: unknown tag +# results + class Search - include ActiveModel::Validations - include ActiveModel::Conversion - include ActiveModel::AttributeMethods - extend ActiveModel::Naming + attr_reader :q, :what, :order, :page, :searcher + attr_reader :invalid_because, :parse_tree + + # takes untrusted params from controller, so sanitize + def initialize params, user + @q = params[:q] + @parse_tree = if params[:q].present? + SearchParser.new.parse(params[:q]) + else + [] + end + + @what = if %w[stories comments].include? params[:what] + params[:what].to_sym + else + :comments + end - attr_accessor :q, :order - attr_accessor :results, :page, :total_results, :per_page - attr_writer :what + @order = if %w[newest relevance score].include? params[:order] + params[:order].to_sym + else + :newest + end - validates :q, length: { :minimum => 2 } + @page = params[:page].to_i + @page = 1 if @page == 0 - def initialize - @q = "" - @what = "stories" - @order = "newest" + @searcher = user - @page = 1 - @per_page = 20 + @results = nil + @results_count = params[:results_count] || -1 + @invalid_because = nil + end - @results = [] - @total_results = -1 + # returns @results so perform_* can return from calling this + def invalid reason + @invalid_because = reason + @results_count = 0 + @results = searched_model.none end def max_matches - 100 + per_page * 20 end - def persisted? - false + def per_page + 20 end - def to_url_params - [:q, :what, :order].map {|p| "#{p}=#{CGI.escape(self.send(p).to_s)}" }.join("&") + def to_param + { + q: @q, + what: @what, + order: @order, + page: @page + } end def page_count - total = self.total_results.to_i + total = results_count.to_i - if total == -1 || total > self.max_matches - total = self.max_matches + if total == -1 || total > max_matches + total = max_matches end - ((total - 1) / self.per_page.to_i) + 1 + ((total - 1) / per_page.to_i) + 1 end - def what - case @what - when "comments" - "comments" + def perform! + return (@results = searched_model.none) if q.blank? || parse_tree.blank? + if what == :stories + perform_story_search! else - "stories" + perform_comment_search! end end - def with_tags(base, tag_scopes) - base - .joins({ :taggings => :tag }, :user).left_outer_joins(:story_text) - .where(:tags => { :tag => tag_scopes }) - .having("COUNT(stories.id) = ?", tag_scopes.length) - .group("stories.id") + # security: must prevent sql injection + # it assumes SearchParser prevents " + def flatten_title tree + if tree.keys.first == :term + ActiveRecord::Base.connection.quote_string(tree.values.first.to_s) + elsif tree.keys.first == :quoted + '"' + tree.values.first.map(&:values).flatten.join(" ").gsub("'", "\\\\'") + '"' + end end - def with_stories_in_domain(base, domain) - # Get around the fact that case uses === not == - # to compare objects - case base.klass - when ->(b) { b == Story } - base.joins(:domain).left_outer_joins(:story_text) - when ->(b) { b == Comment } - base.joins(story: [:domain]) - else - fail "Can't handle #{base.class}" - end.where('domains.domain = ?', domain) + # security: must prevent sql injection + # strip all nonword except -_' so people can search for contractions like "don't" + # some of these are search operators, some sql injection + # https://mariadb.com/kb/ru/full-text-index-overview/#in-boolean-mode + # surprise: + is not in \p{Punct} + def strip_operators s + s.to_s + .gsub(/[^\p{Word}']/, " ") + .gsub("'", "\\\\'") + .strip end - def with_stories_matching_tags(base, tag_scopes) - story_ids_matching_tags = with_tags( - Story.unmerged.where(is_deleted: false), tag_scopes - ).select(:id).map(&:id) - base.where(story_id: story_ids_matching_tags) + # not security-sensitive, mariadb ignores 1 and 2 character terms and + # stripping them allows the frontend to explain they're ignored + def strip_short_terms(s) + s.to_s.strip.split(/\p{Space}/).filter { _1.size > 2 }.join(" ") end - def search_for_user!(user) - self.results = [] - self.total_results = 0 - - # extract domain query since it must be done separately - domain = nil - tag_scopes = [] - words = self.q.to_s.split(" ").reject {|w| - if (m = w.match(/^domain:(.+)$/)) - domain = m[1] - elsif (m = w.match(/^tag:(.+)$/)) - tag_scopes << m[1] - end - }.join(" ") - - qwords = ActiveRecord::Base.connection.quote_string(words) - - base = nil - - case self.what - when "stories" - base = Story.unmerged.where(is_deleted: false) - if domain.present? - base = with_stories_in_domain(base, domain) - end - - title_match_sql = Arel.sql("MATCH(stories.title) AGAINST('#{qwords}' IN BOOLEAN MODE)") - description_match_sql = - Arel.sql("MATCH(stories.description) AGAINST('#{qwords}' IN BOOLEAN MODE)") - story_text_match_sql = - Arel.sql("MATCH(story_texts.body) AGAINST('#{qwords}' IN BOOLEAN MODE)") - - if qwords.present? - base.where!( - "(#{title_match_sql} OR " + - "#{description_match_sql} OR " + - "#{story_text_match_sql})" + def perform_comment_search! + query = Comment.accessible_to_user(searcher).for_presentation + + terms = [] + n_commenters = 0 + n_domains = 0 + n_submitters = 0 + n_tags = 0 + tags = nil + url = false + title = false + + # array of hashes, type => value(s) + parse_tree.each do |node| + type, value = node.first + case type + when :commenter, :user + n_commenters += 1 + return invalid("A comment only has one commenter") if n_commenters > 1 + query.joins!(:user).where!(users: {username: value.to_s}) + when :domain + n_domains += 1 + return invalid("A story can't be from multiple domains at once") if n_domains > 1 + query.joins!(story: [:domain]).where!(story: {domains: {domain: value.to_s}}) + when :submitter + n_submitters += 1 + return invalid("A story only has one submitter") if n_submitters > 1 + query.joins!(story: :user).where!(story: {users: {username: value.to_s}}) + when :tag + n_tags += 1 + tags ||= Tag.none.select(:id) + tags.or!(Tag.where(tag: value.to_s)) + # TODO unknown tag + when :title + title = true + value = flatten_title value + query.where!( + story: Story.joins(:story_text).where( + "MATCH(story_texts.title) AGAINST ('+#{value}' in boolean mode)" + ) ) - - if tag_scopes.present? - self.results = with_tags(base, tag_scopes) - else - base = base.includes({ :taggings => :tag }, :user).left_outer_joins(:story_text) - self.results = base.select( - ["stories.*", title_match_sql, description_match_sql, story_text_match_sql].join(', ') + when :url + url = true + query + .joins!(:story) + .and!( + Story.where(url: value.to_s) + .or(Story.where(normalized_url: Utils.normalize(value))) ) - end - else - if tag_scopes.present? - self.results = with_tags(base, tag_scopes) - else - self.results = base.includes({ :taggings => :tag }, :user).left_outer_joins(:story_text) - end - end - self.total_results = self.results.dup.count("stories.id") - - case self.order - when "relevance" - if qwords.present? - self.results.order!(Arel.sql("((#{title_match_sql}) * 2) + " + - "((#{description_match_sql}) * 1.5) + " + - "(#{story_text_match_sql}) DESC")) - else - self.results.order!("stories.created_at DESC") - end - when "newest" - self.results.order!("stories.created_at DESC") - when "points" - self.results.order!("score DESC") + when :negated + # TODO + when :quoted + terms.append '"' + strip_operators(value.pluck(:term).join(" ")) + '"' + when :term, :catchall + val = strip_short_terms(strip_operators(value)) + # if punctuation is replaced with a space, this would generate a terms search + # AGAINST('+' in boolean mode) + terms.append val if !val.empty? end + end + if terms.any? + terms_sql = <<~SQL.tr("\n", " ") + MATCH(comment) + AGAINST ('#{terms.map { |s| "+#{s}" }.join(" ")}' in boolean mode) + SQL + query.where! terms_sql + end + if tags + query.where!( + story: Story + .joins(:tags) + .where(tags: tags) + .group("stories.id") + .having("count(distinct tags.id) = #{n_tags}") + ) + end - when "comments" - base = Comment.active - if domain.present? - base = with_stories_in_domain(base.joins(:story), domain) - end - if tag_scopes.present? - base = with_stories_matching_tags(base, tag_scopes) - end - if qwords.present? - base = base.where(Arel.sql("MATCH(comment) AGAINST('#{qwords}' IN BOOLEAN MODE)")) - end - self.results = base.select( - "comments.*, " + - "MATCH(comment) AGAINST('#{qwords}' IN BOOLEAN MODE) AS rel_comment" - ).includes(:user, :story) - self.total_results = self.results.dup.count("comments.id") - - case self.order - when "relevance" - self.results.order!("rel_comment DESC") - when "newest" - self.results.order!("created_at DESC") - when "points" - self.results.order!("score DESC") - end + # don't allow blank searches for all records when strip_ removes all data + if n_commenters == 0 && + n_domains == 0 && + n_submitters == 0 && + n_tags == 0 && + !url && + !title && + terms.empty? + return invalid("No search terms recognized") end + @results_count = query.dup.count # with_tags uses group_by, so count returns a hash - self.total_results = self.total_results.count if self.total_results.is_a? Hash - - if self.page > self.page_count - self.page = self.page_count - end - if self.page < 1 - self.page = 1 + @results_count = @results_count.count if @results_count.is_a? Hash + + case order + when :newest + query.order!(id: :desc) + when :relevance + # relevance is undefined without search terms so sort by score + if terms.any? + query.order!(Arel.sql(terms_sql + " DESC")) + else + query.order!(score: :desc) + end + when :score + query.order!(score: :desc) end - self.results = self.results - .limit(self.per_page) - .offset((self.page - 1) * self.per_page) + query.limit!(per_page) + query.offset!((page - 1) * per_page) + query + end - # if a user is logged in, fetch their votes for what's on the page - if user - case what - when "stories" - votes = Vote.story_votes_by_user_for_story_ids_hash(user.id, self.results.map(&:id)) + def perform_story_search! + query = Story.base(@searcher).for_presentation + + terms = [] + n_domains = 0 + n_submitters = 0 + n_tags = 0 + tags = nil + url = false + title = false + + # array of hashes, type => value(s) + parse_tree.each do |node| + type, value = node.first + case type + when :commenter + return invalid("Doesn't make sense to search Stories by commenter") + when :domain + n_domains += 1 + return invalid("A story can't be from multiple domains at once") if n_domains > 1 + query.joins!(:domain).where!(domains: {domain: value.to_s}) + when :submitter, :user + n_submitters += 1 + return invalid("A story only has one submitter") if n_submitters > 1 + query.joins!(:user).where!(user: {username: value.to_s}) + when :tag + n_tags += 1 + tags ||= Tag.none.select(:id) + tags.or!(Tag.where(tag: value.to_s)) + # TODO unknown tag + when :title + title = true + value = flatten_title value + query.joins!(:story_text).where!( + "MATCH(story_texts.title) AGAINST ('+#{value}' in boolean mode)" + ) + when :url + url = true + query.and!( + Story.where(url: value.to_s) + .or(Story.where(normalized_url: Utils.normalize(value))) + ) + when :negated + # TODO + when :quoted + terms.append '"' + strip_operators(value.pluck(:term).join(" ")) + '"' + when :term, :catchall + val = strip_short_terms(strip_operators(value)) + # if punctuation is replaced with a space, this would generate a terms search + # AGAINST('+' in boolean mode) + terms.append val if !val.empty? + end + end + if terms.any? + terms_sql = <<~SQL.tr("\n", " ") + MATCH(story_texts.title, story_texts.description, story_texts.body) + AGAINST ('#{terms.map { |s| "+#{s}" }.join(", ")}' in boolean mode) + SQL + query.joins!(:story_text).where! terms_sql + end + if tags + # This searches tags by subquery because otherwise Rails recognizes the join against tags and + # thinks the .tags association preload is satisfied, so returned stories will only have the + # searched-for tags. + query.joins!(<<~SQL.tr("\n", "") + inner join ( + select stories.id + from stories + join taggings on taggings.story_id = stories.id + where taggings.tag_id in (#{tags.to_sql}) + group by stories.id + having count(distinct taggings.id) = #{n_tags} + ) as stories_with_tags on stories_with_tags.id = stories.id + SQL + ) + end - self.results.each do |r| - if votes[r.id] - r.vote = votes[r.id] - end - end + # don't allow blank searches for all records when strip_ removes all data + if n_domains == 0 && + n_submitters == 0 && + n_tags == 0 && + !url && + !title && + terms.empty? + return invalid("No search terms recognized") + end - when "comments" - votes = Vote.comment_votes_by_user_for_comment_ids_hash(user.id, self.results.map(&:id)) + @results_count = query.dup.count - self.results.each do |r| - if votes[r.id] - r.current_vote = votes[r.id] - end - end + case order + when :newest + query.order!(id: :desc) + when :relevance + # relevance is undefined without search terms so sort by score + if terms.any? + query.order!(Arel.sql(terms_sql + " desc")) + else + query.order!(score: :desc) end + when :score + query.order!(score: :desc) end - rescue ActiveRecord::StatementInvalid - # more likely the user has entered invalid boolean mode operators than our - # code is broken (not that I really trust this hairy class) - self.results = [] - self.total_results = -1 + query.limit!(per_page) + query.offset!((page - 1) * per_page) + query + end + + def results + @results ||= perform! + end + + def results_count + perform! if @results.nil? + @results_count + end + + def searched_model + (what == :stories) ? Story : Comment end end diff --git a/benchmarks/lobsters/app/models/search_parser.rb b/benchmarks/lobsters/app/models/search_parser.rb new file mode 100644 index 00000000..124a3966 --- /dev/null +++ b/benchmarks/lobsters/app/models/search_parser.rb @@ -0,0 +1,53 @@ +# typed: false + +require "parslet" + +class SearchParser < Parslet::Parser + rule(:space) { match('\s').repeat(1) } + rule(:space?) { space.maybe } + + # this regexp should invert punctuation stripped by Search.strip_operators + rule(:term) { match('[\p{Word}_\\-\']').repeat(1).as(:term) >> space? } + rule(:quoted) { str('"') >> term.repeat(1).as(:quoted) >> str('"') >> space? } + + # User::VALID_USERNAME + rule(:commenter) { str("commenter:") >> match("[@~]").repeat(0, 1) >> match("[A-Za-z0-9_\\-]").repeat(1, 24).as(:commenter) >> space? } + # reproduce the named capture in URL_RE + rule(:domain) { str("domain:") >> match("[A-Za-z0-9_\\-\\.]").repeat(1).as(:domain) >> space? } + # User::VALID_USERNAME + rule(:submitter) { str("submitter:") >> match("[@~]").repeat(0, 1) >> match("[A-Za-z0-9_\\-]").repeat(1, 24).as(:submitter) >> space? } + # reproduce the 'validates :tag, format:' regexp from Tag + rule(:tag) { str("tag:") >> match("[A-Za-z0-9\\-_+]").repeat(1).as(:tag) >> space? } + rule(:title) { str("title:") >> (term | quoted).as(:title) >> space? } + rule(:url) { + ( + str("http") >> str("s").repeat(0, 1) >> str("://") >> + match("[A-Za-z0-9\\-_.:@/()%~?&=#]").repeat(1) + ).as(:url) >> space? + } + # User::VALID_USERNAME + rule(:user) { match("[@~]") >> match("[A-Za-z0-9_\\-]").repeat(1, 24).as(:user) >> space? } + rule(:negated) { str("-") >> (domain | tag | quoted | term).as(:negated) >> space? } + + # catchall consumes ill-structured input + rule(:catchall) { match("\\S").repeat(1).as(:term) >> space? } + + rule(:expression) { + space.maybe >> ( + commenter | + domain | + submitter | + tag | + title | # title before quoted so that doesn't consume the quotes + url | + user | # user must come after commenter and submitter + # term and quoted after operators they would fail to consume + term | + quoted | + negated | + # catchall must be last because it consumes everything + catchall + ).repeat(1) + } + root(:expression) +end diff --git a/benchmarks/lobsters/app/models/short_id.rb b/benchmarks/lobsters/app/models/short_id.rb index b3fe1ba3..e254cceb 100644 --- a/benchmarks/lobsters/app/models/short_id.rb +++ b/benchmarks/lobsters/app/models/short_id.rb @@ -1,15 +1,17 @@ +# typed: false + class ShortId attr_accessor :klass, :generation_attempts def initialize(klass) - self.klass = klass + self.klass = klass self.generation_attempts = 0 end def generate - until (generated_id = candidate_id) && generated_id.valid? do + until (generated_id = candidate_id) && generated_id.valid? self.generation_attempts += 1 - raise 'too many hash collisions' if generation_attempts == 10 + raise "too many hash collisions" if generation_attempts == 10 end generated_id.to_s end @@ -23,7 +25,7 @@ class CandidateId def initialize(klass) self.klass = klass - self.id = generate_id + self.id = generate_id end def to_s diff --git a/benchmarks/lobsters/app/models/stories_paginator.rb b/benchmarks/lobsters/app/models/stories_paginator.rb index 00d52453..c5fbab4f 100644 --- a/benchmarks/lobsters/app/models/stories_paginator.rb +++ b/benchmarks/lobsters/app/models/stories_paginator.rb @@ -1,3 +1,5 @@ +# typed: false + class StoriesPaginator attr_accessor :per_page @@ -13,10 +15,10 @@ def initialize(scope, page = 1, user = nil) def get with_pagination_info @scope.limit(per_page + 1) .offset((@page - 1) * per_page) - .includes(:domain, :user, :taggings => :tag) + .for_presentation end -private + private def with_pagination_info(scope) scope = scope.to_a @@ -30,15 +32,11 @@ def cache_votes(scope) if @user votes = Vote.votes_by_user_for_stories_hash(@user.id, scope.map(&:id)) - hs = HiddenStory.where(:user_id => @user.id, :story_id => - scope.map(&:id)).map(&:story_id) - ss = SavedStory.where(:user_id => @user.id, :story_id => - scope.map(&:id)).map(&:story_id) + hs = HiddenStory.where(user_id: @user.id, story_id: scope.map(&:id)).map(&:story_id) + ss = SavedStory.where(user_id: @user.id, story_id: scope.map(&:id)).map(&:story_id) scope.each do |s| - if votes[s.id] - s.vote = votes[s.id] - end + s.current_vote = votes[s.id] if hs.include?(s.id) s.is_hidden_by_cur_user = true end diff --git a/benchmarks/lobsters/app/models/story.rb b/benchmarks/lobsters/app/models/story.rb index a7937f01..3faf2c79 100644 --- a/benchmarks/lobsters/app/models/story.rb +++ b/benchmarks/lobsters/app/models/story.rb @@ -1,64 +1,82 @@ +# typed: false + class Story < ApplicationRecord belongs_to :user - belongs_to :domain, optional: true + belongs_to :domain, optional: true, counter_cache: true + belongs_to :origin, optional: true, counter_cache: true belongs_to :merged_into_story, - :class_name => "Story", - :foreign_key => "merged_story_id", - :inverse_of => :merged_stories, - :optional => true + class_name: "Story", + foreign_key: "merged_story_id", + inverse_of: :merged_stories, + optional: true has_many :merged_stories, - :class_name => "Story", - :foreign_key => "merged_story_id", - :inverse_of => :merged_into_story, - :dependent => :nullify + class_name: "Story", + foreign_key: "merged_story_id", + inverse_of: :merged_into_story, + dependent: :nullify has_many :taggings, - :autosave => true, - :dependent => :destroy - has_many :suggested_taggings, :dependent => :destroy - has_many :suggested_titles, :dependent => :destroy + autosave: true, + dependent: :destroy + has_many :suggested_taggings, dependent: :destroy + has_many :suggested_tags, source: :story, through: :suggested_taggings, dependent: :destroy + has_many :suggested_titles, dependent: :destroy has_many :suggested_tagging_times, - -> { group(:tag_id).select("count(*) as times, tag_id").order('times desc') }, - :class_name => 'SuggestedTagging', - :inverse_of => :story + -> { group(:tag_id).select("count(*) as times, tag_id").order("times desc") }, + class_name: "SuggestedTagging", + inverse_of: :story has_many :suggested_title_times, - -> { group(:title).select("count(*) as times, title").order('times desc') }, - :class_name => 'SuggestedTitle', - :inverse_of => :story + -> { group(:title).select("count(*) as times, title").order("times desc") }, + class_name: "SuggestedTitle", + inverse_of: :story has_many :comments, - :inverse_of => :story, - :dependent => :destroy - has_many :tags, -> { order('tags.is_media desc, tags.tag') }, :through => :taggings - has_many :votes, -> { where(:comment_id => nil) }, :inverse_of => :story - has_many :voters, -> { where('votes.comment_id' => nil) }, - :through => :votes, - :source => :user - has_many :hidings, :class_name => 'HiddenStory', :inverse_of => :story, :dependent => :destroy - has_many :savings, :class_name => 'SavedStory', :inverse_of => :story, :dependent => :destroy + inverse_of: :story, + dependent: :destroy + has_many :tags, -> { order("tags.is_media desc, tags.tag") }, through: :taggings + has_many :votes, -> { where(comment_id: nil) }, inverse_of: :story + has_many :voters, -> { where("votes.comment_id" => nil) }, + through: :votes, + source: :user + has_many :hidings, class_name: "HiddenStory", inverse_of: :story, dependent: :destroy + has_many :savings, class_name: "SavedStory", inverse_of: :story, dependent: :destroy has_one :story_text, foreign_key: :id, dependent: :destroy, inverse_of: :story - - scope :base, ->(user) { - q = includes(:tags).unmerged - user && user.is_moderator? ? q.preload(:suggested_taggings, :suggested_titles) : q.not_deleted + has_many :links, inverse_of: :from_story, dependent: :destroy + has_many :incoming_links, + class_name: "Link", + inverse_of: :to_story, + dependent: :destroy + + scope :base, ->(user, unmerged: true) { + q = includes(:hidings, :story_text, :user).not_deleted(user).mod_preload?(user) + q = q.unmerged if unmerged + q + } + scope :for_presentation, -> { + includes(:domain, :origin, :hidings, :user, :tags, taggings: :tag) + } + scope :mod_preload?, ->(user) { + user.try(:is_moderator?) ? preload(:suggested_taggings, :suggested_titles) : all } scope :deleted, -> { where(is_deleted: true) } - scope :not_deleted, -> { where(is_deleted: false) } - scope :unmerged, -> { where(:merged_story_id => nil) } + scope :not_deleted, ->(user) { + user.try(:is_moderator?) ? all : where(is_deleted: false).or(where(user_id: user.try(:id).to_i)) + } + scope :unmerged, -> { where(merged_story_id: nil) } scope :positive_ranked, -> { where("score >= 0") } scope :low_scoring, ->(max = 5) { where("score < ?", max) } scope :front_page, -> { hottest.limit(StoriesPaginator::STORIES_PER_PAGE) } scope :hottest, ->(user = nil, exclude_tags = nil) { base(user).not_hidden_by(user) - .filter_tags(exclude_tags || []) - .positive_ranked - .order('hotness') + .filter_tags(exclude_tags || []) + .positive_ranked + .order("hotness") } scope :recent, ->(user = nil, exclude_tags = nil) { base(user).not_hidden_by(user) - .filter_tags(exclude_tags || []) - .low_scoring - .where("created_at >= ?", 10.days.ago) - .where.not(id: front_page.ids) - .order("stories.created_at DESC") + .filter_tags(exclude_tags || []) + .low_scoring + .where("created_at >= ?", 10.days.ago) + .where.not(id: front_page.ids) + .order("stories.created_at DESC") } scope :filter_tags, ->(tags) { tags.empty? ? all : where( @@ -71,7 +89,7 @@ class Story < ApplicationRecord user.nil? ? all : where( Story.arel_table[:id].not_in( Tagging.joins(tag: :tag_filters) - .where(tag_filters: { user_id: user }) + .where(tag_filters: {user_id: user}) .select(:story_id).arel ) ) @@ -81,8 +99,8 @@ class Story < ApplicationRecord } scope :not_hidden_by, ->(user) { user.nil? ? all : where.not( - HiddenStory.select('TRUE') - .where(Arel.sql('hidden_stories.story_id = stories.id')) + HiddenStory.select("TRUE") + .where(Arel.sql("hidden_stories.story_id = stories.id")) .by(user) .arel .exists @@ -91,20 +109,24 @@ class Story < ApplicationRecord scope :saved_by, ->(user) { user.nil? ? none : joins(:savings).merge(SavedStory.by(user)) } - scope :to_tweet, -> { - hottest(nil, Tag.where(tag: 'meta').pluck(:id)) - .where(twitter_id: nil) - .where("score >= 2") - .where("created_at >= ?", 2.days.ago) - .limit(10) + scope :to_mastodon, -> { + hottest(nil, Tag.where(tag: "meta").ids) + .where(mastodon_id: nil) + .where("score >= 2") + .where("created_at >= ?", 2.days.ago) + .limit(10) } - validates :title, length: { :in => 3..150 } - validates :description, length: { :maximum => (64 * 1024) } - validates :url, length: { :maximum => 250, :allow_nil => true } - validates :short_id, presence: true, length: { :maximum => 6 } - validates :markeddown_description, length: { :maximum => 16_777_215, :allow_nil => true } - validates :twitter_id, length: { :maximum => 20, :allow_nil => true } + validates :title, length: {in: 3..150}, presence: true + validates :description, length: {maximum: 65_535} + validates :url, length: {maximum: 250, allow_nil: true} + validates :short_id, presence: true, length: {maximum: 6} + validates :markeddown_description, length: {maximum: 16_777_215, allow_nil: true} + validates :mastodon_id, length: {maximum: 25, allow_nil: true} + validates :twitter_id, length: {maximum: 20, allow_nil: true} + validates :is_deleted, :is_moderated, :user_is_author, :user_is_following, inclusion: {in: [true, false]} + validates :score, :flags, :hotness, :comments_count, presence: true + validates :normalized_url, length: {maximum: 255, allow_nil: true} validates_each :merged_story_id do |record, _attr, value| if value.to_i == record.id @@ -114,6 +136,7 @@ class Story < ApplicationRecord COMMENTABLE_DAYS = 90 FLAGGABLE_DAYS = 14 + DELETEABLE_DAYS = FLAGGABLE_DAYS * 2 # the lowest a score can go FLAGGABLE_MIN_SCORE = -5 @@ -133,63 +156,67 @@ class Story < ApplicationRecord # drop these words from titles when making URLs TITLE_DROP_WORDS = ["", "a", "an", "and", "but", "in", "of", "or", "that", "the", "to"].freeze - # URI.parse is not very lenient, so we can't use it - URL_RE = /\A(?https?):\/\/(?([^\.\/]+\.)+[a-z\-]+)(?:\d+)?(\/|\z)/i.freeze - # Dingbats, emoji, and other graphics https://www.unicode.org/charts/ - GRAPHICS_RE = /[\u{0000}-\u{001F}\u{2190}\u{2192}-\u{27BF}\u{1F000}-\u{1F9FF}]/.freeze + GRAPHICS_RE = /[\u{0000}-\u{001F}\u{2190}\u{2192}-\u{27BF}\u{1F000}-\u{1F9FF}]/ - attr_accessor :editing_from_suggestions, :editor, :fetching_ip, - :is_hidden_by_cur_user, :latest_comment_id, - :is_saved_by_cur_user, :moderation_reason, :previewing, - :seen_previous, :vote + attr_accessor :current_vote, :editing_from_suggestions, :editor, :fetching_ip, + :is_hidden_by_cur_user, :latest_comment_id, + :is_saved_by_cur_user, :moderation_reason, :previewing attr_writer :fetched_response - before_validation :assign_short_id_and_score, :on => :create - before_create :assign_initial_hotness + before_validation :assign_short_id_and_score, on: :create before_save :log_moderation before_save :fix_bogus_chars + before_create :assign_initial_hotness after_create :mark_submitter, :record_initial_upvote - after_save :update_merged_into_story_comments, :recalculate_hotness! + after_save :recreate_links, :update_cached_columns, :update_story_text validate do - if self.url.present? - #already_posted_recently? # Disable because it requires RLIKE, which SQLite doesn't support - check_not_tracking_domain + if url.present? + already_posted_recently? + check_not_banned_domain + check_not_banned_origin check_not_new_domain_from_new_user - errors.add(:url, "is not valid") unless url.match(URL_RE) - elsif self.description.to_s.strip == "" + # This would probably have a too-high false-positive rate, I want to have approvals first. + # check_not_new_origin_from_new_user + check_not_pushcx_stream + errors.add(:url, "is not valid") unless url.match(Utils::URL_RE) + elsif description.to_s.strip == "" errors.add(:description, "must contain text if no URL posted") end - if self.title.starts_with?("Ask") && self.tags_a.include?('ask') - errors.add(:title, " starting 'Ask #{Rails.application.name}' or similar is redundant " << + if title.starts_with?("Ask") && tags_a.include?("ask") + errors.add(:title, " starting 'Ask #{Rails.application.name}' or similar is redundant " \ "with the ask tag.") end - if self.title.match(GRAPHICS_RE) + if title.match(GRAPHICS_RE) errors.add(:title, " may not contain graphic codepoints") end - if !errors.any? && self.url.blank? + if !errors.any? && url.blank? self.user_is_author = true end check_tags end + def self./(short_id) + find_by! short_id: + end + def accepting_comments? - !self.is_gone? && - !self.previewing && - (self.new_record? || self.created_at.after?(COMMENTABLE_DAYS.days.ago)) + !is_gone? && + !previewing && + (new_record? || created_at.after?(COMMENTABLE_DAYS.days.ago)) end def already_posted_recently? - return false unless self.url.present? && self.new_record? + return false unless url.present? && new_record? - if most_recent_similar && most_recent_similar.is_recent? + if most_recent_similar&.is_recent? errors.add(:url, "has already been submitted within the past #{RECENT_DAYS} days") true - elsif most_recent_similar && self.user && self.user.is_new? + elsif user&.is_new? && most_recent_similar errors.add(:url, "cannot be resubmitted by new users") true else @@ -198,9 +225,9 @@ def already_posted_recently? end def check_not_new_domain_from_new_user - return unless self.url.present? && self.new_record? && self.domain + return unless url.present? && new_record? && domain - if self.user && self.user.is_new? && self.domain.stories.not_deleted.count == 0 + if user&.is_new? && domain.stories.not_deleted(nil).count == 0 ModNote.tattle_on_story_domain!(self, "new user with new") errors.add :url, <<-EXPLANATION is an unseen domain from a new user. We restrict this to discourage @@ -210,114 +237,103 @@ def check_not_new_domain_from_new_user end end - def check_not_tracking_domain - return unless self.url.present? && self.new_record? && self.domain + def check_not_new_origin_from_new_user + return unless url.present? && new_record? && domain && origin - if self.domain.banned? - ModNote.tattle_on_story_domain!(self, "banned") - errors.add(:url, "is from banned domain #{self.domain.domain}: #{self.domain.banned_reason}") + if user&.is_new? && origin.stories.not_deleted(nil).count == 0 + ModNote.tattle_on_story_origin!(self, "new user with new") + errors.add :url, <<-EXPLANATION + is from a domain that we know has multiple authors, like GitHub. We haven't + seen links from this origin '#{origin.identifier}' before. + We restrict new users from posting such links to discourage self-promotion and give + you time to learn about topicality. Skirting this with a URL shortener or tweet or something + will probably earn a ban. + EXPLANATION end end - # all stories with similar urls - def self.find_similar_by_url(url) - url = url.to_s.gsub('[', '\\[') - url = url.to_s.gsub(']', '\\]') - urls = [url.to_s.gsub(/(#.*)/, "")] - urls2 = [url.to_s.gsub(/(#.*)/, "")] - urls_with_trailing_pound = [] - - # arxiv html page and its pdf link based off the [arxiv identifier](https://arxiv.org/help/arxiv_identifier) - if /^https?:\/\/(www\d*\.)?arxiv.org/i.match(url) - urls.each do |u| - urls2.push u.gsub(/(arxiv.org\/)abs(\/\d{4}.\d{4,5})/i, '\1pdf\2') - urls2.push u.gsub(/(arxiv.org\/)abs(\/\d{4}.\d{4,5})/i, '\1pdf\2.pdf') - urls2.push u.gsub(/(arxiv.org\/)pdf(\/\d{4}.\d{4,5})(.pdf)?/i, '\1abs\2') - end - urls = urls2.uniq - end + def check_not_banned_domain + return unless url.present? && new_record? && domain - # www.youtube.com - # m.youtube.com - # youtube.com redirects to www.youtube.com - # youtu.be redirects to www.youtube.com - # www.m.youtube.com doesn't work - # www.youtu.be doesn't exist - # m.youtu.be doesn't exist - if /^https?:\/\/((?:www\d*|m)\.)?(youtube\.com|youtu\.be)/i.match(url) - urls.each do |u| - id = /^https?:\/\/(?:(?:m|www)\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([A-z0-9\-_]+)/i - .match(u)[1] - - urls2.push "https://www.youtube.com/watch?v=#{id}" - # In theory, youtube redirects https://youtube.com to https://www.youtube.com - # let's check it just in case - urls2.push "https://youtube.com/watch?v=#{id}" - urls2.push "https://youtu.be/#{id}" - urls2.push "https://m.youtube.com/watch?v=#{id}" - end - urls = urls2.uniq + if domain.banned? + ModNote.tattle_on_story_domain!(self, "banned") + errors.add(:url, "is from banned domain #{domain.domain}: #{domain.banned_reason}") end + end - # https - urls.each do |u| - urls2.push u.gsub(/^http:\/\//i, "https://") - urls2.push u.gsub(/^https:\/\//i, "http://") - end - urls = urls2.uniq - - # trailing slash or index.html - urls.each do |u| - u_without_slash = u.gsub(/\/+\z/, "") - urls2.push u_without_slash - urls2.push u_without_slash + "/" - urls2.push u_without_slash + "/index.htm" - urls2.push u_without_slash + "/index.html" - urls2.push u.gsub(/\/index.html?\z/, "") - end - urls = urls2.uniq + def check_not_banned_origin + return unless url.present? && new_record? && origin - # www prefix - urls.each do |u| - urls2.push u.gsub(/^(https?:\/\/)www\d*\./i) {|_| $1 } - urls2.push u.gsub(/^(https?:\/\/)/i) {|_| "#{$1}www." } + if origin.banned? + ModNote.tattle_on_story_origin!(self, "banned") + errors.add(:url, "is from banned origin #{origin.identifier}: #{origin.banned_reason}") end - urls = urls2.uniq + end + + def check_not_pushcx_stream + return unless url.present? && new_record? && + url.start_with?("https://push.cx/stream", "https://twitch.tv/pushcx") + errors.add(:url, "is too meta, we don't need it twice every week. Details: https://lobste.rs/c/skuxo9") + end + + def comments_closing_soon? + created_at && (created_at - 1.hour).before?(Story::COMMENTABLE_DAYS.days.ago) + end + + # current_vote is the vote loaded for the currently-viewing user + def current_flagged? + current_vote.try(:[], :vote) == -1 + end + + def current_upvoted? + current_vote.try(:[], :vote) == 1 + end - # trailing pound - urls.each do |u| - urls_with_trailing_pound.push u + "#" + def negativity_class + @neg ||= score - flags + if @neg <= -5 + "negative_5" + elsif @neg <= -3 + "negative_3" + elsif @neg <= -1 + "negative_1" + else + "" end + end + # all stories with similar urls + def self.find_similar_by_url(url) # if a previous submission was moderated, return it to block it from being # submitted again - Story - .where(:url => urls) - .or(Story.where("url RLIKE ?", urls_with_trailing_pound.join(".|"))) + Story.where(normalized_url: Utils.normalize(url)) .where("is_deleted = ? OR is_moderated = ?", false, true) + .order(id: :desc) end # doesn't include deleted/moderated/merged stories def similar_stories - return [] unless self.url.present? + return Story.none if url.blank? - @_similar_stories ||= Story.find_similar_by_url(self.url).order("id DESC") + @_similar_stories ||= Story.find_similar_by_url(url).order(id: :desc) # do not include this story itself or any story merged into it - if self.id? - @_similar_stories = @_similar_stories.where.not(id: self.id) - .where('merged_story_id is null or merged_story_id != ?', self.id) + if id? + @_similar_stories = @_similar_stories.where.not(id: id) + .where("merged_story_id is null or merged_story_id != ?", id) end # do not include the story this one is merged into - if self.merged_story_id? - @_similar_stories = @_similar_stories.where('id != ?', self.merged_story_id) + if merged_story_id? + @_similar_stories = @_similar_stories.where.not(id: merged_story_id) end @_similar_stories end def public_similar_stories(user) - #@_public_similar_stories ||= similar_stories.empty? ? [] : similar_stories.base(user) - # We don't have a full-text index any more, so similar stories are hard to calculate... - [] + @_public_similar_stories ||= similar_stories.base(user) + end + + def is_resubmit? + !already_posted_recently? && similar_stories.any? end def most_recent_similar @@ -326,22 +342,22 @@ def most_recent_similar def self.recalculate_all_hotnesses! # do the front page first, since find_each can't take an order - Story.order("id DESC").limit(100).each(&:recalculate_hotness!) - Story.find_each(&:recalculate_hotness!) + Story.order("id DESC").limit(100).each(&:update_cached_columns) + Story.find_each(&:update_cached_columns) true end def archiveorg_url # This will redirect to the latest version they have - "https://web.archive.org/web/3/#{CGI.escape(self.url)}" + "https://web.archive.org/web/3/#{CGI.escape(url)}" end def archivetoday_url - "https://archive.today/#{CGI.escape(self.url)}" + "https://archive.today/#{CGI.escape(url)}" end def ghost_url - "https://ghostarchive.org/search?term=#{CGI.escape(self.url)}" + "https://ghostarchive.org/search?term=#{CGI.escape(url)}" end def as_json(options = {}) @@ -354,27 +370,28 @@ def as_json(options = {}) :score, :score, :flags, - { :comment_count => :comments_count }, - { :description => :markeddown_description }, - { :description_plain => :description }, + {comment_count: :comments_count}, + {description: :markeddown_description}, + {description_plain: :description}, :comments_url, - { :submitter_user => :user }, - { :tags => self.tags.map(&:tag).sort }, + {submitter_user: user.username}, + :user_is_author, + {tags: tags.map(&:tag).sort} ] if options && options[:with_comments] - h.push(:comments => options[:with_comments]) + h.push(comments: options[:with_comments]) end js = {} h.each do |k| if k.is_a?(Symbol) - js[k] = self.send(k) + js[k] = send(k) elsif k.is_a?(Hash) - if k.values.first.is_a?(Symbol) - js[k.keys.first] = self.send(k.values.first) + js[k.keys.first] = if k.values.first.is_a?(Symbol) + send(k.values.first) else - js[k.keys.first] = k.values.first + k.values.first end end end @@ -383,7 +400,7 @@ def as_json(options = {}) end def assign_initial_hotness - self.hotness = self.calculated_hotness + self.hotness = calculated_hotness end def assign_short_id_and_score @@ -394,64 +411,63 @@ def assign_short_id_and_score def calculated_hotness # take each tag's hotness modifier into effect, and give a slight bump to # stories submitted by the author - base = self.tags.sum(:hotness_mod) + (self.user_is_author? && self.url.present? ? 0.25 : 0.0) + base = tags.sum(:hotness_mod) + ((user_is_author? && url.present?) ? 0.25 : 0.0) # give a story's comment votes some weight, ignoring submitter's comments - sum_expression = base < 0 ? "flags * -0.5" : "score + 1" - cpoints = self.merged_comments.where.not(user_id: self.user_id).sum(sum_expression).to_f * 0.5 + cpoints = if base < 0 + 0 + else + merged_comments.where.not(user_id: user_id).sum("comments.score + 1").to_f * 0.5 + end # mix in any stories this one cannibalized - cpoints += self.merged_stories.map(&:score).inject(&:+).to_f + cpoints += merged_stories.map(&:score).inject(&:+).to_f # if a story has many comments but few votes, it's probably a bad story, so # cap the comment points at the number of upvotes - upvotes = self.score + self.flags - if cpoints > upvotes - cpoints = upvotes - end + cpoints = [self.score, cpoints].min # don't immediately kill stories at 0 by bumping up score by one order = Math.log([(score + 1).abs + cpoints, 1].max, 10) - if score > 0 - sign = 1 + sign = if score > 0 + 1 elsif score < 0 - sign = -1 + -1 else - sign = 0 + 0 end - return -((order * sign) + base + - ((self.created_at || Time.current).to_f / HOTNESS_WINDOW)).round(7) + -((order * sign) + base + ((created_at || Time.current).to_f / HOTNESS_WINDOW)).round(7) end def can_be_seen_by_user?(user) - !is_gone? || (user && (user.is_moderator? || user.id == self.user_id)) + !is_gone? || (user && (user.is_moderator? || user.id == user_id)) end def can_have_images? # doesn't test self.editor so a user can't trick a mod into editing a # story to enable an image - self.user.try(:is_moderator?) + user.try(:is_moderator?) end def can_have_suggestions_from_user?(user) - if !user || (user.id == self.user_id) || !user.can_offer_suggestions? + if !user || (user.id == user_id) || !user.can_offer_suggestions? return false end - return false if self.is_moderated? + return false if is_moderated? - self.tags.each {|t| return false if t.privileged? } - return true + tags.each { |t| return false if t.privileged? } + true end # this has to happen just before save rather than in tags_a= because we need # to have a valid user_id; remember it fills .taggings, not .tags def check_tags - u = self.editor || self.user + u = editor || user - if u && u.is_new? && - (unpermitted = self.taggings.filter {|t| !t.tag.permit_by_new_users? }).any? - tags = unpermitted.map {|t| t.tag.tag }.to_sentence + if u&.is_new? && + (unpermitted = Tag.where(id: taggings.map(&:tag_id), permit_by_new_users: false)).any? + tags = unpermitted.map(&:tag).to_sentence errors.add :base, <<-EXPLANATION New users can't submit stories with the tag(s) #{tags} because they're for meta discussion or prone to off-topic stories. @@ -462,40 +478,49 @@ def check_tags return end - self.taggings.each do |t| - if !t.tag.valid_for?(u) + # ignored to manage tags_a for nicer UI and because the n is typically 2-5 tags + # Prosopite.pause + taggings.each do |t| + if !t.tag.can_be_applied_by?(u) && t.tag.privileged? raise "#{u.username} does not have permission to use privileged tag #{t.tag.tag}" + elsif !t.tag.can_be_applied_by?(u) && !t.tag.permit_by_new_users? + errors.add(:base, "New users can't submit #{t.tag.tag} stories, please wait. " \ + "If the tag is appropriate, leaving it off to skirt this restriction is a bad idea.") + ModNote.tattle_on_story_domain!(self, "new user with protected tags") + raise "#{u.username} is too new to use tag #{t.tag.tag}" elsif !t.tag.active? && t.new_record? && !t.marked_for_destruction? # stories can have inactive tags as long as they existed before raise "#{u.username} cannot add inactive tag #{t.tag.tag}" end end - if self.taggings.reject {|t| t.marked_for_destruction? || t.tag.is_media? }.empty? - errors.add(:base, "Must have at least one non-media (PDF, video) " << - "tag. If no tags apply to your content, it probably doesn't " << + # Prosopite.resume + + if taggings.reject { |t| t.marked_for_destruction? || t.tag.is_media? }.empty? + errors.add(:base, "Must have at least one non-media (PDF, video) " \ + "tag. If no tags apply to your content, it probably doesn't " \ "belong here.") end end def comments_path - "#{short_id_path}/#{self.title_as_url}" + "#{short_id_path}/#{title_as_url}" end def comments_url - "#{short_id_url}/#{self.title_as_url}" + "#{short_id_url}/#{title_as_url}" end def description=(desc) self[:description] = desc.to_s.rstrip - self.markeddown_description = self.generated_markeddown_description + self.markeddown_description = generated_markeddown_description end def description_or_story_text(chars = 0) - s = if self.description.present? - self.markeddown_description.gsub(/<[^>]*>/, "") + s = if description.present? + markeddown_description.gsub(/<[^>]*>/, "") else - self.story_text && self.story_text.body + story_text&.body end if chars > 0 && s.to_s.length > chars @@ -507,13 +532,13 @@ def description_or_story_text(chars = 0) end def domain_search_url - "/search?order=newest&q=domain:#{self.domain}" + "/search?order=newest&q=domain:#{domain}" end def fix_bogus_chars # this is needlessly complicated to work around character encoding issues # that arise when doing just self.title.to_s.gsub(160.chr, "") - self.title = self.title.to_s.split("").map {|chr| + self.title = title.to_s.chars.map { |chr| if chr.ord == 160 " " else @@ -525,7 +550,7 @@ def fix_bogus_chars end def generated_markeddown_description - Markdowner.to_html(self.description, allow_images: self.can_have_images?) + Markdowner.to_html(description, allow_images: can_have_images?) end # TODO: race condition: if two votes arrive at the same time, the second one @@ -535,97 +560,100 @@ def update_score_and_recalculate!(score_delta, flag_delta) self.flags += flag_delta Story.connection.execute <<~SQL UPDATE stories SET - score = (select coalesce(sum(vote), 0) from votes where story_id = stories.id and comment_id is null), + score = (select count(*) from votes where story_id = stories.id and comment_id is null and vote = 1) - + -- subtract number of hidings where hider flagged AND didn't comment (comment voting is ignored) + ( + select count(*) from hidden_stories hiding + where + story_id = #{id.to_i} + and hiding.created_at >= str_to_date('#{(created_at - FLAGGABLE_DAYS.days).utc.iso8601}', '%Y-%m-%dT%H:%i:%sZ') + and exists ( -- user flagged + select 1 from votes where hiding.user_id = votes.user_id and votes.story_id = stories.id and vote = -1 + ) + and not exists ( -- user didn't comment + select 1 from comments where hiding.user_id = comments.user_id and comments.story_id = stories.id + ) + ), flags = (select count(*) from votes where story_id = stories.id and comment_id is null and vote = -1), - hotness = #{self.calculated_hotness} - WHERE id = #{self.id.to_i} + hotness = #{calculated_hotness} + WHERE id = #{id.to_i} SQL end def has_suggestions? - self.suggested_taggings.any? || self.suggested_titles.any? + suggested_taggings.any? || suggested_titles.any? end def hider_count - @hider_count ||= HiddenStory.where(:story_id => self.id).count + @hider_count ||= HiddenStory.where(story_id: id).count end - def html_class_for_user - c = [] - if !self.user.is_active? - c.push "inactive_user" - elsif self.user.is_new? - c.push "new_user" - elsif self.user_is_author? - c.push "user_is_author" - end - - c.join("") + def disownable_by_user?(user) + user && user.id == user_id && created_at < DELETEABLE_DAYS.days.ago end def is_flaggable? - if self.created_at && self.score > FLAGGABLE_MIN_SCORE - Time.current - self.created_at <= FLAGGABLE_DAYS.days + if created_at && self.score > FLAGGABLE_MIN_SCORE + Time.current - created_at <= FLAGGABLE_DAYS.days else false end end def is_editable_by_user?(user) - if user && user.is_moderator? - return true - elsif user && user.id == self.user_id - if self.is_moderated? - return false + if user&.id == user_id + if is_moderated? + false else - return (Time.current.to_i - self.created_at.to_i < (60 * MAX_EDIT_MINS)) + created_at.after?(MAX_EDIT_MINS.minutes.ago) end else - return false + false end end def is_gone? - is_deleted? || (self.user.is_banned? && score < 0) + is_deleted? || (user.is_banned? && score < 0) end def is_hidden_by_user?(user) - !!HiddenStory.find_by(:user_id => user.id, :story_id => self.id) + !!HiddenStory.find_by(user_id: user.id, story_id: id) end def is_recent? - self.created_at >= RECENT_DAYS.days.ago + created_at >= RECENT_DAYS.days.ago end def is_saved_by_user?(user) - !!SavedStory.find_by(:user_id => user.id, :story_id => self.id) + !!SavedStory.find_by(user_id: user.id, story_id: id) end def is_unavailable - self.unavailable_at != nil + !unavailable_at.nil? end def is_unavailable=(what) - self.unavailable_at = (what.to_i == 1 && !self.is_unavailable ? Time.current : nil) + self.unavailable_at = ((what.to_i == 1 && !is_unavailable) ? Time.current : nil) end def is_undeletable_by_user?(user) - if user && user.is_moderator? - return true - elsif user && user.id == self.user_id && !self.is_moderated? - return true + if user&.is_moderator? + true + elsif user && user.id == user_id && !is_moderated? + true else - return false + false end end def log_moderation - if self.new_record? || - (!self.editing_from_suggestions && (!self.editor || self.editor.id == self.user_id)) + if new_record? || + (!editing_from_suggestions && (!editor || editor.id == user_id)) return end - all_changes = self.changes.merge(self.tagging_changes) + all_changes = changes.merge(tagging_changes) + all_changes.delete("normalized_url") all_changes.delete("unavailable_at") if !all_changes.any? @@ -633,22 +661,22 @@ def log_moderation end m = Moderation.new - if self.editing_from_suggestions + if editing_from_suggestions m.is_from_suggestions = true else - m.moderator_user_id = self.editor.try(:id) + m.moderator_user_id = editor.try(:id) end - m.story_id = self.id + m.story_id = id - m.action = all_changes.map {|k, v| - if k == "is_deleted" && self.is_deleted? + m.action = all_changes.map { |k, v| + if k == "is_deleted" && is_deleted? "deleted story" - elsif k == "is_deleted" && !self.is_deleted? + elsif k == "is_deleted" && !is_deleted? "undeleted story" elsif k == "merged_story_id" if v[1] - "merged into #{self.merged_into_story.short_id} " << - "(#{self.merged_into_story.title})" + "merged into #{merged_into_story.short_id} " \ + "(#{merged_into_story.title})" else "unmerged from another story" end @@ -657,8 +685,8 @@ def log_moderation end }.join(", ") - m.reason = self.moderation_reason - m.save + m.reason = moderation_reason + m.save! self.is_moderated = true end @@ -668,110 +696,92 @@ def mailing_list_message_id end def mark_submitter - Keystore.increment_value_for("user:#{self.user_id}:stories_submitted") + Keystore.increment_value_for("user:#{user_id}:stories_submitted") end + # unordered, use Comment.thread_sorted_comments for presenting threads def merged_comments - # TODO: make this a normal has_many? - Comment.where(story_id: Story.select(:id).where(merged_story_id: self.id) - .where('merged_story_id is not null') + [self.id]) + return Comment.none unless id # unsaved Stories have no comments + + Comment.joins(:story) + .where(story: {merged_story_id: id}) + .or(Comment.where(story_id: id)) end def merge_story_short_id=(sid) - self.merged_story_id = sid.present? ? Story.where(:short_id => sid).pluck(:id).first : nil + self.merged_story_id = sid.present? ? Story.where(short_id: sid).pick(:id) : nil end def merge_story_short_id - self.merged_story_id ? self.merged_into_story.try(:short_id) : nil - end - - def recalculate_hotness! - update_column :hotness, calculated_hotness + merged_story_id ? merged_into_story.try(:short_id) : nil end def record_initial_upvote - Vote.vote_thusly_on_story_or_comment_for_user_because(1, self.id, nil, self.user_id, nil, false) + Vote.vote_thusly_on_story_or_comment_for_user_because(1, id, nil, user_id, nil, false) end def short_id_path - Rails.application.routes.url_helpers.root_path + "s/#{self.short_id}" + Rails.application.routes.url_helpers.root_path + "s/#{short_id}" end def short_id_url - Rails.application.root_url + "s/#{self.short_id}" + Rails.application.root_url + "s/#{short_id}" end def show_score_to_user?(u) - return true if u && u.is_moderator? - # cast nil to 0, only show score if user hasn't flagged - (!vote || vote[:vote].to_i >= 0) + u&.is_moderator? || !current_flagged? end def tagging_changes - old_tags_a = self.taggings.reject(&:new_record?).map {|tg| tg.tag.tag }.join(" ") - new_tags_a = self.taggings.reject(&:marked_for_destruction?).map {|tg| tg.tag.tag }.join(" ") + old_tags_a = taggings.reject(&:new_record?).map { |tg| tg.tag.tag }.join(" ") + new_tags_a = taggings.reject(&:marked_for_destruction?).map { |tg| tg.tag.tag }.join(" ") if old_tags_a == new_tags_a {} else - { "tags" => [old_tags_a, new_tags_a] } + {"tags" => [old_tags_a, new_tags_a]} end end def tags_a - @_tags_a ||= self.taggings.reject(&:marked_for_destruction?).map {|t| t.tag.tag } + @_tags_a ||= Tag + .where(id: taggings.reject(&:marked_for_destruction?).map { |t| t.tag_id }) + .pluck(:tag) end def tags_a=(new_tag_names_a) - self.taggings.each do |tagging| + @_tags_a = nil + taggings.each do |tagging| if !new_tag_names_a.include?(tagging.tag.tag) tagging.mark_for_destruction end end - new_tag_names_a.uniq.each do |tag_name| - if tag_name.to_s != "" && !self.tags.exists?(:tag => tag_name) - if (t = Tag.active.find_by(:tag => tag_name)) - # we can't lookup whether the user is allowed to use this tag yet - # because we aren't assured to have a user_id by now; we'll do it in - # the validation with check_tags - self.taggings.build(tag_id: t.id) - end - end + new_tags = Tag.where(tag: new_tag_names_a.uniq.compact_blank - tags.pluck(:tag)) + new_tags.each do |t| + # we can't lookup whether the user is allowed to use this tag yet + # because we aren't assured to have a user_id by now; we'll do it in + # the validation with check_tags + taggings.build(tag_id: t.id) end end - def save_suggested_tags_a_for_user!(new_tag_names_a, user) - st = self.suggested_taggings.where(:user_id => user.id) - - st.each do |tagging| - if !new_tag_names_a.include?(tagging.tag.tag) - tagging.destroy - end - end - - st.reload + def preview_tags + Tag.where(id: taggings.map { |t| t.tag_id }) + end - new_tag_names_a.each do |tag_name| - # XXX: AR bug? st.exists?(:tag => tag_name) does not work - if tag_name.to_s != "" && !st.map {|x| x.tag.tag }.include?(tag_name) - if (t = Tag.active.find_by(:tag => tag_name)) && - t.valid_for?(user) - tg = self.suggested_taggings.build - tg.user_id = user.id - tg.tag_id = t.id - tg.save! + def save_suggested_tags_a_for_user!(new_tag_names_a, user) + suggested_taggings.where(user_id: user.id).destroy_all - st.reload - else - next - end - end - end + new_tags = Tag + .active + .where(tag: new_tag_names_a.uniq.compact_blank) + .map { |t| {user: user, tag: t} } + suggested_taggings.create!(new_tags) # if enough users voted on the same set of replacement tags, do it tag_votes = {} - self.suggested_taggings.group_by(&:user_id).each do |_u, stg| + suggested_taggings.group_by(&:user_id).each do |_u, stg| stg.each do |s| tag_votes[s.tag.tag] ||= 0 tag_votes[s.tag.tag] += 1 @@ -785,24 +795,24 @@ def save_suggested_tags_a_for_user!(new_tag_names_a, user) end end - if final_tags.any? && (final_tags.sort != self.tags_a.sort) - Rails.logger.info "[s#{self.id}] promoting suggested tags " << - "#{final_tags.inspect} instead of #{self.tags_a.inspect}" + if final_tags.any? && (final_tags.sort != tags_a.sort) + Rails.logger.info "[s#{id}] promoting suggested tags " \ + "#{final_tags.inspect} instead of #{tags_a.inspect}" self.editor = nil self.editing_from_suggestions = true self.moderation_reason = "Automatically changed from user suggestions" self.tags_a = final_tags.sort - if !self.save - Rails.logger.error "[s#{self.id}] failed auto promoting: " << - self.errors.inspect + if !save + Rails.logger.error "[s#{id}] failed auto promoting: " << + errors.inspect end end end def save_suggested_title_for_user!(title, user) - st = self.suggested_titles.find_by(:user_id => user.id) + st = suggested_titles.find_by(user_id: user.id) if !st - st = self.suggested_titles.build + st = suggested_titles.build st.user_id = user.id end st.title = title @@ -810,22 +820,22 @@ def save_suggested_title_for_user!(title, user) # if enough users voted on the same exact title, save it title_votes = {} - self.suggested_titles.each do |s| + suggested_titles.each do |s| title_votes[s.title] ||= 0 title_votes[s.title] += 1 end - title_votes.sort_by {|_k, v| v }.reverse_each do |kv| + title_votes.sort_by { |_k, v| v }.reverse_each do |kv| if kv[1] >= SUGGESTION_QUORUM - Rails.logger.info "[s#{self.id}] promoting suggested title " << - "#{kv[0].inspect} instead of #{self.title.inspect}" + Rails.logger.info "[s#{id}] promoting suggested title " \ + "#{kv[0].inspect} instead of #{self.title.inspect}" self.editor = nil self.editing_from_suggestions = true self.moderation_reason = "Automatically changed from user suggestions" self.title = kv[0] - if !self.save - Rails.logger.error "[s#{self.id}] failed auto promoting: " << - self.errors.inspect + if !save + Rails.logger.error "[s#{id}] failed auto promoting: " << + errors.inspect end break @@ -835,7 +845,7 @@ def save_suggested_title_for_user!(title, user) def title=(t) # change unicode whitespace characters into real spaces - self[:title] = t.to_s.strip.gsub(/[\.,;:!]*$/, '') + self[:title] = t.to_s.strip.gsub(/[\.,;:!]*$/, "") end def title_as_url @@ -843,12 +853,12 @@ def title_as_url wl = 0 words = [] - self.title - .parameterize - .gsub(/[^a-z0-9]/, "_") - .split("_") - .reject {|z| TITLE_DROP_WORDS.include?(z) } - .each do |w| + title + .parameterize + .gsub(/[^a-z0-9]/, "_") + .split("_") + .reject { |z| TITLE_DROP_WORDS.include?(z) } + .each do |w| if wl + w.length <= max_len words.push w wl += w.length @@ -864,119 +874,166 @@ def title_as_url words.push "_" end - words.join("_").gsub(/_-_/, "-") + words.join("_").gsub("_-_", "-") end def to_param - self.short_id + short_id end def update_availability - if self.is_unavailable && !self.unavailable_at + if is_unavailable && !unavailable_at self.unavailable_at = Time.current - elsif self.unavailable_at && !self.is_unavailable + elsif unavailable_at && !is_unavailable self.unavailable_at = nil end end - def update_comments_count! - comments = self.merged_comments.arrange_for_user(nil) + # this is less evil than it looks because commonmark produces consistent html: + # example + def parsed_links + if markeddown_description.blank? + [] + else + markeddown_description + .scan(/([^<]+)<\/a>/) + .map { |url, title| + Link.new({ + from_story_id: id, + url: url, + title: (url == title) ? nil : title + }) + }.compact + end + + if url.blank? + [] + else + [Link.new({ + from_story_id: id, + url: url, + title: title + })] + end + end + + def recreate_links + Link.recreate_from_story!(self) if saved_change_to_attribute?(:url) || saved_change_to_attribute?(:description) + end + + def update_cached_columns + update_column :comments_count, merged_comments.active.count + merged_into_story&.update_cached_columns - # calculate count after removing deleted comments and threads - self.update_column :comments_count, (comments.count {|c| !c.is_gone? }) - self.update_merged_into_story_comments - self.recalculate_hotness! + update_column :hotness, calculated_hotness end - def update_merged_into_story_comments - if self.merged_into_story - self.merged_into_story.update_comments_count! - end + def update_story_text + return unless saved_change_to_attribute?(:title) || saved_change_to_attribute?(:description) + + # story_text created by cron job, so ignore missing story_text + story_text.try(:update!, title: title, description: description) end # disincentivize content marketers by not appearing to be a source of # significant traffic, but do show referrer a few times so authors can find # their way back def send_referrer? - self.created_at && self.created_at <= 1.hour && self.merged_story_id.nil? + created_at && created_at <= 1.hour && merged_story_id.nil? end - def set_domain(match) - name = match ? match[:domain].sub(/^www\d*\./, '') : nil - self.domain = name ? Domain.where(domain: name).first_or_initialize : nil + def set_domain_and_origin(domain_name) + domain_name&.sub!(/\Awww\d*\.(.+?\..+)/, '\1') # remove www\d* from domain if the url is not like www10.org + if domain_name.present? + self.domain = Domain.where(domain: domain_name).first_or_initialize + self.origin = domain&.find_or_create_origin(url) + else + self.domain = nil + self.origin = nil + end end def url=(u) - super(u.try(:strip)) or return if u.blank? + return if u.blank? + u = u.strip + + # strip out tracking query params + if (match = u.match(/\A([^\?]+)\?(.+)\z/)) + params = match[2].split(/[&\?]/) + # utm_ is google and many others; sk is medium; si is youtube source id + params.reject! { |p| + p.match(/^utm_(source|medium|campaign|term|content|referrer)=|^sk=|^gclid=|^fbclid=|^linkId=|^si=|^trk=/x) + } + params.reject! { |p| + if /^lobsters|^src=lobsters|^ref=lobsters/x.match?(p) + ModNote.tattle_on_traffic_attribution!(self) + true + end + } + u = match[1] << (params.any? ? "?#{params.join("&")}" : "") + end - if (match = u.match(URL_RE)) + if (match = u.match(Utils::URL_RE)) # remove well-known port for http and https if present @url_port = match[:port] - if match[:protocol] == 'http' && match[:port] == ':80' || - match[:protocol] == 'https' && match[:port] == ':443' - u = u[0...match.begin(3)] + u[match.end(3)..-1] + if match[:protocol] == "http" && match[:port] == ":80" || + match[:protocol] == "https" && match[:port] == ":443" + u = u[0...match.begin(3)] + u[match.end(3)..] @url_port = nil end end - set_domain(match) - # strip out tracking query params - if (match = u.match(/\A([^\?]+)\?(.+)\z/)) - params = match[2].split(/[&\?]/) - # utm_ is google and many others; sk is medium - params.reject! {|p| - p.match(/^utm_(source|medium|campaign|term|content|referrer)=|^sk=|^gclid=|^fbclid=/x) - } - u = match[1] << (params.any?? "?" << params.join("&") : "") - end + # set field + super - super(u) + # set related fields + self.normalized_url = Utils.normalize(u) + set_domain_and_origin(match&.[](:domain)) end def url_is_editable_by_user?(user) - if self.new_record? + if new_record? true - elsif user && user.is_moderator? + elsif !is_moderated? && created_at.after?(MAX_EDIT_MINS.minutes.ago) true else - false + user&.is_moderator? end end def url_or_comments_path - self.url.presence || self.comments_path + url.presence || comments_path end def url_or_comments_url - self.url.presence || self.comments_url + url.presence || comments_url end def vote_summary_for(user) r_counts = {} r_whos = {} - votes.includes(user && user.is_moderator? ? :user : nil).find_each do |v| + votes.includes(user&.is_moderator? ? :user : nil).find_each do |v| next if v.vote == 0 r_counts[v.reason.to_s] ||= 0 - r_counts[v.reason.to_s] += v.vote - if user && user.is_moderator? + r_counts[v.reason.to_s] += 1 + if user&.is_moderator? r_whos[v.reason.to_s] ||= [] r_whos[v.reason.to_s].push v.user.username end end - r_counts.keys.sort.map {|k| + r_counts.keys.sort.map { |k| if k == "" "+#{r_counts[k]}" else "#{r_counts[k]} " + (Vote::ALL_STORY_REASONS[k] || k) + - (user && user.is_moderator? ? " (#{r_whos[k].join(', ')})" : "") + ((user && user.is_moderator?) ? " (#{r_whos[k].join(", ")})" : "") end }.join(", ") end def fetched_attributes_html - converted = @fetched_response.body.force_encoding('utf-8') + converted = @fetched_response.body.force_encoding("utf-8") parsed = Nokogiri::HTML(converted.to_s) # parse best title from html tags @@ -1009,12 +1066,12 @@ def fetched_attributes_html .attributes["content"].text if site_name.present? && - site_name.length < title.length && - title[-(site_name.length), site_name.length] == site_name + site_name.length < title.length && + title[-site_name.length, site_name.length] == site_name title = title[0, title.length - site_name.length] # remove title/site name separator - if title.match(/ [ \-\|\u2013] $/) + if / [ \-\|\u2013] $/.match?(title) title = title[0, title.length - 3] end end @@ -1024,7 +1081,7 @@ def fetched_attributes_html @fetched_attributes[:title] = title # strip off common GitHub site + repo owner - @fetched_attributes[:title].sub!(/GitHub - [a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}\//i, '') + @fetched_attributes[:title].sub!(/GitHub - [a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}\//i, "") # attempt to get the canonical url if it can be parsed, # if it is not the domain root path, and if it @@ -1055,8 +1112,8 @@ def fetched_attributes return @fetched_attributes if @fetched_attributes @fetched_attributes = { - :url => self.url, - :title => "", + url: url, + title: "" } # security: do not connect to arbitrary user-submitted ports @@ -1072,7 +1129,7 @@ def fetched_attributes s.ssl_verify = false headers = { "User-agent" => "#{Rails.application.domain} for #{fetching_ip}", - "Referer" => Rails.application.domain, + "Referer" => Rails.application.domain } res = s.fetch(url, :get, nil, nil, headers, 3) @fetched_response = res @@ -1080,23 +1137,29 @@ def fetched_attributes case @fetched_response["content-type"] when /pdf/ - return fetched_attributes_pdf + fetched_attributes_pdf else - return fetched_attributes_html + fetched_attributes_html end rescue - return @fetched_attributes + @fetched_attributes end end -private + def self.title_maximum_length + validators_on(:title) + .find { |v| v.is_a? ActiveRecord::Validations::LengthValidator } + .options[:maximum] + end + + private def valid_canonical_uri?(url) ucu = URI.parse(url) new_page = ucu && - ucu.scheme.present? && - ucu.host.present? && - ucu.path != "/" + ucu.scheme.present? && + ucu.host.present? && + ucu.path != "/" return false unless new_page diff --git a/benchmarks/lobsters/app/models/story_repository.rb b/benchmarks/lobsters/app/models/story_repository.rb index 1358b99e..fa288646 100644 --- a/benchmarks/lobsters/app/models/story_repository.rb +++ b/benchmarks/lobsters/app/models/story_repository.rb @@ -1,3 +1,5 @@ +# typed: false + class StoryRepository def initialize(user = nil, params = {}) @user = user @@ -5,7 +7,7 @@ def initialize(user = nil, params = {}) end def categories(cats) - tagged_story_ids = Tagging.select(:story_id).where(tag_id: Tag.where(category: cats).pluck(:id)) + tagged_story_ids = Tagging.select(:story_id).where(tag_id: Tag.where(category: cats).select(:id)) Story.base(@user).positive_ranked.where(id: tagged_story_ids).order(created_at: :desc) end @@ -13,7 +15,7 @@ def categories(cats) def hottest hottest = Story.base(@user).positive_ranked.not_hidden_by(@user) hottest = hottest.filter_tags(@params[:exclude_tags] || []) - hottest.order('hotness') + hottest.order("hotness") end def hidden @@ -26,31 +28,21 @@ def newest def active Story.base(@user) - .where.not(id: Story.hidden_by(@user).pluck(:id)) + .where.not(id: Story.hidden_by(@user).select(:id)) .filter_tags(@params[:exclude_tags] || []) .select('stories.*, ( select max(comments.id) from comments where comments.story_id = stories.id ) as latest_comment_id') + # CHANGE: .where('created_at >= ?', 3.days.ago) .order('latest_comment_id desc') end def newest_by_user(user) - if @user == user - stories = Story.includes(:tags).not_deleted.left_joins(:merged_stories) - unmerged = stories.unmerged.where(user_id: user.id) - merged_into_others = stories.where(merged_stories_stories: { user_id: user.id }) - - unmerged.or(merged_into_others).order(id: :desc) - else - Story.base(@user).where(user_id: user.id).order("stories.id DESC") - end - end - - def newest_including_deleted_by_user(user) - Story.includes(:tags).unmerged.where(user_id: user.id).order(id: :desc) + # Story.base without unmerged scope + Story.where(user: user).includes(:tags).not_deleted(@user).mod_preload?(@user).order(id: :desc) end def saved @@ -64,8 +56,9 @@ def tagged(tags) end def top(length) - top = Story.base(@user).where("created_at >= (DATETIME('now', '- " << - "#{length[:dur]} #{length[:intv].upcase}'))") + # CHANGE: + top = Story.base(@user).where("created_at >= DATE('now', '- " \ + "#{length[:dur]} #{length[:intv].downcase}')") top.order("score DESC") end end diff --git a/benchmarks/lobsters/app/models/story_text.rb b/benchmarks/lobsters/app/models/story_text.rb index 0740b8c7..0a5f558e 100644 --- a/benchmarks/lobsters/app/models/story_text.rb +++ b/benchmarks/lobsters/app/models/story_text.rb @@ -1,18 +1,23 @@ +# typed: false + class StoryText < ApplicationRecord self.primary_key = :id belongs_to :story, foreign_key: :id, inverse_of: :story_text - validates :body, presence: true, length: { :maximum => 16_777_215 } + validates :title, presence: true, length: {maximum: 150} + validates :description, :body, length: {maximum: 16_777_215} - def self.fill_cache!(story) - return nil unless story.url.present? + def body=(s) + # pass nil, truncate to column limit https://mariadb.com/kb/en/mediumtext/ + super(s ? s[...(2**24 - 1)] : s) + end + def self.fill_cache!(story) return true if StoryText.where(id: story).exists? - text = DiffBot.get_story_text(story) - # not create! because body may be too long - StoryText.create id: story.id, body: text + body = DiffBot.get_story_text(story) + StoryText.create! id: story.id, title: story.title, description: story.description, body: body end def self.cached?(story, &blk) @@ -25,6 +30,6 @@ def self.cached?(story, &blk) return false end - self.where(id: story).exists? + where(id: story).exists? end end diff --git a/benchmarks/lobsters/app/models/suggested_tagging.rb b/benchmarks/lobsters/app/models/suggested_tagging.rb index 41a20d5a..578850a5 100644 --- a/benchmarks/lobsters/app/models/suggested_tagging.rb +++ b/benchmarks/lobsters/app/models/suggested_tagging.rb @@ -1,3 +1,5 @@ +# typed: false + class SuggestedTagging < ApplicationRecord belongs_to :story belongs_to :tag diff --git a/benchmarks/lobsters/app/models/suggested_title.rb b/benchmarks/lobsters/app/models/suggested_title.rb index 3fa0ddf3..4c22fa0d 100644 --- a/benchmarks/lobsters/app/models/suggested_title.rb +++ b/benchmarks/lobsters/app/models/suggested_title.rb @@ -1,4 +1,8 @@ +# typed: false + class SuggestedTitle < ApplicationRecord belongs_to :story belongs_to :user + + validates :title, length: {maximum: 150}, presence: true end diff --git a/benchmarks/lobsters/app/models/tag.rb b/benchmarks/lobsters/app/models/tag.rb index 73688620..254dd0b0 100644 --- a/benchmarks/lobsters/app/models/tag.rb +++ b/benchmarks/lobsters/app/models/tag.rb @@ -1,54 +1,57 @@ +# typed: false + class Tag < ApplicationRecord belongs_to :category - has_many :taggings, :dependent => :delete_all - has_many :stories, :through => :taggings - has_many :tag_filters, :dependent => :destroy + has_many :taggings, dependent: :delete_all + has_many :stories, through: :taggings + has_many :tag_filters, dependent: :destroy has_many :filtering_users, - :class_name => "User", - :through => :tag_filters, - :source => :user, - :dependent => :delete_all + class_name: "User", + through: :tag_filters, + source: :user, + dependent: :delete_all after_save :log_modifications attr_accessor :edit_user_id, :stories_count attr_writer :filtered_count - validates :tag, length: { maximum: 25 }, presence: true, - uniqueness: { case_sensitive: true }, - format: { with: /\A[A-Za-z0-9_\-\+]+\z/ } - validates :description, length: { maximum: 100 } - validates :hotness_mod, inclusion: { in: -10..10 } - validates :permit_by_new_users, :privileged, inclusion: { in: [true, false] } + validates :tag, length: {maximum: 25}, presence: true, + uniqueness: {case_sensitive: true}, + format: {with: /\A[A-Za-z0-9_\-\+]+\z/} + validates :description, length: {maximum: 100} + validates :hotness_mod, inclusion: {in: -10..10} + validates :permit_by_new_users, :privileged, :active, :is_media, + inclusion: {in: [true, false]} scope :active, -> { where(active: true) } scope :not_permitted_for_new_users, -> { where(permit_by_new_users: false) } scope :related, ->(tag) { active .joins(:taggings) - .where(taggings: { story_id: Tagging.where(tag: tag).select(:story_id) }) + .where(taggings: {story_id: Tagging.where(tag: tag).select(:story_id)}) .where.not(id: [tag, 67]) # 67 = programming, the catch-all .where.not(is_media: true) .group(:id) - .order(Arel.sql('COUNT(*) desc')) + .order(Arel.sql("COUNT(*) desc")) .limit(8) } def to_param - self.tag + tag end def self.all_with_filtered_counts_for(user) counts = TagFilter.group(:tag_id).count - Tag.active.order(:tag).select {|t| t.valid_for?(user) }.map {|t| + Tag.active.order(:tag).select { |t| t.can_be_applied_by?(user) }.map { |t| t.filtered_count = counts[t.id].to_i t } end def category_name - self.category && self.category.category + category&.category end def category_name=(category) @@ -56,35 +59,36 @@ def category_name=(category) end def css_class - "tag tag_#{self.tag}" << (self.is_media?? " tag_is_media" : "") + "tag tag_#{tag}" << (is_media? ? " tag_is_media" : "") end def user_can_filter?(user) - self.active? && (!self.privileged? || user.try(:is_moderator?)) + active? && (!privileged? || user.try(:is_moderator?)) end - def valid_for?(user) - if self.privileged? + def can_be_applied_by?(user) + if privileged? !!user.try(:is_moderator?) + # do include tags they can't use so they submit and get error else true end end def filtered_count - @filtered_count ||= TagFilter.where(:tag_id => self.id).count + @filtered_count ||= TagFilter.where(tag_id: id).count end def log_modifications Moderation.create do |m| - if self.id_previously_changed? - m.action = 'Created new tag ' + self.attributes.map {|f, c| "with #{f} '#{c}'" }.join(', ') + m.action = if id_previously_changed? + "Created new tag " + attributes.map { |f, c| "with #{f} '#{c}'" }.join(", ") else - m.action = "Updating tag #{self.tag}, " + self.saved_changes - .map {|f, c| "changed #{f} from '#{c[0]}' to '#{c[1]}'" } .join(', ') + "Updating tag #{tag}, " + saved_changes + .map { |f, c| "changed #{f} from '#{c[0]}' to '#{c[1]}'" }.join(", ") end m.moderator_user_id = @edit_user_id - m.tag_id = self.id + m.tag_id = id end end end diff --git a/benchmarks/lobsters/app/models/tag_filter.rb b/benchmarks/lobsters/app/models/tag_filter.rb index ed0c62b4..ae9e9301 100644 --- a/benchmarks/lobsters/app/models/tag_filter.rb +++ b/benchmarks/lobsters/app/models/tag_filter.rb @@ -1,3 +1,5 @@ +# typed: false + class TagFilter < ApplicationRecord belongs_to :tag belongs_to :user diff --git a/benchmarks/lobsters/app/models/tagging.rb b/benchmarks/lobsters/app/models/tagging.rb index 73060ac9..ecf616b9 100644 --- a/benchmarks/lobsters/app/models/tagging.rb +++ b/benchmarks/lobsters/app/models/tagging.rb @@ -1,4 +1,6 @@ +# typed: false + class Tagging < ApplicationRecord - belongs_to :tag, :inverse_of => :taggings - belongs_to :story, :inverse_of => :taggings + belongs_to :tag, inverse_of: :taggings + belongs_to :story, inverse_of: :taggings end diff --git a/benchmarks/lobsters/app/models/user.rb b/benchmarks/lobsters/app/models/user.rb index b6fc1d76..1573e5b1 100644 --- a/benchmarks/lobsters/app/models/user.rb +++ b/benchmarks/lobsters/app/models/user.rb @@ -1,126 +1,145 @@ +# typed: false + class User < ApplicationRecord - has_many :stories, -> { includes :user }, :inverse_of => :user + has_many :stories, -> { includes :user }, inverse_of: :user has_many :comments, - :inverse_of => :user, - :dependent => :restrict_with_exception + inverse_of: :user, + dependent: :restrict_with_exception has_many :sent_messages, - :class_name => "Message", - :foreign_key => "author_user_id", - :inverse_of => :author, - :dependent => :restrict_with_exception + class_name: "Message", + foreign_key: "author_user_id", + inverse_of: :author, + dependent: :restrict_with_exception has_many :received_messages, - :class_name => "Message", - :foreign_key => "recipient_user_id", - :inverse_of => :recipient, - :dependent => :restrict_with_exception - has_many :tag_filters, :dependent => :destroy + class_name: "Message", + foreign_key: "recipient_user_id", + inverse_of: :recipient, + dependent: :restrict_with_exception + has_many :tag_filters, dependent: :destroy has_many :tag_filter_tags, - :class_name => "Tag", - :through => :tag_filters, - :source => :tag, - :dependent => :delete_all + class_name: "Tag", + through: :tag_filters, + source: :tag, + dependent: :delete_all belongs_to :invited_by_user, - :class_name => "User", - :inverse_of => false, - :optional => true + class_name: "User", + inverse_of: false, + optional: true belongs_to :banned_by_user, - :class_name => "User", - :inverse_of => false, - :optional => true + class_name: "User", + inverse_of: false, + optional: true belongs_to :disabled_invite_by_user, - :class_name => "User", - :inverse_of => false, - :optional => true - has_many :invitations, :dependent => :destroy + class_name: "User", + inverse_of: false, + optional: true + has_many :invitations, dependent: :destroy has_many :mod_notes, - :inverse_of => :user, - :dependent => :restrict_with_exception + inverse_of: :user, + dependent: :restrict_with_exception has_many :moderations, - :inverse_of => :moderator, - :dependent => :restrict_with_exception - has_many :votes, :dependent => :destroy - has_many :voted_stories, -> { where('votes.comment_id' => nil) }, - :through => :votes, - :source => :story + inverse_of: :moderator, + dependent: :restrict_with_exception + has_many :votes, dependent: :destroy + has_many :voted_stories, -> { where("votes.comment_id" => nil) }, + through: :votes, + source: :story has_many :upvoted_stories, - -> { - where('votes.comment_id' => nil, 'votes.vote' => 1) - .where('stories.user_id != votes.user_id') - }, - :through => :votes, - :source => :story - has_many :hats, :dependent => :destroy - has_many :wearable_hats, -> { where('doffed_at is null') }, - :class_name => "Hat", - :inverse_of => :user - - has_secure_password + -> { + where("votes.comment_id" => nil, "votes.vote" => 1) + .where("stories.user_id != votes.user_id") + }, + through: :votes, + source: :story + has_many :hats, dependent: :destroy + has_many :wearable_hats, -> { where(doffed_at: nil) }, + class_name: "Hat", + inverse_of: :user + + # As of Rails 8.0, `has_secure_password` generates a `password_reset_token` + # method that shadows the explicit `password_reset_token` attribute. + # So we need to explictily disable that. + has_secure_password(reset_token: false) typed_store :settings do |s| - s.string :prefers_color_scheme, :default => "system" - s.boolean :email_notifications, :default => false - s.boolean :email_replies, :default => false - s.boolean :pushover_replies, :default => false + s.string :prefers_color_scheme, default: "system" + s.boolean :email_notifications, default: false + s.boolean :email_replies, default: false + s.boolean :pushover_replies, default: false s.string :pushover_user_key - s.boolean :email_messages, :default => false - s.boolean :pushover_messages, :default => false - s.boolean :email_mentions, :default => false - s.boolean :show_avatars, :default => true - s.boolean :show_story_previews, :default => false - s.boolean :show_submitted_story_threads, :default => false + s.boolean :email_messages, default: false + s.boolean :pushover_messages, default: false + s.boolean :email_mentions, default: false + s.boolean :show_avatars, default: true + s.boolean :show_email, default: false + s.boolean :show_story_previews, default: false + s.boolean :show_submitted_story_threads, default: false s.string :totp_secret s.string :github_oauth_token s.string :github_username - s.string :twitter_oauth_token - s.string :twitter_oauth_token_secret - s.string :twitter_username + s.string :mastodon_instance + s.string :mastodon_oauth_token + s.string :mastodon_username s.any :keybase_signatures, array: true s.string :homepage end - validates :prefers_color_scheme, inclusion: %w(system light dark) + validates :prefers_color_scheme, inclusion: %w[system light dark] validates :email, - :length => { :maximum => 100 }, - :format => { :with => /\A[^@ ]+@[^@ ]+\.[^@ ]+\Z/ }, - :uniqueness => { :case_sensitive => false } + length: {maximum: 100}, + format: {with: /\A[^@ ]+@[^@ ]+\.[^@ ]+\Z/}, + uniqueness: {case_sensitive: false} validates :homepage, - :format => { - :with => /\A(?:https?|gemini|gopher):\/\/[^\/\s]+\.[^.\/\s]+(\/.*)?\Z/, - }, - :allow_blank => true + format: { + with: /\A(?:https?|gemini|gopher):\/\/[^\/\s]+\.[^.\/\s]+(\/.*)?\Z/ + }, + allow_blank: true - validates :password, :presence => true, :on => :create + validates :password, presence: true, on: :create - VALID_USERNAME = /[A-Za-z0-9][A-Za-z0-9_-]{0,24}/.freeze + VALID_USERNAME = /[A-Za-z0-9][A-Za-z0-9_-]{0,24}/ validates :username, - :format => { :with => /\A#{VALID_USERNAME}\z/ }, - :length => { :maximum => 50 }, - :uniqueness => { :case_sensitive => false } - + format: {with: /\A#{VALID_USERNAME}\z/o}, + length: {maximum: 50}, + uniqueness: {case_sensitive: false} + validate :underscores_and_dashes_in_username validates :password_reset_token, - :length => { :maximum => 75 } + length: {maximum: 75} validates :session_token, - :length => { :maximum => 75 } + length: {maximum: 75} validates :about, - :length => { :maximum => 16_777_215 } + length: {maximum: 16_777_215} validates :rss_token, - :length => { :maximum => 75 } + length: {maximum: 75} validates :mailing_list_token, - :length => { :maximum => 75 } + length: {maximum: 75} validates :banned_reason, - :length => { :maximum => 200 } + length: {maximum: 200} validates :disabled_invite_reason, - :length => { :maximum => 200 } + length: {maximum: 200} + + validates :show_email, :is_admin, :is_moderator, :pushover_mentions, + inclusion: {in: [true, false]} + + validates :session_token, + allow_blank: true, + presence: true + + validates :karma, + presence: true + + validates :settings, + length: {maximum: 16_777_215} validates_each :username do |record, attr, value| - if BANNED_USERNAMES.include?(value.to_s.downcase) || value.starts_with?('tag-') + if BANNED_USERNAMES.include?(value.to_s.downcase) || value.starts_with?("tag-") record.errors.add(attr, "is not permitted") end end - scope :active, -> { where(:banned_at => nil, :deleted_at => nil) } + scope :active, -> { where(banned_at: nil, deleted_at: nil) } scope :moderators, -> { where(' is_moderator = True OR @@ -129,15 +148,15 @@ class User < ApplicationRecord } before_save :check_session_token - before_validation :on => :create do - self.create_rss_token - self.create_mailing_list_token + before_validation on: :create do + create_rss_token + create_mailing_list_token end BANNED_USERNAMES = ["admin", "administrator", "contact", "fraud", "guest", "help", "hostmaster", "lobster", "lobsters", "mailer-daemon", "moderator", "moderators", "nobody", "postmaster", "root", "security", "support", - "sysop", "webmaster", "enable", "new", "signup",].freeze + "sysop", "webmaster", "enable", "new", "signup"].freeze # days old accounts are considered new for NEW_USER_DAYS = 70 @@ -160,6 +179,18 @@ class User < ApplicationRecord # minimum number of submitted stories before checking self promotion MIN_STORIES_CHECK_SELF_PROMOTION = 2 + def underscores_and_dashes_in_username + username_regex = username.gsub(/_|-/, "[-_]") + return unless username_regex.include?("[-_]") + + collisions = User.where("username REGEXP ?", username_regex).where.not(id: id) + errors.add(:username, "is already in use (perhaps swapping _ and -)") if collisions.any? + end + + def self./(username) + find_by! username: + end + def self.username_regex_s "/^" + VALID_USERNAME.to_s.gsub(/(\?-mix:|\(|\))/, "") + "$/" end @@ -169,84 +200,76 @@ def as_json(_options = {}) :username, :created_at, :is_admin, - :is_moderator, + :is_moderator ] - if !self.is_admin? + if !is_admin? attrs.push :karma end attrs.push :homepage, :about - h = super(:only => attrs) + h = super(only: attrs) - h[:avatar_url] = self.avatar_url - h[:invited_by_user] = User.where(id: invited_by_user_id).pluck(:username).first + h[:avatar_url] = avatar_url + h[:invited_by_user] = User.where(id: invited_by_user_id).pick(:username) - if self.github_username.present? - h[:github_username] = self.github_username + if github_username.present? + h[:github_username] = github_username end - if self.twitter_username.present? - h[:twitter_username] = self.twitter_username + if mastodon_username.present? + h[:mastodon_username] = mastodon_username end - if self.keybase_signatures.present? - h[:keybase_signatures] = self.keybase_signatures + if keybase_signatures.present? + h[:keybase_signatures] = keybase_signatures end h end def authenticate_totp(code) - totp = ROTP::TOTP.new(self.totp_secret) + totp = ROTP::TOTP.new(totp_secret) totp.verify(code) end def avatar_path(size = 100) ActionController::Base.helpers.image_path( - "/avatars/#{self.username}-#{size}.png", + "/avatars/#{username}-#{size}.png", skip_pipeline: true ) end def avatar_url(size = 100) ActionController::Base.helpers.image_url( - "/avatars/#{self.username}-#{size}.png", + "/avatars/#{username}-#{size}.png", skip_pipeline: true ) end - def average_karma - if self.karma == 0 - 0 - else - self.karma.to_f / (self.stories_submitted_count + self.comments_posted_count) - end - end - def disable_invite_by_user_for_reason!(disabler, reason) User.transaction do self.disabled_invite_at = Time.current self.disabled_invite_by_user_id = disabler.id self.disabled_invite_reason = reason - self.save! + save! msg = Message.new msg.deleted_by_author = true msg.author_user_id = disabler.id - msg.recipient_user_id = self.id + msg.recipient_user_id = id msg.subject = "Your invite privileges have been revoked" - msg.body = "The reason given:\n" << - "\n" << - "> *#{reason}*\n" << - "\n" << - "*This is an automated message.*" + msg.body = "The reason given:\n" \ + "\n" \ + "> *#{reason}*\n" \ + "\n" \ + "*This is an automated message.*" msg.save! m = Moderation.new m.moderator_user_id = disabler.id - m.user_id = self.id + m.user_id = id m.action = "Disabled invitations" m.reason = reason m.save! @@ -261,12 +284,12 @@ def ban_by_user_for_reason!(banner, reason) self.banned_by_user_id = banner.id self.banned_reason = reason - BanNotification.notify(self, banner, reason) unless self.deleted_at? - self.delete! + BanNotificationMailer.notify(self, banner, reason).deliver_now unless deleted_at? + delete! m = Moderation.new m.moderator_user_id = banner.id - m.user_id = self.id + m.user_id = id m.action = "Banned" m.reason = reason m.save! @@ -285,64 +308,64 @@ def can_flag?(obj) elsif obj.is_a?(Story) if obj.is_flaggable? return true - elsif obj.vote == -1 + elsif obj.current_flagged? # user can unvote return true end elsif obj.is_a?(Comment) && obj.is_flaggable? - return self.karma >= MIN_KARMA_TO_FLAG + return karma >= MIN_KARMA_TO_FLAG end false end def can_invite? - !self.is_new? && !banned_from_inviting? && self.can_submit_stories? + !banned_from_inviting? && can_submit_stories? end def can_offer_suggestions? - !self.is_new? && (self.karma >= MIN_KARMA_TO_SUGGEST) + !is_new? && (karma >= MIN_KARMA_TO_SUGGEST) end def can_see_invitation_requests? - can_invite? && (self.is_moderator? || - (self.karma >= MIN_KARMA_FOR_INVITATION_REQUESTS)) + can_invite? && (is_moderator? || + (karma >= MIN_KARMA_FOR_INVITATION_REQUESTS)) end def can_submit_stories? - self.karma >= MIN_KARMA_TO_SUBMIT_STORIES + karma >= MIN_KARMA_TO_SUBMIT_STORIES end def check_session_token - if self.session_token.blank? - self.roll_session_token + if session_token.blank? + roll_session_token end end def create_mailing_list_token - if self.mailing_list_token.blank? + if mailing_list_token.blank? self.mailing_list_token = Utils.random_str(10) end end def create_rss_token - if self.rss_token.blank? + if rss_token.blank? self.rss_token = Utils.random_str(60) end end def comments_posted_count - Keystore.value_for("user:#{self.id}:comments_posted").to_i + Keystore.value_for("user:#{id}:comments_posted").to_i end def comments_deleted_count - Keystore.value_for("user:#{self.id}:comments_deleted").to_i + Keystore.value_for("user:#{id}:comments_deleted").to_i end def fetched_avatar(size = 100) gravatar_url = "https://www.gravatar.com/avatar/" << - Digest::MD5.hexdigest(self.email.strip.downcase) << - "?r=pg&d=identicon&s=#{size}" + Digest::MD5.hexdigest(email.strip.downcase) << + "?r=pg&d=identicon&s=#{size}" begin s = Sponge.new @@ -359,81 +382,73 @@ def fetched_avatar(size = 100) end def refresh_counts! - Keystore.put("user:#{self.id}:stories_submitted", self.stories.count) - Keystore.put("user:#{self.id}:comments_posted", self.comments.active.count) - Keystore.put("user:#{self.id}:comments_deleted", self.comments.deleted.count) + Keystore.put("user:#{id}:stories_submitted", stories.count) + Keystore.put("user:#{id}:comments_posted", comments.active.count) + Keystore.put("user:#{id}:comments_deleted", comments.deleted.count) end def delete! User.transaction do - self.comments + # walks comments -> story -> merged stories; this is a rare event and likely + # to be fixed in a redesign of the story merging db model: + # https://github.com/lobsters/lobsters/issues/1298#issuecomment-2272179720 + # Prosopite.pause + comments .where("score < 0") - .find_each {|c| c.delete_for_user(self) } + .find_each { |c| c.delete_for_user(self) } + # Prosopite.resume - self.sent_messages.each do |m| - m.deleted_by_author = true - m.save - end - self.received_messages.each do |m| - m.deleted_by_recipient = true - m.save - end + # delete messages bypassing validation because a message may have a hat + # sender has doffed, which would fail validations + sent_messages.update_all(deleted_by_author: true) + received_messages.update_all(deleted_by_recipient: true) - self.invitations.destroy_all + invitations.unused.update_all(used_at: Time.now.utc) - self.roll_session_token + roll_session_token self.deleted_at = Time.current - self.good_riddance? - self.save! + good_riddance? + save! end end def undelete! User.transaction do - self.sent_messages.each do |m| - m.deleted_by_author = false - m.save - end - self.received_messages.each do |m| - m.deleted_by_recipient = false - m.save - end - self.deleted_at = nil - self.save! + save! end end def disable_2fa! self.totp_secret = nil - self.save! + save! end # ensures some users talk to a mod before reactivating def good_riddance? - return if self.is_banned? # https://www.youtube.com/watch?v=UcZzlPGnKdU - self.email = "#{self.username}@lobsters.example" if \ - self.karma < 0 || - (self.comments.where('created_at >= now() - interval 30 day AND is_deleted').count + - self.stories.where('created_at >= now() - interval 30 day AND is_deleted AND is_moderated') - .count >= 3) || - FlaggedCommenters.new('90d').check_list_for(self) + return if is_banned? # https://www.youtube.com/watch?v=UcZzlPGnKdU + self.email = "#{username}@lobsters.example" if \ + karma < 0 || + (comments.where("created_at >= now() - interval 30 day AND is_deleted").count + + stories.where("created_at >= now() - interval 30 day AND is_deleted AND is_moderated") + .count >= 3) || + FlaggedCommenters.new("90d").check_list_for(self) end def grant_moderatorship_by_user!(user) User.transaction do self.is_moderator = true - self.save! + save! m = Moderation.new m.moderator_user_id = user.id - m.user_id = self.id + m.user_id = id m.action = "Granted moderator status" m.save! h = Hat.new - h.user_id = self.id + h.user_id = id h.granted_by_user_id = user.id h.hat = "Sysop" h.save! @@ -444,13 +459,13 @@ def grant_moderatorship_by_user!(user) def initiate_password_reset_for_ip(ip) self.password_reset_token = "#{Time.current.to_i}-#{Utils.random_str(30)}" - self.save! + save! - PasswordReset.password_reset_link(self, ip).deliver_now + PasswordResetMailer.password_reset_link(self, ip).deliver_now end def has_2fa? - self.totp_secret.present? + totp_secret.present? end def is_active? @@ -463,23 +478,23 @@ def is_banned? # user was deleted/banned before a server move, see lib/tasks/privacy_wipe def is_wiped? - password_digest == '*' + password_digest == "*" end def is_new? - return true unless self.created_at # unsaved object; in signup flow or a test - self.created_at > NEW_USER_DAYS.days.ago + return true unless created_at # unsaved object; in signup flow or a test + created_at > NEW_USER_DAYS.days.ago end def add_or_update_keybase_proof(kb_username, kb_signature) self.keybase_signatures ||= [] - self.remove_keybase_proof(kb_username) - self.keybase_signatures.push('kb_username' => kb_username, 'sig_hash' => kb_signature) + remove_keybase_proof(kb_username) + self.keybase_signatures.push("kb_username" => kb_username, "sig_hash" => kb_signature) end def remove_keybase_proof(kb_username) self.keybase_signatures ||= [] - self.keybase_signatures.reject! {|kbsig| kbsig['kb_username'] == kb_username } + self.keybase_signatures.reject! { |kbsig| kbsig["kb_username"] == kb_username } end def roll_session_token @@ -487,47 +502,52 @@ def roll_session_token end def is_heavy_self_promoter? - total_count = self.stories_submitted_count + total_count = stories_submitted_count if total_count < MIN_STORIES_CHECK_SELF_PROMOTION false else - authored = self.stories.where(:user_is_author => true).count + authored = stories.where(user_is_author: true).count authored.to_f / total_count >= HEAVY_SELF_PROMOTER_PROPORTION end end def linkified_about - Markdowner.to_html(self.about) + Markdowner.to_html(about) + end + + def mastodon_acct + raise unless mastodon_username.present? && mastodon_instance.present? + "@#{mastodon_username}@#{mastodon_instance}" end def most_common_story_tag Tag.active.joins( :stories ).where( - :stories => { :user_id => self.id, :is_deleted => false } + stories: {user_id: id, is_deleted: false} ).group( Tag.arel_table[:id] ).order( - Arel.sql('COUNT(*) desc') + Arel.sql("COUNT(*) desc") ).first end def pushover!(params) - if self.pushover_user_key.present? - Pushover.push(self.pushover_user_key, params) + if pushover_user_key.present? + Pushover.push(pushover_user_key, params) end end def recent_threads(amount, include_submitted_stories: false, for_user: user) comments = self.comments.accessible_to_user(for_user) - thread_ids = comments.group(:thread_id).order('MAX(created_at) DESC').limit(amount) + thread_ids = comments.group(:thread_id).order("MAX(created_at) DESC").limit(amount) .pluck(:thread_id) - if include_submitted_stories && self.show_submitted_story_threads + if include_submitted_stories && show_submitted_story_threads thread_ids += Comment.joins(:story) - .where(:stories => { :user_id => self.id }).group(:thread_id) + .where(stories: {user_id: id}).group(:thread_id) .order("MAX(comments.created_at) DESC").limit(amount).pluck(:thread_id) thread_ids = thread_ids.uniq.sort.reverse[0, amount] @@ -537,11 +557,11 @@ def recent_threads(amount, include_submitted_stories: false, for_user: user) end def stories_submitted_count - Keystore.value_for("user:#{self.id}:stories_submitted").to_i + Keystore.value_for("user:#{id}:stories_submitted").to_i end def stories_deleted_count - Keystore.value_for("user:#{self.id}:stories_deleted").to_i + Keystore.value_for("user:#{id}:stories_deleted").to_i end def to_param @@ -553,11 +573,11 @@ def unban_by_user!(unbanner, reason) self.banned_by_user_id = nil self.banned_reason = nil self.deleted_at = nil - self.save! + save! m = Moderation.new m.moderator_user_id = unbanner.id - m.user_id = self.id + m.user_id = id m.action = "Unbanned" m.reason = reason m.save! @@ -570,11 +590,11 @@ def enable_invite_by_user!(mod) self.disabled_invite_at = nil self.disabled_invite_by_user_id = nil self.disabled_invite_reason = nil - self.save! + save! m = Moderation.new m.moderator_user_id = mod.id - m.user_id = self.id + m.user_id = id m.action = "Enabled invitations" m.save! end @@ -583,18 +603,22 @@ def enable_invite_by_user!(mod) end def unread_message_count - @unread_message_count ||= Keystore.value_for("user:#{self.id}:unread_messages").to_i + @unread_message_count ||= Keystore.value_for("user:#{id}:unread_messages").to_i end def update_unread_message_count! - @unread_message_count = self.received_messages.unread.count - Keystore.put("user:#{self.id}:unread_messages", @unread_message_count) + @unread_message_count = received_messages.unread.count + Keystore.put("user:#{id}:unread_messages", @unread_message_count) + end + + def clear_unread_replies! + Rails.cache.delete("user:#{id}:unread_replies") end def unread_replies_count @unread_replies_count ||= - Rails.cache.fetch("user:#{self.id}:unread_replies", expires_in: 2.minutes) { - ReplyingComment.where(user_id: self.id, is_unread: true).count + Rails.cache.fetch("user:#{id}:unread_replies", expires_in: 2.minutes) { + ReplyingComment.where(user_id: id, is_unread: true).count } end @@ -603,8 +627,9 @@ def inbox_count end def votes_for_others - self.votes.left_outer_joins(:story, :comment) - .where("(votes.comment_id is not null and comments.user_id <> votes.user_id) OR " << + votes.left_outer_joins(:story, :comment) + .includes(comment: :user, story: :user) + .where("(votes.comment_id is not null and comments.user_id <> votes.user_id) OR " \ "(votes.comment_id is null and stories.user_id <> votes.user_id)") .order("id DESC") end diff --git a/benchmarks/lobsters/app/models/vote.rb b/benchmarks/lobsters/app/models/vote.rb index 21041067..78dc8b1c 100644 --- a/benchmarks/lobsters/app/models/vote.rb +++ b/benchmarks/lobsters/app/models/vote.rb @@ -1,12 +1,26 @@ +# typed: false + class Vote < ApplicationRecord belongs_to :user, optional: false belongs_to :story, optional: false belongs_to :comment, optional: true - validates :vote, presence: true + normalizes :reason, with: ->(r) { r.to_s }, apply_to_nil: true + + # for comment_vote_summaries + attribute :count, :integer + attribute :usernames, :string + + validates :vote, presence: true, inclusion: {in: [1, -1]} validates :reason, - length: { is: 1 }, - allow_blank: true + length: {is: 1}, + allow_blank: true, + presence: true + + scope :comments_flags, ->(comments, user = nil) { + q = where(comment: comments, vote: -1) + user ? q.where(user: user) : q.all + } # don't forget to edit the explanations on /about COMMENT_REASONS = { @@ -15,10 +29,10 @@ class Vote < ApplicationRecord "T" => "Troll", "U" => "Unkind", "S" => "Spam", - "" => "Cancel", + "" => "Cancel" }.freeze ALL_COMMENT_REASONS = COMMENT_REASONS.merge({ - "I" => "Incorrect", + "I" => "Incorrect" }).freeze # don't forget to edit the explanations on /about @@ -27,18 +41,46 @@ class Vote < ApplicationRecord "A" => "Already Posted", "B" => "Broken Link", "S" => "Spam", - "" => "Cancel", + "" => "Cancel" }.freeze ALL_STORY_REASONS = STORY_REASONS.merge({ - "Q" => "Low Quality", + "Q" => "Low Quality" }).freeze + def on_comment? + comment_id.present? + end + + def on_story? + comment_id.blank? + end + + def reason_text + if on_story? + ALL_STORY_REASONS[reason] + else + ALL_COMMENT_REASONS[reason] + end + end + + def self.comment_vote_summaries(comment_ids) + Vote + .joins(:user) + # CHANGE: + # .select("comment_id, reason, count(1) as count, group_concat(username separator ', ') as usernames") + .select("comment_id, reason, count(1) as count, group_concat(username, ', ') as usernames") + .where(comment_id: comment_ids) + .where.not(reason: "") + .group(:comment_id, :reason) + .group_by(&:comment_id) + end + def self.votes_by_user_for_stories_hash(user, stories) votes = {} - Vote.where(:user_id => user, :story_id => stories, - :comment_id => nil).find_each do |v| - votes[v.story_id] = { :vote => v.vote, :reason => v.reason } + Vote.where(user_id: user, story_id: stories, + comment_id: nil).find_each do |v| + votes[v.story_id] = {vote: v.vote, reason: v.reason} end votes @@ -48,11 +90,9 @@ def self.comment_votes_by_user_for_story_hash(user_id, story_id) votes = {} Vote.where( - :user_id => user_id, :story_id => story_id - ).where( - "comment_id IS NOT NULL" - ).find_each do |v| - votes[v.comment_id] = { :vote => v.vote, :reason => v.reason } + user_id: user_id, story_id: story_id + ).where.not(comment_id: nil).find_each do |v| + votes[v.comment_id] = {vote: v.vote, reason: v.reason} end votes @@ -62,51 +102,60 @@ def self.story_votes_by_user_for_story_ids_hash(user_id, story_ids) if story_ids.empty? {} else - votes = self.where( - :user_id => user_id, - :comment_id => nil, - :story_id => story_ids, + votes = where( + user_id: user_id, + comment_id: nil, + story_id: story_ids ) - votes.inject({}) do |memo, v| - memo[v.story_id] = { :vote => v.vote, :reason => v.reason } - memo + votes.each_with_object({}) do |v, memo| + memo[v.story_id] = {vote: v.vote, reason: v.reason} end end end def self.comment_votes_by_user_for_comment_ids_hash(user_id, comment_ids) - if comment_ids.empty? - {} - else - votes = self.where( - :user_id => user_id, - :comment_id => comment_ids, - ) - votes.inject({}) do |memo, v| - memo[v.comment_id] = { :vote => v.vote, :reason => v.reason } - memo - end + return {} if user_id.nil? || comment_ids.empty? + + votes = where( + user_id: user_id, + comment_id: comment_ids + ).select(:comment_id, :vote, :reason) + votes.each_with_object({}) do |v, memo| + memo[v.comment_id] = {vote: v.vote, reason: v.reason} end end def self.vote_thusly_on_story_or_comment_for_user_because( new_vote, story_id, comment_id, user_id, reason, update_counters = true ) - v = Vote.where(:user_id => user_id, :story_id => story_id, - :comment_id => comment_id).first_or_initialize + v = Vote.where(user_id: user_id, story_id: story_id, + comment_id: comment_id).first_or_initialize return if !v.new_record? && v.vote == new_vote # done if there's no change - score_delta = new_vote - v.vote.to_i - if v.vote == -1 + # score deltas when flags no longer affect scores + # no vote -> 1 1 + # flag -> 1 1 + # no vote -> flag 0 + # flag -> no vote 0 + # 1 -> no vote -1 + # 1 -> flag -1 + score_delta = if new_vote == 1 + 1 + elsif v.vote == 1 + -1 + else + 0 + end + flag_delta = if v.vote == -1 # we know there's a change, so we must be removing a flag - flag_delta = -1 + -1 elsif new_vote == -1 # we know there's a change, so we must be adding a flag - flag_delta = 1 + 1 else # change from 1 to 0 or 0 to 1, so number of flags doesn't change - flag_delta = 0 + 0 end if new_vote == 0 @@ -127,10 +176,10 @@ def self.vote_thusly_on_story_or_comment_for_user_because( end def target - if self.comment_id - Comment.find(self.comment_id) + if comment_id + Comment.find(comment_id) else - Story.find(self.story_id) + Story.find(story_id) end end end diff --git a/benchmarks/lobsters/app/views/about/_subnav.html.erb b/benchmarks/lobsters/app/views/about/_subnav.html.erb index f2b3b448..d03b55f8 100644 --- a/benchmarks/lobsters/app/views/about/_subnav.html.erb +++ b/benchmarks/lobsters/app/views/about/_subnav.html.erb @@ -1,3 +1,4 @@ +<%# locals: () -%> <% content_for :subnav do %> <%= link_to_different_page 'About', about_path %> <%= link_to_different_page 'Chat', chat_path %> diff --git a/benchmarks/lobsters/app/views/ban_notification/notify.text.erb b/benchmarks/lobsters/app/views/ban_notification_mailer/notify.text.erb similarity index 100% rename from benchmarks/lobsters/app/views/ban_notification/notify.text.erb rename to benchmarks/lobsters/app/views/ban_notification_mailer/notify.text.erb diff --git a/benchmarks/lobsters/app/views/categories/_form.html.erb b/benchmarks/lobsters/app/views/categories/_form.html.erb index e65e5384..4462947a 100644 --- a/benchmarks/lobsters/app/views/categories/_form.html.erb +++ b/benchmarks/lobsters/app/views/categories/_form.html.erb @@ -1,10 +1,11 @@ -<%= form_with model: @category, url: @category.persisted? ? update_category_path : categories_path, method: :post do |f| %> +<%# locals: (category:) -%> +<%= form_with model: category, url: category.persisted? ? update_category_path : categories_path, method: :post do |f| %>
<%= f.label :category, 'Name:', class: 'required' %> <%= f.text_field :category %>
- <%= f.submit @category.persisted? ? 'Update Category' : 'Create Category' %> + <%= f.submit category.persisted? ? 'Update Category' : 'Create Category' %>
<% end %> diff --git a/benchmarks/lobsters/app/views/categories/_multi_category_tip.html.erb b/benchmarks/lobsters/app/views/categories/_multi_category_tip.html.erb index 0006dce9..2da05e26 100644 --- a/benchmarks/lobsters/app/views/categories/_multi_category_tip.html.erb +++ b/benchmarks/lobsters/app/views/categories/_multi_category_tip.html.erb @@ -1,3 +1,4 @@ +<%# locals: () -%>

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

diff --git a/benchmarks/lobsters/app/views/categories/edit.html.erb b/benchmarks/lobsters/app/views/categories/edit.html.erb index 7f960c7f..41a94b54 100644 --- a/benchmarks/lobsters/app/views/categories/edit.html.erb +++ b/benchmarks/lobsters/app/views/categories/edit.html.erb @@ -3,5 +3,5 @@ Category Edit - <%= render partial: 'form' %> + <%= render partial: 'form', locals: { category: @category } %> diff --git a/benchmarks/lobsters/app/views/categories/new.html.erb b/benchmarks/lobsters/app/views/categories/new.html.erb index 47721af6..716b5eef 100644 --- a/benchmarks/lobsters/app/views/categories/new.html.erb +++ b/benchmarks/lobsters/app/views/categories/new.html.erb @@ -3,5 +3,5 @@ Category Create - <%= render partial: 'form' %> + <%= render partial: 'form', locals: { category: @category } %> diff --git a/benchmarks/lobsters/app/views/comments/_comment.html.erb b/benchmarks/lobsters/app/views/comments/_comment.html.erb index 9b5c1a22..19443440 100644 --- a/benchmarks/lobsters/app/views/comments/_comment.html.erb +++ b/benchmarks/lobsters/app/views/comments/_comment.html.erb @@ -1,17 +1,27 @@ -<% flagged = comment.current_vote && comment.current_vote[:vote] == -1 %> +<%# locals: (comment:, force_open: false, show_tree_lines: false, show_story: false, was_merged: false, is_unread: false) -%> +<% + # partial inputs: + # force_open: this comment is allowed to collapse + # show_tree_lines: render collapse button (misnamed?) + # show_story: show "on: story title" end of byline + # was_merged: show merged icon start of byline + # is_unread: show (unread) in byline +%> Comment::COLLAPSE_SCORE && !flagged) ? "" : "checked" %>> + (comment.score > Comment::COLLAPSE_SCORE && !comment.current_flagged?) ? "" : "checked" %>>
- <%= comment.score < Comment::SCORE_RANGE_TO_HIDE.first ? "bad" : "" %>"> - - <% if defined?(show_tree_lines) && show_tree_lines %> + data-shortid="<%= comment.short_id if comment.persisted? %>" + class="comment + <%= comment.current_upvoted? ? "upvoted" : "" %> + <%= comment.current_flagged? ? "flagged" : "" %> + <%= comment.score < Comment::SCORE_RANGE_TO_HIDE.first ? "bad" : "" %> + "> + + <% if show_tree_lines %> <% end %> @@ -30,14 +40,14 @@ class="comment <%= comment.current_vote ? (comment.current_vote[:vote] == 1 ?
<%= score_display != " " ? "score_shown" : "" %> - <%= defined?(children) && children ? "" : "no_children" %>">
+ <%= comment.reply_count.to_i == 0 ? "no_children" : "" %>">
<% end %>