diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..7f44804c --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 2.6.3 diff --git a/Gemfile b/Gemfile index a45d00b0..d6e76adb 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source 'https://rubygems.org' git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby '3.1.2' +ruby '2.6.3' # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' gem 'rails', '~> 6.0' @@ -71,6 +71,10 @@ gem 'dotenv-rails' gem 'pagy' gem 'ahoy_matey' +# Paola Dev Gems +# gem 'rack-attack' +gem 'tf-idf-similarity' + # Using Dragonfly v0.9 for files & images # Because I can never get v1.0 to work with PJ's caching solution # Also, finally had to patch gem with a bug fix from the newer version diff --git a/Gemfile.lock b/Gemfile.lock index c402d17a..924c7a77 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,109 +10,110 @@ GEM remote: https://rubygems.org/ specs: CFPropertyList (2.3.6) - actioncable (6.1.7.4) - actionpack (= 6.1.7.4) - activesupport (= 6.1.7.4) + actioncable (6.1.7.7) + actionpack (= 6.1.7.7) + activesupport (= 6.1.7.7) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.4) - actionpack (= 6.1.7.4) - activejob (= 6.1.7.4) - activerecord (= 6.1.7.4) - activestorage (= 6.1.7.4) - activesupport (= 6.1.7.4) + actionmailbox (6.1.7.7) + actionpack (= 6.1.7.7) + activejob (= 6.1.7.7) + activerecord (= 6.1.7.7) + activestorage (= 6.1.7.7) + activesupport (= 6.1.7.7) mail (>= 2.7.1) - actionmailer (6.1.7.4) - actionpack (= 6.1.7.4) - actionview (= 6.1.7.4) - activejob (= 6.1.7.4) - activesupport (= 6.1.7.4) + actionmailer (6.1.7.7) + actionpack (= 6.1.7.7) + actionview (= 6.1.7.7) + activejob (= 6.1.7.7) + activesupport (= 6.1.7.7) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.7.4) - actionview (= 6.1.7.4) - activesupport (= 6.1.7.4) + actionpack (6.1.7.7) + actionview (= 6.1.7.7) + activesupport (= 6.1.7.7) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.4) - actionpack (= 6.1.7.4) - activerecord (= 6.1.7.4) - activestorage (= 6.1.7.4) - activesupport (= 6.1.7.4) + actiontext (6.1.7.7) + actionpack (= 6.1.7.7) + activerecord (= 6.1.7.7) + activestorage (= 6.1.7.7) + activesupport (= 6.1.7.7) nokogiri (>= 1.8.5) - actionview (6.1.7.4) - activesupport (= 6.1.7.4) + actionview (6.1.7.7) + activesupport (= 6.1.7.7) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.7.4) - activesupport (= 6.1.7.4) + activejob (6.1.7.7) + activesupport (= 6.1.7.7) globalid (>= 0.3.6) - activemodel (6.1.7.4) - activesupport (= 6.1.7.4) - activerecord (6.1.7.4) - activemodel (= 6.1.7.4) - activesupport (= 6.1.7.4) - activestorage (6.1.7.4) - actionpack (= 6.1.7.4) - activejob (= 6.1.7.4) - activerecord (= 6.1.7.4) - activesupport (= 6.1.7.4) + activemodel (6.1.7.7) + activesupport (= 6.1.7.7) + activerecord (6.1.7.7) + activemodel (= 6.1.7.7) + activesupport (= 6.1.7.7) + activestorage (6.1.7.7) + actionpack (= 6.1.7.7) + activejob (= 6.1.7.7) + activerecord (= 6.1.7.7) + activesupport (= 6.1.7.7) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.7.4) + activesupport (6.1.7.7) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.4) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) - ahoy_matey (5.0.2) - activesupport (>= 6.1) - device_detector (>= 1) - safely_block (>= 0.4) + ahoy_matey (4.2.1) + activesupport (>= 5.2) + device_detector + safely_block (>= 0.2.1) aliyun-sdk (0.8.0) nokogiri (~> 1.6) rest-client (~> 2.0) - aws-eventstream (1.2.0) - aws-partitions (1.791.0) - aws-sdk-core (3.178.0) - aws-eventstream (~> 1, >= 1.0.2) + aws-eventstream (1.3.0) + aws-partitions (1.895.0) + aws-sdk-core (3.191.3) + aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.71.0) - aws-sdk-core (~> 3, >= 3.177.0) + aws-sdk-kms (1.77.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.131.0) - aws-sdk-core (~> 3, >= 3.177.0) + aws-sdk-s3 (1.143.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.6) - aws-sigv4 (1.6.0) + aws-sigv4 (~> 1.8) + aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) - bcrypt (3.1.19) + base64 (0.2.0) + bcrypt (3.1.20) bindex (0.8.1) - bootsnap (1.16.0) + bootsnap (1.18.3) msgpack (~> 1.2) builder (3.2.4) byebug (11.1.3) - concurrent-ruby (1.2.2) + concurrent-ruby (1.2.3) connection_pool (2.4.1) crass (1.0.6) - date (3.3.3) + date (3.3.4) declarative (0.0.20) - device_detector (1.1.1) - devise (4.9.2) + device_detector (1.0.7) + devise (4.9.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise_invitable (2.0.8) + devise_invitable (2.0.9) actionmailer (>= 5.0) devise (>= 4.6) domain_name (0.5.20190701) @@ -121,14 +122,16 @@ GEM dotenv-rails (2.8.1) dotenv (= 2.8.1) railties (>= 3.2) - dry-inflector (1.0.0) + dry-inflector (0.2.1) + errbase (0.2.2) erubi (1.12.0) - excon (0.100.0) - faraday (2.7.10) + excon (0.109.0) + faraday (2.8.1) + base64 faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) - ffi (1.15.5) + ffi (1.16.3) fission (0.5.0) CFPropertyList (~> 2.2) fog (2.3.0) @@ -176,7 +179,7 @@ GEM fog-atmos (0.1.0) fog-core fog-xml - fog-aws (3.19.0) + fog-aws (3.21.0) fog-core (~> 2.1) fog-json (~> 1.1) fog-xml (~> 0.1) @@ -212,7 +215,7 @@ GEM fog-ecloud (0.3.0) fog-core fog-xml - fog-google (1.21.1) + fog-google (1.23.0) addressable (>= 2.7.0) fog-core (< 2.3) fog-json (~> 1.2) @@ -285,9 +288,9 @@ GEM fog-voxel (0.1.0) fog-core fog-xml - fog-vsphere (3.6.2) + fog-vsphere (3.5.2) fog-core - rbvmomi2 (~> 3.0) + rbvmomi (>= 1.9, < 3) fog-xenserver (1.0.0) fog-core fog-xml @@ -296,11 +299,11 @@ GEM fog-core nokogiri (>= 1.5.11, < 2.0.0) formatador (0.3.0) - globalid (1.1.0) - activesupport (>= 5.0) - google-apis-compute_v1 (0.74.0) + globalid (1.2.1) + activesupport (>= 6.1) + google-apis-compute_v1 (0.86.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.1) + google-apis-core (0.11.3) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -308,25 +311,23 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.a) rexml - webrick - google-apis-dns_v1 (0.32.0) + google-apis-dns_v1 (0.36.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-monitoring_v3 (0.47.0) + google-apis-monitoring_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-pubsub_v1 (0.40.0) + google-apis-pubsub_v1 (0.45.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-sqladmin_v1beta4 (0.53.0) + google-apis-sqladmin_v1beta4 (0.61.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.24.0) + google-apis-storage_v1 (0.32.0) google-apis-core (>= 0.11.0, < 2.a) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - googleauth (1.7.0) + googleauth (1.8.1) faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) - memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) @@ -351,12 +352,13 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.6.3) - jwt (2.7.1) + json (2.7.1) + jwt (2.8.1) + base64 listen (3.0.8) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - loofah (2.21.3) + loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -364,90 +366,92 @@ GEM net-imap net-pop net-smtp - marcel (1.0.2) - memoist (0.16.2) + marcel (1.0.4) method_source (1.0.0) - mime-types (3.4.1) + mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2023.0218.1) + mime-types-data (3.2024.0206) mini_magick (4.12.0) - mini_mime (1.1.2) - minitest (5.18.1) + mini_mime (1.1.5) + mini_portile2 (2.8.5) + minitest (5.22.2) msgpack (1.7.2) multi_json (1.15.0) multi_xml (0.6.0) - mysql2 (0.5.5) - net-http (0.3.2) + mysql2 (0.5.6) + net-http (0.4.1) uri - net-imap (0.3.6) + net-imap (0.3.7) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout - net-smtp (0.3.3) + net-smtp (0.4.0.1) net-protocol netrc (0.11.0) - nio4r (2.5.9) - nokogiri (1.15.3-arm64-darwin) + nio4r (2.7.0) + nokogiri (1.13.10) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) + nokogiri (1.13.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.15.3-x86_64-linux) + nokogiri (1.13.10-x86_64-linux) racc (~> 1.4) - optimist (3.0.1) + optimist (3.1.0) orm_adapter (0.5.0) os (1.1.4) - ovirt-engine-sdk (4.4.1) + ovirt-engine-sdk (4.6.0) json (>= 1, < 3) - pagy (6.0.4) - public_suffix (5.0.3) + pagy (6.5.0) + public_suffix (5.0.4) puma (3.12.6) - racc (1.7.1) - rack (2.2.7) - rack-cache (1.14.0) + racc (1.7.3) + rack (2.2.8.1) + rack-cache (1.15.0) rack (>= 0.4) rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.4) - actioncable (= 6.1.7.4) - actionmailbox (= 6.1.7.4) - actionmailer (= 6.1.7.4) - actionpack (= 6.1.7.4) - actiontext (= 6.1.7.4) - actionview (= 6.1.7.4) - activejob (= 6.1.7.4) - activemodel (= 6.1.7.4) - activerecord (= 6.1.7.4) - activestorage (= 6.1.7.4) - activesupport (= 6.1.7.4) + rails (6.1.7.7) + actioncable (= 6.1.7.7) + actionmailbox (= 6.1.7.7) + actionmailer (= 6.1.7.7) + actionpack (= 6.1.7.7) + actiontext (= 6.1.7.7) + actionview (= 6.1.7.7) + activejob (= 6.1.7.7) + activemodel (= 6.1.7.7) + activerecord (= 6.1.7.7) + activestorage (= 6.1.7.7) + activesupport (= 6.1.7.7) bundler (>= 1.15.0) - railties (= 6.1.7.4) + railties (= 6.1.7.7) sprockets-rails (>= 2.0.0) - rails-dom-testing (2.1.1) + rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) - loofah (~> 2.21) - nokogiri (~> 1.14) - railties (6.1.7.4) - actionpack (= 6.1.7.4) - activesupport (= 6.1.7.4) + rails-html-sanitizer (1.5.0) + loofah (~> 2.19, >= 2.19.1) + railties (6.1.7.7) + actionpack (= 6.1.7.7) + activesupport (= 6.1.7.7) method_source rake (>= 12.2) thor (~> 1.0) - rake (13.0.6) + rake (13.1.0) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) - rbvmomi2 (3.6.1) - builder (~> 3.2) - json (~> 2.3) - nokogiri (~> 1.12, >= 1.12.5) + rbvmomi (2.4.1) + builder (~> 3.0) + json (>= 1.8) + nokogiri (~> 1.5) optimist (~> 3.0) - redis (5.0.6) - redis-client (>= 0.9.0) - redis-client (0.14.1) + redis (5.1.0) + redis-client (>= 0.17.0) + redis-client (0.21.0) connection_pool redis-namespace (1.11.0) redis (>= 4) @@ -455,7 +459,7 @@ GEM declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - responders (3.1.0) + responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) rest-client (2.1.0) @@ -464,53 +468,59 @@ GEM mime-types (>= 1.16, < 4.0) netrc (~> 0.8) retriable (3.1.2) - rexml (3.2.5) - ruby-vips (2.1.4) + rexml (3.2.6) + ruby-vips (2.2.1) ffi (~> 1.12) ruby2_keywords (0.0.5) - safely_block (0.4.0) - signet (0.17.0) + safely_block (0.3.0) + errbase (>= 0.1.1) + signet (0.18.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) multi_json (~> 1.10) - sprockets (4.2.0) + sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - thor (1.2.2) - timeout (0.4.0) + tf-idf-similarity (0.3.0) + unicode_utils (~> 1.4) + thor (1.3.1) + timeout (0.4.1) trailblazer-option (0.1.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) unf (0.1.4) unf_ext - unf_ext (0.0.8.2) + unf_ext (0.0.9.1) + unicode_utils (1.4.0) uniquify (0.1.0) - uri (0.12.2) + uri (0.13.0) warden (1.2.9) rack (>= 2.0.9) - web-console (4.2.0) + web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) bindex (>= 0.4.0) railties (>= 6.0.0) webrick (1.8.1) - websocket-driver (0.7.5) + websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xml-simple (1.1.9) rexml - xmlrpc (0.3.2) + xmlrpc (0.3.3) webrick - zeitwerk (2.6.8) + zeitwerk (2.6.13) PLATFORMS + -darwin-22 arm64-darwin-20 + arm64-darwin-22 arm64-darwin-23 x86_64-linux @@ -538,12 +548,13 @@ DEPENDENCIES rails (~> 6.0) redis redis-namespace + tf-idf-similarity tzinfo-data uniquify web-console (>= 3.3.0) RUBY VERSION - ruby 3.1.2p20 + ruby 2.6.3p62 BUNDLED WITH 2.4.12 diff --git a/README.md b/README.md index ada18b90..4f931b38 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,19 @@ The AnyKey app uses several external services: * Stripe for donation payments * Twitch for GLHF pledge badge assignment and moderation -In order to test all of the features in your development environment you will have to add additional credentials to your `.env` file. These credentials are only available to trusted collaborators and can be obtained from the repository manager. -Note that the `TWITCH_REDIRECT_URL` must be set in both the external Twitch app and the local development environment. A separate Twitch app should be created by the developer for local testing purposes. +### Notes for Twitch API + +* Make sure to run + ```shell + rake twitch_token:request + ``` +* Note that the `TWITCH_REDIRECT_URL` must be set in both the external Twitch app and the local development environment. +* A separate Twitch app should be created by the developer for local testing purposes. + +### Environment Credentials + +In order to test all of the features in your development environment you will have to add additional credentials to your `.env` file. These credentials are only available to trusted collaborators and can be obtained from the repository manager. ```shell SENDGRID_USERNAME=XXX diff --git a/app/controllers/concerns/rate_limitable.rb b/app/controllers/concerns/rate_limitable.rb new file mode 100644 index 00000000..18933a8b --- /dev/null +++ b/app/controllers/concerns/rate_limitable.rb @@ -0,0 +1,37 @@ +module RateLimitable + private + + CACHE_KEY_PATTERN = '%{type}:%{base_key}:%{identifier}'.freeze + + def limit_request_by_ip(base_key, redirection_path, limit = 9, expiry = 60) + client_ip = request.remote_ip + rate_limit_key = CACHE_KEY_PATTERN % {type: "ip_rate_limit", base_key: base_key, identifier: client_ip} + rate_limit_count = Rails.cache.read(rate_limit_count).to_i + end + + def limit_request_by_signature(base_key, redirection_path, limit = 9, expiry = 60) + device_signature = generate_device_signature(request) + rate_limit_key = CACHE_KEY_PATTERN % { type: 'device_rate_limit', base_key: base_key, identifier: device_signature } + rate_limit_count = Rails.cache.read(rate_limit_key).to_i + + process_rate_limit(rate_limit_key, rate_limit_count, limit, expiry, redirection_path) + end + + def generate_device_signature(request) + user_agent = request.user_agent || "" + accept_language = request.env['HTTP_ACCEPT_LANGUAGE'] || "" + Digest::SHA1.hexdigest([request.remote_ip, user_agent, accept_language].join(':')) # unique device signature + end + + def process_rate_limit(key, current_count, limit, expiry, redirect_path) + if current_count >= limit + ahoy.track "Rate limit exceeded", key: key + flash[:alert] = 'Rate limit exceeded. Please try again later.' + redirect_to redirect_path + return false + else + Rails.cache.write(key, current_count + 1, expires_in: expiry.seconds) + return true + end + end +end diff --git a/app/controllers/concerns_controller.rb b/app/controllers/concerns_controller.rb index cec21137..fb33540c 100644 --- a/app/controllers/concerns_controller.rb +++ b/app/controllers/concerns_controller.rb @@ -1,5 +1,5 @@ class ConcernsController < ApplicationController - + include RateLimitable layout "backstage", only: [ :index, :show ] skip_before_action :verify_authenticity_token, only: [ :watch, :unwatch ] @@ -8,7 +8,8 @@ class ConcernsController < ApplicationController before_action :ensure_staff, only: [ :index, :show, :dismiss, :undismiss, :review, :watch, :unwatch ] before_action :find_concern, only: [ :show, :dismiss, :undismiss, :review, :watch, :unwatch ] around_action :display_timezone - + before_action :apply_request_rate, only: [ :create] + def index # f is used to filter reports by scope # q is used to search for keywords @@ -132,5 +133,9 @@ def display_timezone def concern_params params.require(:concern).permit(:concerning_player_id, :concerning_player_id_type, :background, :description, :recommended_response, :concerned_email, :concerned_cert_code, screenshots: []) end + + def apply_request_rate + limit_create_request("concerns", new_concern_path) + end end diff --git a/app/controllers/pledges_controller.rb b/app/controllers/pledges_controller.rb index fb7577a1..2402397f 100644 --- a/app/controllers/pledges_controller.rb +++ b/app/controllers/pledges_controller.rb @@ -1,5 +1,5 @@ class PledgesController < ApplicationController - + include RateLimitable layout "backstage", only: [ :index ] before_action :authenticate_user!, only: [ :index ] @@ -39,6 +39,12 @@ def new def create @pledge = Pledge.find_by(email: pledge_params[:email]) + # First check IP-based rate limiting + return unless limit_request_by_ip('pledge_create', new_pledge_path) + + # Fallback to device signature-based limiting (may not be needed) + return unless limit_request_by_signature('pledge_create', new_pledge_path) + if @pledge # Set cookie to enforce single visit to redirect page @@ -218,5 +224,5 @@ def ensure_staff def pledge_params params.require(:pledge).permit(:first_name, :last_name, :email) end - + end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 6b89f538..3d05ec56 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -1,14 +1,18 @@ class ReportsController < ApplicationController - + include RateLimitable + require 'tf-idf-similarity' + require 'matrix' + layout "backstage", only: [ :index, :show ] skip_before_action :verify_authenticity_token, only: [ :watch, :unwatch, :twitch_lookup ] - before_action :authenticate_user!, only: [ :index, :show, :dismiss, :undismiss, :watch, :unwatch ] - before_action :ensure_staff, only: [ :index, :show, :dismiss, :undismiss, :watch, :unwatch ] - before_action :find_report, only: [ :show, :dismiss, :undismiss, :watch, :unwatch ] + before_action :authenticate_user!, only: [ :index, :show, :dismiss, :undismiss, :watch, :unwatch, :unspam, :spam] + before_action :ensure_staff, only: [ :index, :show, :dismiss, :undismiss, :watch, :unwatch, :unspam, :spam] + before_action :find_report, only: [ :show, :dismiss, :undismiss, :watch, :unwatch, :unspam, :spam] + # after_action :check_report_matches, only: [ :create] around_action :display_timezone - + def index # f is used to filter reports by scope # q is used to search for keywords @@ -38,6 +42,7 @@ def show @pledge = @report.reported_pledge @reporter_pledge = @report.reporter_pledge @related_reports = @report.related_reports + @spam_reports = @report.related_spam_reports end def new @@ -46,7 +51,14 @@ def new def create @report = Report.new(report_params) - + + # First check IP-based rate limiting + return unless limit_request_by_ip('report_create', new_report_path) + + # Fallback to device signature-based limiting (may not be needed) + return unless limit_request_by_signature('report_create', new_report_path) + + puts "here" # Lookup Twitch IDs (if not fetched via Ajax) if @report.reporter_twitch_name && @report.reporter_twitch_id.blank? @report.reporter_twitch_id = lookup_twitch_id(@report.reporter_twitch_name) @@ -68,6 +80,8 @@ def create PledgeMailer.confirm_receipt(@report).deliver_now flash[:notice] = "You've successfully submitted the report. Thank you." + check_report_matches() + redirect_to root_path else flash.now[:alert] ||= "" @@ -113,6 +127,26 @@ def unwatch end end end + + def spam + if @report.update(spam: true) + flash[:success] = "Report has been marked as spam." + else + flash[:error] = "Unable to update the report." + end + + redirect_to report_path(@report) + end + + def unspam + if @report.update(spam: false) + flash[:success] = "Report has been marked as not spam." + else + flash[:error] = "Unable to update the report." + end + redirect_to report_path(@report) + end + def twitch_lookup if params[:twitch_username].blank? @@ -148,6 +182,86 @@ def find_report redirect_to staff_index_path end + def check_report_matches + time_threshold = 2.hour + current_time = Time.current + time_window_start = current_time - time_threshold + time_window_end = current_time + time_threshold + + potential_matches = Report.where( + reporter_email: @report.reporter_email, + reported_twitch_id: @report.reported_twitch_id, + created_at: time_window_start..time_window_end # time range + ) + + # mark as spam based on a similarity threshold + spam_found = false + potential_matches.find_each do |match| + if match != @report + score, spam_report = similarity_score(@report, match) + + if score >= 0.70 + if spam_report.save + match.update(spam: true, dismissed: false, warned: false, revoked: false, watched: false) + spam_found = true + puts "SpamReport created successfully." + else + puts "Failed to create SpamReport: #{spam_report.errors.full_messages.join(", ")}" + end + + end + + end + end + + if spam_found + @report.update(spam: true, dismissed: false, warned: false, revoked: false, watched: false) + end + end + + def similarity_score(report1, report2) + description_similarity = text_similarity(report1.incident_description, report2.incident_description) + + # recommended_response_similarity based on whether the responses exist + + if report1.recommended_response.blank? && report2.recommended_response.blank? + recommended_response_similarity = 1.0 # nil values as a perfect match + + elsif !report1.recommended_response.blank? && !report2.recommended_response.blank? + recommended_response_similarity = text_similarity(report1.recommended_response, report2.recommended_response) + else + recommended_response_similarity = 0 + end + + # weighted avg of both where required weighs more + spam_info_value = "Description Simillarity of #{description_similarity} and Recommend Simillarity of #{recommended_response_similarity} for #{report1.id} & #{report2.id}" + + # flash[:notice] << spam_info_value + + spam_report = SpamReport.new( + description: spam_info_value, + report1: report1, + report2: report2 + ) + + + return (description_similarity + recommended_response_similarity)/2, spam_report + + end + + def text_similarity(text1, text2) + + documents = [ + TfIdfSimilarity::Document.new(text1), + TfIdfSimilarity::Document.new(text2) + ] + + model = TfIdfSimilarity::TfIdfModel.new(documents) + matrix = model.similarity_matrix + + return matrix[0, 1] + end + private def ensure_staff unless current_user.is_moderator? || current_user.is_admin? @@ -163,5 +277,4 @@ def display_timezone def report_params params.require(:report).permit(:reporter_email, :reporter_twitch_name, :reporter_twitch_id, :reported_twitch_name, :reported_twitch_id, :incident_stream, :incident_stream_twitch_id, :incident_occurred, :incident_description, :recommended_response, :image,) end - end diff --git a/app/controllers/verifications_controller.rb b/app/controllers/verifications_controller.rb index 8167017b..f8b0175a 100644 --- a/app/controllers/verifications_controller.rb +++ b/app/controllers/verifications_controller.rb @@ -1,5 +1,5 @@ class VerificationsController < ApplicationController - + include RateLimitable layout "backstage", only: [ :index, :show, :verify_eligibility, :deny_eligibility, :withdraw_eligibility ] skip_before_action :verify_authenticity_token, only: [ :watch, :unwatch ] @@ -11,6 +11,7 @@ class VerificationsController < ApplicationController before_action :find_verification, only: [ :show, :verify_eligibility, :deny_eligibility, :withdraw_eligibility, :verify, :deny, :ignore, :withdraw, :voucher, :resend_cert, :watch, :unwatch ] around_action :display_timezone + def index # f is used to filter reports by scope @@ -48,6 +49,12 @@ def new def create @verification = Verification.new(verification_params) + # First check IP-based rate limiting + return unless limit_request_by_ip('verificate_create', new_verification_path) + + # Fallback to device signature-based limiting (may not be needed) + return unless limit_request_by_signature('verificate_create', new_verification_path) + if @verification.save # TODO: send notification to staff @@ -214,5 +221,5 @@ def display_timezone def verification_params params.require(:verification).permit(:first_name, :last_name, :email, :birth_date, :discord_username, :player_id_type, :player_id, :player_id_and_discord, :gender, :pronouns, :photo_id, :doctors_note, :social_profile, :voice_requested, :additional_notes) end - + end diff --git a/app/models/report.rb b/app/models/report.rb index eccc9ac6..44ad3ae4 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -6,7 +6,8 @@ class Report < ApplicationRecord warned: "Warned", revoked: "Revoked", watched: "Watched", - all: "All" + all: "All", + spam: "Spam" }.freeze IMAGE_STYLES = { @@ -38,6 +39,10 @@ class Report < ApplicationRecord has_one :revocation has_many :comments, as: :commentable + + # report can be referenced by multiple SpamReports in two different fields + has_many :spam_reports_as_report1, class_name: "SpamReport", foreign_key: "report1_id" + has_many :spam_reports_as_report2, class_name: "SpamReport", foreign_key: "report2_id" image_accessor :image @@ -47,7 +52,7 @@ class Report < ApplicationRecord scope :dismissed, lambda { where(dismissed: true) } scope :warned, lambda { where(warned: true) } scope :revoked, lambda { where(revoked: true) } - scope :unresolved, lambda { where("#{table_name}.dismissed IS FALSE AND #{table_name}.warned IS FALSE AND #{table_name}.revoked IS FALSE") } + scope :unresolved, lambda { where("#{table_name}.dismissed IS FALSE AND #{table_name}.warned IS FALSE AND #{table_name}.revoked IS FALSE AND #{table_name}.spam IS FALSE") } scope :watched, lambda { where(watched: true) } scope :search, lambda { |search| where("lower(reported_twitch_name) LIKE :search OR lower(reported_twitch_id) LIKE :search OR @@ -58,12 +63,13 @@ class Report < ApplicationRecord lower(incident_stream_twitch_id) LIKE :search OR lower(incident_description) LIKE :search", search: "%#{search.downcase}%") } + scope :spam, lambda { where(spam: true) } def unresolved? - self.dismissed == false && self.warned == false && self.revoked == false + self.dismissed == false && self.warned == false && self.revoked == false && self.spam == false end - + def word_count return (self.incident_description + " " + self.recommended_response).gsub(/[^\w\s]/,"").split.count end @@ -101,10 +107,20 @@ def related_reports Report.where.not(id: self.id).where(reported_twitch_id: self.reported_twitch_id) end end + + def related_spam_reports + unless self.reported_twitch_id.blank? + + # all related SpamReport records + SpamReport.where("report1_id = ? OR report2_id = ?", self.id, self.id).distinct + + end + + end protected def ensure_sane_review - if (self.dismissed || self.warned || self.revoked) && !(self.dismissed ^ self.warned ^ self.revoked) + if (self.dismissed || self.warned || self.revoked || self.spam) && !(self.dismissed ^ self.warned ^ self.revoked ^ self.spam) if self.dismissed errors.add(:dismissed, "must be the only status flag set") end @@ -114,6 +130,9 @@ def ensure_sane_review if self.revoked errors.add(:revoked, "must be the only status flag set") end + if self.spam + errors.add(:spam, "must be the only status flag set") + end end end diff --git a/app/models/spam_report.rb b/app/models/spam_report.rb new file mode 100644 index 00000000..9f05140d --- /dev/null +++ b/app/models/spam_report.rb @@ -0,0 +1,17 @@ +class SpamReport < ApplicationRecord + # foreign keys for the two reports + belongs_to :report1, class_name: "Report", foreign_key: "report1_id" + belongs_to :report2, class_name: "Report", foreign_key: "report2_id" + + # validations + validates :description, presence: true + validates :report1_id, presence: true + validates :report2_id, presence: true + validate :reports_must_be_different + + private + def reports_must_be_different + errors.add(:report2_id, "must be different from report1") if report1_id == report2_id + end + +end \ No newline at end of file diff --git a/app/views/reports/_related_spam.html.erb b/app/views/reports/_related_spam.html.erb new file mode 100644 index 00000000..440896bc --- /dev/null +++ b/app/views/reports/_related_spam.html.erb @@ -0,0 +1,21 @@ +
+
+
+
+ Related Report Spams Descriptions <%= @spam_reports.present? ? "(#{@spam_reports.size})" : ''%> +
+ <% if @spam_reports.present? %> +
+ <% @spam_reports.each do |spam_report| %> +  · 
  • + <%= spam_report.description %> +
  • + <% end %> +
    + <% else %> +
    + No related automated spam description dectection. +
    + <% end %> +
    +
    \ No newline at end of file diff --git a/app/views/reports/_row.html.erb b/app/views/reports/_row.html.erb index 36dc0f02..e8558ca5 100644 --- a/app/views/reports/_row.html.erb +++ b/app/views/reports/_row.html.erb @@ -8,9 +8,13 @@
    <% if report.unresolved? %> -
    - <%= l(report.created_at, format: "%b. %-d, %Y · %-l:%M%P ") %> -
    +
    + <%= l(report.created_at, format: "%b. %-d, %Y · %-l:%M%P ") %> +
    + <% elsif report.spam? %> +
    + (SPAM) <%= l(report.created_at, format: "%b. %-d, %Y · %-l:%M%P ") %> +
    <% elsif report.dismissed %>
    <%= l(report.updated_at, format: "%b. %-d, %Y") %> ·  diff --git a/app/views/reports/_show_spam.html.erb b/app/views/reports/_show_spam.html.erb new file mode 100644 index 00000000..3b65d090 --- /dev/null +++ b/app/views/reports/_show_spam.html.erb @@ -0,0 +1,28 @@ +
    +
    Spam Badge
    +
    +
    + Spam <%= l(@report.created_at, format: "%b. %-d, %Y · %-l:%M%P") %> +
    + <%= render(partial: "reports/watchable_toggle")%> +
    +
    +<%= render(partial: "reports/identifiers") %> +<%= render(partial: "reports/materials") %> +<%= render(partial: "reports/associated_pledge") %> + +
    + <% if @pledge %> + <%= link_to("Revoke", new_report_revocation_path(@report, back: request.fullpath), class: 'revoke-button button') %> + <%= link_to("Warn", new_report_warning_path(@report, back: request.fullpath), class: 'warn-button button') %> + <%= link_to("Unspam", new_report_warning_path(@report, back: request.fullpath), class: 'warn-button button') %> + <% end %> + <%= link_to("Dismiss", dismiss_report_path(@report), method: :post, class: 'dismiss-button button') %> + <%= link_to("Unspam", unspam_report_path(@report), method: :post, class: 'warn-button button') %> + <%= link_to('Back', params[:back].present? ? params[:back] : reports_path, class: "back-button mini button") %> +
    + +<%= render(partial: "reports/reporter_details") %> +<%= render(partial: "reports/related_spam") %> +<%= render(partial: "reports/related_reports") %> +<%= render(partial: "reports/comments")%> diff --git a/app/views/reports/_show_unresolved.html.erb b/app/views/reports/_show_unresolved.html.erb index 061df200..d3bb7525 100644 --- a/app/views/reports/_show_unresolved.html.erb +++ b/app/views/reports/_show_unresolved.html.erb @@ -11,6 +11,8 @@ <%= render(partial: "reports/materials") %> <%= render(partial: "reports/associated_pledge") %>
    + <%= link_to("Spam", spam_report_path(@report), method: :post, class: 'revoke-button button') %> + <% if @pledge %> <%= link_to("Revoke", new_report_revocation_path(@report, back: request.fullpath), class: 'revoke-button button') %> <%= link_to("Warn", new_report_warning_path(@report, back: request.fullpath), class: 'warn-button button') %> diff --git a/app/views/reports/show.html.erb b/app/views/reports/show.html.erb index 44077167..ce766c6d 100644 --- a/app/views/reports/show.html.erb +++ b/app/views/reports/show.html.erb @@ -9,6 +9,8 @@ <%= render(partial: "reports/show_warned") %> <% elsif @report.revoked? %> <%= render(partial: "reports/show_revoked") %> + <% elsif @report.spam? %> + <%= render(partial: "reports/show_spam") %> <% end %>
    diff --git a/config/.tool-versions b/config/.tool-versions new file mode 100644 index 00000000..7f44804c --- /dev/null +++ b/config/.tool-versions @@ -0,0 +1 @@ +ruby 2.6.3 diff --git a/config/routes.rb b/config/routes.rb index f2442d7a..37af190b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,8 @@ post :undismiss post :watch post :unwatch + post :unspam + post :spam end resources :warnings, only: [ :new, :create ] resources :revocations, only: [ :new, :create ] diff --git a/db/migrate/20190904053235_change_desired_outcome_to_recommended_response.rb b/db/migrate/20190904053235_change_desired_outcome_to_recommended_response.rb index 7715639f..438139ae 100644 --- a/db/migrate/20190904053235_change_desired_outcome_to_recommended_response.rb +++ b/db/migrate/20190904053235_change_desired_outcome_to_recommended_response.rb @@ -1,5 +1,5 @@ class ChangeDesiredOutcomeToRecommendedResponse < ActiveRecord::Migration[6.0] def change - rename_column :reports, :desired_outcome, :recommended_response + #rename_column :reports, :desired_outcome, :recommended_response end end diff --git a/db/migrate/20240401033626_add_spam_to_reports.rb b/db/migrate/20240401033626_add_spam_to_reports.rb new file mode 100644 index 00000000..d7894e87 --- /dev/null +++ b/db/migrate/20240401033626_add_spam_to_reports.rb @@ -0,0 +1,5 @@ +class AddSpamToReports < ActiveRecord::Migration[6.1] + def change + add_column :reports, :spam, :boolean, default: false + end +end diff --git a/db/migrate/20240510134850_create_spam_reports.rb b/db/migrate/20240510134850_create_spam_reports.rb new file mode 100644 index 00000000..d0b59e83 --- /dev/null +++ b/db/migrate/20240510134850_create_spam_reports.rb @@ -0,0 +1,12 @@ +class CreateSpamReports < ActiveRecord::Migration[6.1] + def change + create_table :spam_reports do |t| + t.text :description + + t.references :report1, null: false, foreign_key: { to_table: :reports } + t.references :report2, null: false, foreign_key: { to_table: :reports } + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 20d54e42..d0ecb8a2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,9 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2024_01_10_174212) do +ActiveRecord::Schema.define(version: 2024_05_10_134850) do - create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false t.bigint "record_id", null: false @@ -22,7 +22,7 @@ t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end - create_table "active_storage_blobs", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "active_storage_blobs", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "key", null: false t.string "filename", null: false t.string "content_type" @@ -34,7 +34,7 @@ t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end - create_table "active_storage_variant_records", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "active_storage_variant_records", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.bigint "blob_id", null: false t.string "variation_digest", null: false t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true @@ -57,7 +57,7 @@ t.string "mixer" end - create_table "ahoy_events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "ahoy_events", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.bigint "visit_id" t.bigint "user_id" t.string "name" @@ -68,7 +68,7 @@ t.index ["visit_id"], name: "index_ahoy_events_on_visit_id" end - create_table "ahoy_visits", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "ahoy_visits", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "visit_token" t.string "visitor_token" t.bigint "user_id" @@ -96,7 +96,7 @@ t.index ["visitor_token", "started_at"], name: "index_ahoy_visits_on_visitor_token_and_started_at" end - create_table "badge_activations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "badge_activations", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "twitch_username" t.integer "twitch_id" t.datetime "activated_on" @@ -105,7 +105,7 @@ t.datetime "updated_at", precision: 6, null: false end - create_table "comments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "comments", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.integer "commenter_id" t.integer "commentable_id" t.string "commentable_type" @@ -115,7 +115,7 @@ t.datetime "updated_at", precision: 6, null: false end - create_table "concerns", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "concerns", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "concerning_player_id" t.string "concerning_player_id_type" t.text "background" @@ -190,6 +190,7 @@ t.integer "reported_twitch_id" t.integer "incident_stream_twitch_id" t.bigint "ahoy_visit_id" + t.boolean "spam", default: false t.index ["ahoy_visit_id"], name: "index_reports_on_ahoy_visit_id" t.index ["reported_twitch_id"], name: "index_reports_on_reported_twitch_id" t.index ["reporter_email"], name: "index_reports_on_reporter_email" @@ -207,6 +208,16 @@ t.index ["reviewer_id"], name: "index_revocations_on_reviewer_id" end + create_table "spam_reports", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| + t.text "description" + t.bigint "report1_id", null: false + t.bigint "report2_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["report1_id"], name: "index_spam_reports_on_report1_id" + t.index ["report2_id"], name: "index_spam_reports_on_report2_id" + end + create_table "stories", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "headline" t.text "description" @@ -218,7 +229,7 @@ t.datetime "published_on" end - create_table "survey_invites", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "survey_invites", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "email" t.string "survey_code" t.string "surveyable_type" @@ -229,7 +240,7 @@ t.datetime "updated_at", precision: 6, null: false end - create_table "twitch_tokens", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "twitch_tokens", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "access_token" t.integer "expires_in" t.datetime "created_at", precision: 6, null: false @@ -271,7 +282,7 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end - create_table "verifications", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + create_table "verifications", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| t.string "first_name" t.string "last_name" t.string "email" @@ -308,4 +319,6 @@ add_foreign_key "conduct_warnings", "users", column: "reviewer_id" add_foreign_key "reports", "users", column: "reviewer_id" add_foreign_key "revocations", "users", column: "reviewer_id" + add_foreign_key "spam_reports", "reports", column: "report1_id" + add_foreign_key "spam_reports", "reports", column: "report2_id" end diff --git a/db/test_data.rb b/db/test_data.rb new file mode 100644 index 00000000..4d7b30a5 --- /dev/null +++ b/db/test_data.rb @@ -0,0 +1,74 @@ +# Create Reports for testing the check_report_matches functionality + +# Constants +reporter_email = "test@example.com" +reported_twitch_id = "123456789" +reported_twitch_name = "pcalle2" +incident_occurred = "pcalle2" +incident_stream = "pcalle2" +incident_stream_twitch_id = "123" + +# Report 1: Exact Match within Time Window +Report.create( + reporter_email: reporter_email, + reported_twitch_id: reported_twitch_id, + incident_description: "Report about an issue", + recommended_response: "Immediate action required", + reported_twitch_name: reported_twitch_name, + incident_occurred: incident_occurred, + incident_stream_twitch_id: incident_stream_twitch_id, + incident_stream: incident_stream, + created_at: 1.hour.ago +) + +# Report 2: Match with slightly different descriptions and responses +Report.create( + reporter_email: reporter_email, + reported_twitch_id: reported_twitch_id, + incident_description: "Detailed report about an issue", + recommended_response: "Immediate action needed", + reported_twitch_name: reported_twitch_name, + incident_occurred: incident_occurred, + incident_stream_twitch_id: incident_stream_twitch_id, + incident_stream: incident_stream, + created_at: 1.hour.ago +) + +# Report 3: Outside Time Window +Report.create( + reporter_email: reporter_email, + reported_twitch_id: reported_twitch_id, + incident_description: "Old report about an unrelated issue", + recommended_response: "Review later", + reported_twitch_name: reported_twitch_name, + incident_occurred: incident_occurred, + incident_stream_twitch_id: incident_stream_twitch_id, + incident_stream: incident_stream, + created_at: 3.days.ago +) + +# Report 4: No similarity in description or response +Report.create( + reporter_email: reporter_email, + reported_twitch_id: reported_twitch_id, + incident_description: "Unrelated issue report", + recommended_response: '', # No recommended response + reported_twitch_name: reported_twitch_name, + incident_occurred: incident_occurred, + incident_stream_twitch_id: incident_stream_twitch_id, + incident_stream: incident_stream, + created_at: 30.minutes.ago +) + +# Report 5: Report with nil recommended_response matching another nil +Report.create( + reporter_email: reporter_email, + reported_twitch_id: reported_twitch_id, + incident_description: "Issue report with no response recommended", + recommended_response: "", + reported_twitch_name: reported_twitch_name, + incident_occurred: incident_occurred, + incident_stream_twitch_id: incident_stream_twitch_id, + incident_stream: incident_stream, + created_at: 2.hours.ago +) diff --git a/lib/tasks/mock_spam.rake b/lib/tasks/mock_spam.rake new file mode 100644 index 00000000..e4edb815 --- /dev/null +++ b/lib/tasks/mock_spam.rake @@ -0,0 +1,7 @@ +namespace :db do + desc "Load custom seed data" + task :load_custom_data => :environment do + load Rails.root.join('db', 'test_data.rb') + end + end + \ No newline at end of file diff --git a/test/fixtures/spam_reports.yml b/test/fixtures/spam_reports.yml new file mode 100644 index 00000000..49d5c0ac --- /dev/null +++ b/test/fixtures/spam_reports.yml @@ -0,0 +1,11 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + description: MyText + report1: one + report2: one + +two: + description: MyText + report1: two + report2: two diff --git a/test/models/spam_report_test.rb b/test/models/spam_report_test.rb new file mode 100644 index 00000000..1566db2f --- /dev/null +++ b/test/models/spam_report_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SpamReportTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end