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 @@
+
+
+
+ | Source |
+ ID |
+ IP Address |
+ User |
+ Date |
+
+
+
+ <% @ip_addresses.each do |ip| %>
+
+ | <%= 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 %> |
+
+ <% end %>
+
+
+
+<%= 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 Address |
+ Uses |
+
+
+
+ <% @ip_addresses.each do |ip| %>
+
+ |
+ <%= 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 }) %> |
+
+ <% end %>
+
+
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 @@
+
+
+
+ | User |
+ Uses |
+
+
+
+ <% @ip_addresses.each do |ip| %>
+
+ |
+ <%= 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] }) %> |
+
+ <% 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..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