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: ( - "
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: ("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 << "There were the problems with the following fields:
" html << "
Tip: read stories across multiple categories with /categories/foo,bar