From bf6bb947027ff22544de657716e6bfa861015fa2 Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 10 Nov 2019 23:24:28 -0600 Subject: [PATCH] Add new IP address search page. Add a new IP address search page at /ip_addresses. Replaces the old search page at /moderator/ip_addrs. On user profile pages, show the user's last known IP to mods. Also add search links for finding other IPs or accounts associated with the user. IP address search uses a big UNION ALL statement to merge IP addresses across various tables into a single view. This makes searching easier, but is known to timeout in certain cases. Fixes #4207 (the new IP search page supports searching by subnet). --- Gemfile | 2 + Gemfile.lock | 6 ++ app/controllers/ip_addresses_controller.rb | 17 +++++ app/helpers/application_helper.rb | 2 +- app/logical/ip_address_type.rb | 9 +++ app/models/application_record.rb | 15 +++++ app/models/ip_address.rb | 28 ++++++++ app/views/ip_addresses/_index.html.erb | 30 +++++++++ .../ip_addresses/_index_by_ip_addr.html.erb | 19 ++++++ .../ip_addresses/_index_by_user.html.erb | 19 ++++++ app/views/ip_addresses/index.html.erb | 21 ++++++ app/views/users/_statistics.html.erb | 10 +++ config/routes.rb | 1 + .../20191111004329_create_ip_addresses.rb | 5 ++ db/structure.sql | 66 ++++++++++++++++++- db/views/ip_addresses_v01.sql | 24 +++++++ .../ip_addresses_controller_test.rb | 39 +++++++++++ 17 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 app/controllers/ip_addresses_controller.rb create mode 100644 app/logical/ip_address_type.rb create mode 100644 app/models/ip_address.rb create mode 100644 app/views/ip_addresses/_index.html.erb create mode 100644 app/views/ip_addresses/_index_by_ip_addr.html.erb create mode 100644 app/views/ip_addresses/_index_by_user.html.erb create mode 100644 app/views/ip_addresses/index.html.erb create mode 100644 db/migrate/20191111004329_create_ip_addresses.rb create mode 100644 db/views/ip_addresses_v01.sql create mode 100644 test/functional/ip_addresses_controller_test.rb diff --git a/Gemfile b/Gemfile index b566d522f..a9c340af7 100644 --- a/Gemfile +++ b/Gemfile @@ -43,6 +43,8 @@ gem 'request_store' gem 'builder' # gem 'did_you_mean' # github.com/yuki24/did_you_mean/issues/117 gem 'puma' +gem 'scenic' +gem 'ipaddress' # needed for looser jpeg header compat gem 'ruby-imagespec', :require => "image_spec", :git => "https://github.com/r888888888/ruby-imagespec.git", :branch => "exif-fixes" diff --git a/Gemfile.lock b/Gemfile.lock index 6199dee5b..e61713939 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -171,6 +171,7 @@ GEM multi_xml (>= 0.5.2) i18n (1.7.0) concurrent-ruby (~> 1.0) + ipaddress (0.8.3) jmespath (1.4.0) jquery-rails (4.3.5) rails-dom-testing (>= 1, < 3) @@ -326,6 +327,9 @@ GEM crass (~> 1.0.2) nokogiri (>= 1.8.0) nokogumbo (~> 2.0) + scenic (1.5.1) + activerecord (>= 4.0.0) + railties (>= 4.0.0) selenium-webdriver (3.142.6) childprocess (>= 0.5, < 4.0) rubyzip (>= 1.2.2) @@ -435,6 +439,7 @@ DEPENDENCIES ffaker flamegraph httparty + ipaddress jquery-rails listen mechanize @@ -465,6 +470,7 @@ DEPENDENCIES ruby-vips rubyzip sanitize + scenic selenium-webdriver shoulda-context shoulda-matchers diff --git a/app/controllers/ip_addresses_controller.rb b/app/controllers/ip_addresses_controller.rb new file mode 100644 index 000000000..a1f382f97 --- /dev/null +++ b/app/controllers/ip_addresses_controller.rb @@ -0,0 +1,17 @@ +class IpAddressesController < ApplicationController + respond_to :html, :xml, :json + before_action :moderator_only + + def index + if search_params[:group_by] == "ip_addr" + @ip_addresses = IpAddress.search(search_params).group_by_ip_addr + respond_with(@ip_addresses) + elsif search_params[:group_by] == "user" + @ip_addresses = IpAddress.search(search_params).group_by_user + respond_with(@ip_addresses) + else + @ip_addresses = IpAddress.includes(:user, :model).paginated_search(params) + respond_with(@ip_addresses) + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 27edc5037..b127181cf 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -109,7 +109,7 @@ module ApplicationHelper end def link_to_ip(ip) - link_to ip, moderator_ip_addrs_path(:search => {:ip_addr => ip}) + link_to ip, ip_addresses_path(search: { ip_addr: ip, group_by: "user" }) end def link_to_search(search) diff --git a/app/logical/ip_address_type.rb b/app/logical/ip_address_type.rb new file mode 100644 index 000000000..75570790f --- /dev/null +++ b/app/logical/ip_address_type.rb @@ -0,0 +1,9 @@ +class IpAddressType < ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Inet + def cast(value) + super(IPAddress.parse(value)) + end + + def serialize(value) + value.to_string + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 8a8df0e24..93bd7412f 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -50,6 +50,11 @@ class ApplicationRecord < ActiveRecord::Base where.not("#{qualified_column_for(attr)} ~ ?", "(?e)" + value) end + def where_inet_matches(attr, value) + ip = IPAddress.parse(value) + where("#{qualified_column_for(attr)} <<= ?", ip.to_string) + end + def where_array_includes_any(attr, values) where("#{qualified_column_for(attr)} && ARRAY[?]", values) end @@ -79,6 +84,14 @@ class ApplicationRecord < ActiveRecord::Base end end + def search_inet_attribute(attr, params) + if params[attr].present? + where_inet_matches(attr, params[attr]) + else + all + end + end + # range: "5", ">5", "<5", ">=5", "<=5", "5..10", "5,6,7" def numeric_attribute_matches(attribute, range) return all unless range.present? @@ -130,6 +143,8 @@ class ApplicationRecord < ActiveRecord::Base search_boolean_attribute(name, params) when :integer, :datetime numeric_attribute_matches(name, params[name]) + when :inet + search_inet_attribute(name, params) else raise NotImplementedError, "unhandled attribute type" end diff --git a/app/models/ip_address.rb b/app/models/ip_address.rb new file mode 100644 index 000000000..ecb9c9580 --- /dev/null +++ b/app/models/ip_address.rb @@ -0,0 +1,28 @@ +class IpAddress < ApplicationRecord + belongs_to :model, polymorphic: true + belongs_to :user + attribute :ip_addr, IpAddressType.new + + def self.model_types + %w[Post User Comment Dmail ArtistVersion ArtistCommentaryVersion NoteVersion WikiPageVersion] + end + + def self.search(params) + q = super + q = q.where.not(model_type: "Dmail") unless CurrentUser.is_admin? + q = q.search_attributes(params, :user, :model_type, :model_id, :ip_addr) + q.order(created_at: :desc) + end + + def self.group_by_ip_addr + group(:ip_addr).select("ip_addr, COUNT(*) AS count_all").reorder("count_all DESC, ip_addr") + end + + def self.group_by_user + group(:user_id).select("user_id, COUNT(*) AS count_all").reorder("count_all DESC, user_id") + end + + def readonly? + true + end +end diff --git a/app/views/ip_addresses/_index.html.erb b/app/views/ip_addresses/_index.html.erb new file mode 100644 index 000000000..686c71f77 --- /dev/null +++ b/app/views/ip_addresses/_index.html.erb @@ -0,0 +1,30 @@ + + + + + + + + + + + + <% @ip_addresses.each do |ip| %> + + + + + + + + <% end %> + +
SourceIDIP AddressUserDate
<%= link_to ip.model_type.underscore.humanize, ip_addresses_path(search: { model_type: ip.model_type }) %><%= link_to "##{ip.model_id}", ip.model %> + <%= link_to ip.ip_addr, ip_addresses_path(search: { ip_addr: ip.ip_addr }) %> + <%= link_to "»", ip_addresses_path(search: { ip_addr: ip.ip_addr, group_by: "user" }) %> + + <%= link_to_user ip.user %> + <%= link_to "»", ip_addresses_path(search: { user_id: ip.user_id, group_by: "ip_addr" }) %> + <%= time_ago_in_words_tagged ip.created_at %>
+ +<%= numbered_paginator(@ip_addresses) %> diff --git a/app/views/ip_addresses/_index_by_ip_addr.html.erb b/app/views/ip_addresses/_index_by_ip_addr.html.erb new file mode 100644 index 000000000..27f58645a --- /dev/null +++ b/app/views/ip_addresses/_index_by_ip_addr.html.erb @@ -0,0 +1,19 @@ + + + + + + + + + <% @ip_addresses.each do |ip| %> + + + + + <% end %> + +
IP AddressUses
+ <%= link_to ip.ip_addr, ip_addresses_path(search: { ip_addr: ip.ip_addr }) %> + <%= link_to "»", ip_addresses_path(search: { ip_addr: ip.ip_addr, group_by: "user" }) %> + <%= link_to ip.count_all, ip_addresses_path(search: { ip_addr: ip.ip_addr }) %>
diff --git a/app/views/ip_addresses/_index_by_user.html.erb b/app/views/ip_addresses/_index_by_user.html.erb new file mode 100644 index 000000000..a5a50a00c --- /dev/null +++ b/app/views/ip_addresses/_index_by_user.html.erb @@ -0,0 +1,19 @@ + + + + + + + + + <% @ip_addresses.each do |ip| %> + + + + + <% end %> + +
UserUses
+ <%= link_to_user ip.user %> + <%= link_to "»", ip_addresses_path(search: { user_id: ip.user_id, group_by: "ip_addr" }) %> + <%= link_to ip.count_all, ip_addresses_path(search: { user_id: ip.user_id, ip_addr: params[:search][:ip_addr] }) %>
diff --git a/app/views/ip_addresses/index.html.erb b/app/views/ip_addresses/index.html.erb new file mode 100644 index 000000000..1f11d3d65 --- /dev/null +++ b/app/views/ip_addresses/index.html.erb @@ -0,0 +1,21 @@ +
+
+ <%= search_form_for(ip_addresses_path) do |f| %> + <%= f.input :user_id, label: "User ID", input_html: { value: params[:search][:user_id] }, hint: "Separate with spaces" %> + <%= f.input :user_name, label: "User Name", input_html: { "data-autocomplete": "user", value: params[:search][:user_name] }, hint: "Use * for wildcard" %> + <%= f.input :ip_addr, label: "IP Address", input_html: { value: params[:search][:ip_addr] } %> + <%= f.input :created_at, label: "Date", input_html: { value: params[:search][:created_at] } %> + <%= f.input :model_type, label: "Source", collection: IpAddress.model_types, include_blank: true, selected: params[:search][:model_type] %> + <%= f.input :group_by, label: "Group By", collection: [["User", "user"], ["IP Address", "ip_addr"]], include_blank: true, selected: params[:search][:group_by] %> + <%= f.submit "Search" %> + <% end %> + + <% if params[:search][:group_by] == "user" %> + <%= render "index_by_user" %> + <% elsif params[:search][:group_by] == "ip_addr" %> + <%= render "index_by_ip_addr" %> + <% elsif @ip_addresses.present? %> + <%= render "index" %> + <% end %> +
+
diff --git a/app/views/users/_statistics.html.erb b/app/views/users/_statistics.html.erb index 3349b14d0..e426275fe 100644 --- a/app/views/users/_statistics.html.erb +++ b/app/views/users/_statistics.html.erb @@ -10,6 +10,16 @@ Join Date <%= presenter.join_date %> + <% if CurrentUser.is_moderator? %> + + Last IP + + <%= link_to user.last_ip_addr, ip_addresses_path(search: { ip_addr: user.last_ip_addr }) %> + (<%= link_to "users", ip_addresses_path(search: { ip_addr: user.last_ip_addr, group_by: "user" }) %>, + <%= link_to "IPs", ip_addresses_path(search: { user_id: user.id, group_by: "ip_addr" }) %>) + + + <% end %> Inviter diff --git a/config/routes.rb b/config/routes.rb index 947483e36..bef725617 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -155,6 +155,7 @@ Rails.application.routes.draw do resource :visit, :controller => "forum_topic_visits" end resources :ip_bans + resources :ip_addresses, only: [:index] resource :iqdb_queries, :only => [:show, :create] do collection do get :preview diff --git a/db/migrate/20191111004329_create_ip_addresses.rb b/db/migrate/20191111004329_create_ip_addresses.rb new file mode 100644 index 000000000..9ac88ec71 --- /dev/null +++ b/db/migrate/20191111004329_create_ip_addresses.rb @@ -0,0 +1,5 @@ +class CreateIpAddresses < ActiveRecord::Migration[6.0] + def change + create_view :ip_addresses + end +end diff --git a/db/structure.sql b/db/structure.sql index 237fe529c..54b0a1b13 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2153,6 +2153,69 @@ CREATE SEQUENCE public.forum_topics_id_seq ALTER SEQUENCE public.forum_topics_id_seq OWNED BY public.forum_topics.id; +-- +-- Name: ip_addresses; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.ip_addresses AS + SELECT 'ArtistVersion'::text AS model_type, + artist_versions.id AS model_id, + artist_versions.updater_id AS user_id, + artist_versions.updater_ip_addr AS ip_addr, + artist_versions.created_at + FROM public.artist_versions +UNION ALL + SELECT 'ArtistCommentaryVersion'::text AS model_type, + artist_commentary_versions.id AS model_id, + artist_commentary_versions.updater_id AS user_id, + artist_commentary_versions.updater_ip_addr AS ip_addr, + artist_commentary_versions.created_at + FROM public.artist_commentary_versions +UNION ALL + SELECT 'Comment'::text AS model_type, + comments.id AS model_id, + comments.creator_id AS user_id, + comments.creator_ip_addr AS ip_addr, + comments.created_at + FROM public.comments +UNION ALL + SELECT 'Dmail'::text AS model_type, + dmails.id AS model_id, + dmails.from_id AS user_id, + dmails.creator_ip_addr AS ip_addr, + dmails.created_at + FROM public.dmails +UNION ALL + SELECT 'NoteVersion'::text AS model_type, + note_versions.id AS model_id, + note_versions.updater_id AS user_id, + note_versions.updater_ip_addr AS ip_addr, + note_versions.created_at + FROM public.note_versions +UNION ALL + SELECT 'Post'::text AS model_type, + posts.id AS model_id, + posts.uploader_id AS user_id, + posts.uploader_ip_addr AS ip_addr, + posts.created_at + FROM public.posts +UNION ALL + SELECT 'User'::text AS model_type, + users.id AS model_id, + users.id AS user_id, + users.last_ip_addr AS ip_addr, + users.created_at + FROM public.users + WHERE (users.last_ip_addr IS NOT NULL) +UNION ALL + SELECT 'WikiPageVersion'::text AS model_type, + wiki_page_versions.id AS model_id, + wiki_page_versions.updater_id AS user_id, + wiki_page_versions.updater_ip_addr AS ip_addr, + wiki_page_versions.created_at + FROM public.wiki_page_versions; + + -- -- Name: ip_bans; Type: TABLE; Schema: public; Owner: - -- @@ -7407,6 +7470,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20190923071044'), ('20190926000912'), ('20191023191749'), -('20191024194544'); +('20191024194544'), +('20191111004329'); diff --git a/db/views/ip_addresses_v01.sql b/db/views/ip_addresses_v01.sql new file mode 100644 index 000000000..102c76b6b --- /dev/null +++ b/db/views/ip_addresses_v01.sql @@ -0,0 +1,24 @@ + SELECT 'ArtistVersion' AS model_type, id AS model_id, updater_id AS user_id, updater_ip_addr AS ip_addr, created_at + FROM artist_versions +UNION ALL + SELECT 'ArtistCommentaryVersion', id, updater_id, updater_ip_addr, created_at + FROM artist_commentary_versions +UNION ALL + SELECT 'Comment', id, creator_id, creator_ip_addr, created_at + FROM comments +UNION ALL + SELECT 'Dmail', id, from_id, creator_ip_addr, created_at + FROM dmails +UNION ALL + SELECT 'NoteVersion', id, updater_id, updater_ip_addr, created_at + FROM note_versions +UNION ALL + SELECT 'Post', id, uploader_id, uploader_ip_addr, created_at + FROM posts +UNION ALL + SELECT 'User', id, id, last_ip_addr, created_at + FROM users + WHERE last_ip_addr IS NOT NULL +UNION ALL + SELECT 'WikiPageVersion', id, updater_id, updater_ip_addr, created_at + FROM wiki_page_versions diff --git a/test/functional/ip_addresses_controller_test.rb b/test/functional/ip_addresses_controller_test.rb new file mode 100644 index 000000000..54d1548fb --- /dev/null +++ b/test/functional/ip_addresses_controller_test.rb @@ -0,0 +1,39 @@ +require 'test_helper' + +class IpAddressesControllerTest < ActionDispatch::IntegrationTest + context "The IP addresses controller" do + setup do + @mod = create(:mod_user, last_ip_addr: "1.2.3.4") + @user = create(:user, last_ip_addr: "5.6.7.8") + + CurrentUser.scoped(@user, "5.6.7.9") do + @note = create(:note) + @artist = create(:artist) + end + end + + context "index action" do + should "list all IP addresses" do + get_auth ip_addresses_path, @mod + assert_response :success + end + + should "allow searching by subnet" do + get_auth ip_addresses_path(search: { ip_addr: "5.0.0.0/8" }), @mod, as: :json + + assert_response :success + assert(response.parsed_body.present?) + end + + should "allow grouping by user" do + get_auth ip_addresses_path(search: { ip_addr: @user.last_ip_addr, group_by: "user" }), @mod + assert_response :success + end + + should "allow grouping by IP" do + get_auth ip_addresses_path(search: { user_id: @user.id, group_by: "ip_addr" }), @mod + assert_response :success + end + end + end +end