diff --git a/app/controllers/emails_controller.rb b/app/controllers/emails_controller.rb
index 6d8eb3f19..2f14ad9c3 100644
--- a/app/controllers/emails_controller.rb
+++ b/app/controllers/emails_controller.rb
@@ -26,6 +26,7 @@ class EmailsController < ApplicationController
@user = authorize User.find(params[:user_id]), policy_class: EmailAddressPolicy
if @user.authenticate_password(params[:user][:password])
+ UserEvent.build_from_request(@user, :email_change, request)
@user.update(email_address_attributes: { address: params[:user][:email] })
else
@user.errors.add(:base, "Password was incorrect")
diff --git a/app/controllers/ip_addresses_controller.rb b/app/controllers/ip_addresses_controller.rb
index 11a592b01..e0ab7ffd4 100644
--- a/app/controllers/ip_addresses_controller.rb
+++ b/app/controllers/ip_addresses_controller.rb
@@ -17,7 +17,7 @@ class IpAddressesController < ApplicationController
def show
@ip_address = authorize IpAddress.new(ip_addr: params[:id])
- @ip_info = @ip_address.lookup.info
+ @ip_info = @ip_address.lookup.response
respond_with(@ip_info)
end
end
diff --git a/app/controllers/ip_geolocations_controller.rb b/app/controllers/ip_geolocations_controller.rb
new file mode 100644
index 000000000..a51f9fcc4
--- /dev/null
+++ b/app/controllers/ip_geolocations_controller.rb
@@ -0,0 +1,9 @@
+class IpGeolocationsController < ApplicationController
+ respond_to :html, :json, :xml
+
+ def index
+ @ip_geolocations = authorize IpGeolocation.visible(CurrentUser.user).paginated_search(params, count_pages: true)
+
+ respond_with(@ip_geolocations)
+ end
+end
diff --git a/app/controllers/maintenance/user/deletions_controller.rb b/app/controllers/maintenance/user/deletions_controller.rb
index 627a65a73..8b85bb163 100644
--- a/app/controllers/maintenance/user/deletions_controller.rb
+++ b/app/controllers/maintenance/user/deletions_controller.rb
@@ -7,7 +7,7 @@ module Maintenance
end
def destroy
- deletion = UserDeletion.new(CurrentUser.user, params.dig(:user, :password))
+ deletion = UserDeletion.new(CurrentUser.user, params.dig(:user, :password), request)
deletion.delete!
if deletion.errors.none?
diff --git a/app/controllers/password_resets_controller.rb b/app/controllers/password_resets_controller.rb
index 06274fb0d..746f43869 100644
--- a/app/controllers/password_resets_controller.rb
+++ b/app/controllers/password_resets_controller.rb
@@ -9,6 +9,7 @@ class PasswordResetsController < ApplicationController
redirect_to password_reset_path
elsif @user.can_receive_email?(require_verification: false)
UserMailer.password_reset(@user).deliver_later
+ UserEvent.create_from_request!(@user, :password_reset, request)
flash[:notice] = "Password reset email sent. Check your email"
respond_with(@user, location: new_session_path)
else
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index f5fcc40e0..ffec5bc2a 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -10,6 +10,7 @@ class PasswordsController < ApplicationController
@user = authorize User.find(params[:user_id]), policy_class: PasswordPolicy
if @user.authenticate_password(params[:user][:old_password]) || @user.authenticate_login_key(params[:user][:signed_user_id]) || CurrentUser.user.is_owner?
+ UserEvent.build_from_request(@user, :password_change, request)
@user.update(password: params[:user][:password], password_confirmation: params[:user][:password_confirmation])
else
@user.errors.add(:base, "Incorrect password")
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 610e00150..e95e76e2b 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -20,7 +20,7 @@ class SessionsController < ApplicationController
end
def destroy
- session.delete(:user_id)
+ SessionLoader.new(request).logout
redirect_to(posts_path, :notice => "You are now logged out")
end
diff --git a/app/controllers/user_events_controller.rb b/app/controllers/user_events_controller.rb
new file mode 100644
index 000000000..5348fcfc5
--- /dev/null
+++ b/app/controllers/user_events_controller.rb
@@ -0,0 +1,10 @@
+class UserEventsController < ApplicationController
+ respond_to :html, :json, :xml
+
+ def index
+ @user_events = authorize UserEvent.visible(CurrentUser.user).paginated_search(params, count_pages: true)
+ @user_events = @user_events.includes(:user, user_session: [:ip_geolocation]) if request.format.html?
+
+ respond_with(@user_events)
+ end
+end
diff --git a/app/controllers/user_sessions_controller.rb b/app/controllers/user_sessions_controller.rb
new file mode 100644
index 000000000..49446fe8b
--- /dev/null
+++ b/app/controllers/user_sessions_controller.rb
@@ -0,0 +1,9 @@
+class UserSessionsController < ApplicationController
+ respond_to :html, :json, :xml
+
+ def index
+ @user_sessions = authorize UserSession.visible(CurrentUser.user).paginated_search(params, count_pages: true)
+
+ respond_with(@user_sessions)
+ end
+end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index a53d97ade..448f46c6f 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -70,6 +70,8 @@ class UsersController < ApplicationController
password_confirmation: params[:user][:password_confirmation]
)
+ UserEvent.build_from_request(@user, :user_creation, request)
+
if params[:user][:email].present?
@user.email_address = EmailAddress.new(address: params[:user][:email])
end
diff --git a/app/logical/concerns/searchable.rb b/app/logical/concerns/searchable.rb
index aa6e4971f..db7f7788b 100644
--- a/app/logical/concerns/searchable.rb
+++ b/app/logical/concerns/searchable.rb
@@ -193,7 +193,7 @@ module Searchable
search_text_attribute(name, params)
when :boolean
search_boolean_attribute(name, params)
- when :integer, :datetime
+ when :integer, :float, :datetime
search_numeric_attribute(name, params)
when :inet
search_inet_attribute(name, params)
diff --git a/app/logical/ip_lookup.rb b/app/logical/ip_lookup.rb
index 7a6e3bf1a..f8dae9747 100644
--- a/app/logical/ip_lookup.rb
+++ b/app/logical/ip_lookup.rb
@@ -9,23 +9,51 @@ class IpLookup
Danbooru.config.ip_registry_api_key.present?
end
- def initialize(ip_addr, api_key: Danbooru.config.ip_registry_api_key, cache_duration: 1.day)
- @ip_addr = ip_addr
+ def initialize(ip_addr, api_key: Danbooru.config.ip_registry_api_key, cache_duration: 3.days)
+ @ip_addr = IPAddress.parse(ip_addr.to_s)
@api_key = api_key
@cache_duration = cache_duration
end
- def info
+ def ip_info
+ return {} if response.blank?
+
+ {
+ ip_addr: ip_addr.to_s,
+ network: response.dig(:connection, :route),
+ asn: response.dig(:connection, :asn),
+ is_proxy: is_proxy?,
+ latitude: response.dig(:location, :latitude),
+ longitude: response.dig(:location, :longitude),
+ organization: response.dig(:connection, :organization),
+ time_zone: response.dig(:time_zone, :id),
+ continent: response.dig(:location, :continent, :code),
+ country: response.dig(:location, :country, :code),
+ region: response.dig(:location, :region, :code),
+ city: response.dig(:location, :city),
+ carrier: response.dig(:carrier, :name),
+ }.with_indifferent_access
+ end
+
+ def response
return {} if api_key.blank?
- response = Danbooru::Http.cache(cache_duration).get("https://api.ipregistry.co/#{ip_addr}?key=#{api_key}")
+ response = Danbooru::Http.cache(cache_duration).get("https://api.ipregistry.co/#{ip_addr.to_s}?key=#{api_key}")
return {} if response.status != 200
json = response.parse.deep_symbolize_keys.with_indifferent_access
json
end
- def is_proxy?
- info[:security].present? && info[:security].values.any?
+ def is_local?
+ if ip_addr.ipv4?
+ ip_addr.loopback? || ip_addr.link_local? || ip_addr.private?
+ elsif ip_addr.ipv6?
+ ip_addr.loopback? || ip_addr.link_local? || ip_addr.unique_local?
+ end
end
- memoize :info, :is_proxy?
+ def is_proxy?
+ response[:security].present? && response[:security].values.any?
+ end
+
+ memoize :response, :is_proxy?
end
diff --git a/app/logical/session_loader.rb b/app/logical/session_loader.rb
index 12f12e189..c3c4bcac1 100644
--- a/app/logical/session_loader.rb
+++ b/app/logical/session_loader.rb
@@ -10,12 +10,28 @@ class SessionLoader
end
def login(name, password)
- user = User.find_by_name(name)&.authenticate_password(password)
- return nil unless user
+ user = User.find_by_name(name)
- session[:user_id] = user.id
- user.update_column(:last_ip_addr, request.remote_ip)
- user
+ if user.present? && user.authenticate_password(password)
+ session[:user_id] = user.id
+
+ UserEvent.build_from_request(user, :login, request)
+ user.last_logged_in_at = Time.now
+ user.last_ip_addr = request.remote_ip
+ user.save!
+
+ user
+ elsif user.nil?
+ nil # username incorrect
+ else
+ UserEvent.create_from_request!(user, :failed_login, request)
+ nil # password incorrect
+ end
+ end
+
+ def logout
+ session.delete(:user_id)
+ UserEvent.create_from_request!(CurrentUser.user, :logout, request)
end
def load
@@ -76,6 +92,7 @@ class SessionLoader
CurrentUser.user = user
end
+ # XXX use rails 6.1 signed ids (https://github.com/rails/rails/blob/6-1-stable/activerecord/CHANGELOG.md)
def load_param_user(signed_user_id)
session[:user_id] = Danbooru::MessageVerifier.new(:login).verify(signed_user_id)
load_session_user
diff --git a/app/logical/user_deletion.rb b/app/logical/user_deletion.rb
index e39192736..d74d6ba56 100644
--- a/app/logical/user_deletion.rb
+++ b/app/logical/user_deletion.rb
@@ -1,23 +1,26 @@
class UserDeletion
include ActiveModel::Validations
- attr_reader :user, :password
+ attr_reader :user, :password, :request
validate :validate_deletion
- def initialize(user, password)
+ def initialize(user, password, request)
@user = user
@password = password
+ @request = request
end
def delete!
return false if invalid?
+
clear_user_settings
remove_favorites
clear_saved_searches
rename
reset_password
create_mod_action
+ create_user_event
user
end
@@ -27,6 +30,10 @@ class UserDeletion
ModAction.log("user ##{user.id} deleted", :user_delete)
end
+ def create_user_event
+ UserEvent.create_from_request!(user, :user_deletion, request)
+ end
+
def clear_saved_searches
SavedSearch.where(user_id: user.id).destroy_all
end
diff --git a/app/logical/user_verifier.rb b/app/logical/user_verifier.rb
index 3b493a51c..0e17e8446 100644
--- a/app/logical/user_verifier.rb
+++ b/app/logical/user_verifier.rb
@@ -33,11 +33,7 @@ class UserVerifier
end
def is_local_ip?
- if ip_address.ipv4?
- ip_address.loopback? || ip_address.link_local? || ip_address.private?
- elsif ip_address.ipv6?
- ip_address.loopback? || ip_address.link_local? || ip_address.unique_local?
- end
+ IpLookup.new(ip_address).is_local?
end
def is_logged_in?
diff --git a/app/models/ip_geolocation.rb b/app/models/ip_geolocation.rb
new file mode 100644
index 000000000..266ce25b0
--- /dev/null
+++ b/app/models/ip_geolocation.rb
@@ -0,0 +1,29 @@
+# An IpGeolocation contains metadata associated with an IP address, primarily geolocation data.
+
+class IpGeolocation < ApplicationRecord
+ has_many :user_sessions, foreign_key: :ip_addr, primary_key: :ip_addr
+
+ def self.visible(user)
+ if user.is_moderator?
+ all
+ else
+ none
+ end
+ end
+
+ def self.search(params)
+ q = search_attributes(params, :id, :created_at, :updated_at, :ip_addr, :network, :asn, :is_proxy, :latitude, :longitude, :organization, :time_zone, :continent, :country, :region, :city, :carrier)
+ q = q.apply_default_order(params)
+ q
+ end
+
+ def self.create_or_update!(ip_addr)
+ ip_lookup = IpLookup.new(ip_addr)
+ return nil if ip_lookup.is_local?
+ return nil if ip_lookup.ip_info.blank?
+
+ ip_geolocation = IpGeolocation.create_with(**ip_lookup.ip_info).create_or_find_by!(ip_addr: ip_addr)
+ ip_geolocation.update!(**ip_lookup.ip_info)
+ ip_geolocation
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index c48b5c620..5eb1fc0f0 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -102,6 +102,7 @@ class User < ApplicationRecord
has_many :bans, -> {order("bans.id desc")}
has_many :received_upgrades, class_name: "UserUpgrade", foreign_key: :recipient_id, dependent: :destroy
has_many :purchased_upgrades, class_name: "UserUpgrade", foreign_key: :purchaser_id, dependent: :destroy
+ has_many :user_events, dependent: :destroy
has_one :recent_ban, -> {order("bans.id desc")}, :class_name => "Ban"
has_one :api_key
diff --git a/app/models/user_event.rb b/app/models/user_event.rb
new file mode 100644
index 000000000..752a02398
--- /dev/null
+++ b/app/models/user_event.rb
@@ -0,0 +1,54 @@
+# A UserEvent is used to track important events related to a user's account,
+# such as signups, logins, password changes, etc. A UserEvent is associated
+# with a UserSession, which contains the IP and browser information associated
+# with the event.
+
+class UserEvent < ApplicationRecord
+ belongs_to :user
+ belongs_to :user_session
+
+ enum category: {
+ login: 0,
+ failed_login: 50,
+ logout: 100,
+ user_creation: 200,
+ user_deletion: 300,
+ password_reset: 400,
+ password_change: 500,
+ email_change: 600,
+ }
+
+ delegate :session_id, :ip_addr, :ip_geolocation, to: :user_session
+ delegate :country, :city, :is_proxy?, to: :ip_geolocation, allow_nil: true
+
+ def self.visible(user)
+ if user.is_moderator?
+ all
+ else
+ where(user: user)
+ end
+ end
+
+ def self.search(params)
+ q = search_attributes(params, :id, :created_at, :updated_at, :category, :user, :user_session)
+ q = q.apply_default_order(params)
+ q
+ end
+
+ concerning :ConstructorMethods do
+ class_methods do
+ # Build an event but don't save it yet. The caller is expected to update the user, which will save the event.
+ def build_from_request(user, category, request)
+ ip_addr = request.remote_ip
+ IpGeolocation.create_or_update!(ip_addr)
+ user_session = UserSession.new(session_id: request.session[:session_id], ip_addr: ip_addr, user_agent: request.user_agent)
+
+ user.user_events.build(user: user, category: category, user_session: user_session)
+ end
+
+ def create_from_request!(...)
+ build_from_request(...).save!
+ end
+ end
+ end
+end
diff --git a/app/models/user_session.rb b/app/models/user_session.rb
new file mode 100644
index 000000000..9ecaaaab7
--- /dev/null
+++ b/app/models/user_session.rb
@@ -0,0 +1,21 @@
+# A UserSession contains browser and IP metadata associated with a UserEvent. This
+# includes the user's session ID from their session cookie, their IP address,
+# and their browser user agent. This is used to track logins and other events.
+
+class UserSession < ApplicationRecord
+ belongs_to :ip_geolocation, foreign_key: :ip_addr, primary_key: :ip_addr, optional: true
+
+ def self.visible(user)
+ if user.is_moderator?
+ all
+ else
+ none
+ end
+ end
+
+ def self.search(params)
+ q = search_attributes(params, :id, :created_at, :updated_at, :session_id, :user_agent, :ip_addr, :ip_geolocation)
+ q = q.apply_default_order(params)
+ q
+ end
+end
diff --git a/app/policies/ip_geolocation_policy.rb b/app/policies/ip_geolocation_policy.rb
new file mode 100644
index 000000000..bd269f3ee
--- /dev/null
+++ b/app/policies/ip_geolocation_policy.rb
@@ -0,0 +1,5 @@
+class IpGeolocationPolicy < ApplicationPolicy
+ def index?
+ user.is_moderator?
+ end
+end
diff --git a/app/policies/user_event_policy.rb b/app/policies/user_event_policy.rb
new file mode 100644
index 000000000..ddc8b240e
--- /dev/null
+++ b/app/policies/user_event_policy.rb
@@ -0,0 +1,5 @@
+class UserEventPolicy < ApplicationPolicy
+ def index?
+ user.is_moderator?
+ end
+end
diff --git a/app/policies/user_session_policy.rb b/app/policies/user_session_policy.rb
new file mode 100644
index 000000000..cb84fe503
--- /dev/null
+++ b/app/policies/user_session_policy.rb
@@ -0,0 +1,5 @@
+class UserSessionPolicy < ApplicationPolicy
+ def index?
+ user.is_moderator?
+ end
+end
diff --git a/app/views/ip_geolocations/index.html.erb b/app/views/ip_geolocations/index.html.erb
new file mode 100644
index 000000000..2c99283a7
--- /dev/null
+++ b/app/views/ip_geolocations/index.html.erb
@@ -0,0 +1,43 @@
+
+
+ <%= search_form_for(ip_geolocations_path) do |f| %>
+ <%= f.input :ip_addr, label: "IP Address", input_html: { value: params[:search][:ip_addr] } %>
+ <%= f.input :asn, input_html: { value: params[:search][:asn] } %>
+ <%= f.input :continent, input_html: { value: params[:search][:continent] } %>
+ <%= f.input :country, as: :string, input_html: { value: params[:search][:country] } %>
+ <%= f.input :region, input_html: { value: params[:search][:region] } %>
+ <%= f.input :city, input_html: { value: params[:search][:city] } %>
+ <%= f.input :is_proxy, label: "Proxy?", as: :select, include_blank: true, selected: params[:search][:is_proxy] %>
+ <%= f.submit "Search" %>
+ <% end %>
+
+ <%= table_for @ip_geolocations, class: "striped autofit" do |t| %>
+ <% t.column "IP Address" do |ip_geolocation| %>
+ <%= ip_geolocation.ip_addr %>
+ <% end %>
+
+ <% t.column :network %>
+ <% t.column :asn %>
+ <% t.column :organization %>
+ <% t.column :is_proxy %>
+ <% t.column :continent %>
+ <% t.column :country %>
+ <% t.column :region %>
+ <% t.column :city %>
+
+ <% t.column "Updated" do |ip_geolocation| %>
+ <%= time_ago_in_words_tagged(ip_geolocation.updated_at) %>
+ <% end %>
+
+ <% t.column "Created" do |ip_geolocation| %>
+ <%= time_ago_in_words_tagged(ip_geolocation.created_at) %>
+ <% end %>
+
+ <% t.column column: "control" do |ip_geolocation| %>
+ <%= external_link_to "https://ipinfo.io/#{ip_geolocation.ip_addr}", "IP Info" %>
+ <% end %>
+ <% end %>
+
+ <%= numbered_paginator(@ip_geolocations) %>
+
+
diff --git a/app/views/user_events/index.html.erb b/app/views/user_events/index.html.erb
new file mode 100644
index 000000000..c0ac5b22c
--- /dev/null
+++ b/app/views/user_events/index.html.erb
@@ -0,0 +1,66 @@
+
+
+ <%= search_form_for(user_events_path) do |f| %>
+ <%= f.input :user_name, label: "User", input_html: { value: params[:search][:user_name], "data-autocomplete": "user" } %>
+ <%= f.simple_fields_for :user_session do |f1| %>
+ <%= f1.input :ip_addr, label: "IP Address", input_html: { value: params.dig(:search, :user_session, :ip_addr) } %>
+ <%= f1.input :session_id, label: "Session", input_html: { value: params.dig(:search, :user_session, :session_id) } %>
+ <%= f1.simple_fields_for :ip_geolocation do |f2| %>
+ <%= f2.input :country, as: :string, input_html: { value: params.dig(:search, :user_session, :ip_geolocation, :country) } %>
+ <%= f2.input :city, input_html: { value: params.dig(:search, :user_session, :ip_geolocation, :city) } %>
+ <%= f2.input :is_proxy, label: "Proxy?", as: :select, include_blank: true, selected: params.dig(:search, :user_session, :ip_geolocation, :is_proxy) %>
+ <% end %>
+ <% end %>
+ <%= f.input :category, collection: UserEvent.categories.transform_keys(&:humanize), include_blank: true, selected: params[:search][:category] %>
+ <%= f.submit "Search" %>
+ <% end %>
+
+ <%= table_for @user_events, class: "striped autofit" do |t| %>
+ <% t.column "User" do |user_event| %>
+ <%= link_to_user user_event.user %>
+ <%= link_to "»", user_events_path(search: { user_name: user_event.user.name }) %>
+ <% end %>
+
+ <% t.column :category do |user_event| %>
+ <%= link_to user_event.category.humanize, user_events_path(search: { category: UserEvent.categories[user_event.category] }) %>
+ <% end %>
+
+ <% t.column "Session" do |user_event| %>
+ <%= link_to user_event.session_id, user_events_path(search: { user_session: { session_id: user_event.session_id }}) %>
+ <% end %>
+
+ <% t.column "IP Address" do |user_event| %>
+ <%= link_to user_event.ip_addr, user_events_path(search: { user_session: { ip_addr: user_event.ip_addr }}) %>
+ <%= link_to "»", ip_geolocations_path(search: { ip_addr: user_event.ip_addr }) %>
+ <% end %>
+
+ <% t.column "Country" do |user_event| %>
+ <% if user_event.country.present? %>
+ <%= link_to user_event.country, user_events_path(search: { user_session: { ip_geolocation: { country: user_event.country }}}) %>
+ <% end %>
+ <% end %>
+
+ <% t.column "City" do |user_event| %>
+ <% if user_event.city.present? %>
+ <%= link_to user_event.city, user_events_path(search: { user_session: { ip_geolocation: { city: user_event.city }}}) %>
+ <% end %>
+ <% end %>
+
+ <% t.column "Proxy?" do |user_event| %>
+ <% if user_event.is_proxy?.present? %>
+ <%= link_to user_event.is_proxy? ? "Yes" : "No", user_events_path(search: { user_session: { ip_geolocation: { is_proxy: user_event.is_proxy? }}}) %>
+ <% end %>
+ <% end %>
+
+ <% t.column "Created" do |user_event| %>
+ <%= time_ago_in_words_tagged(user_event.created_at) %>
+ <% end %>
+
+ <% t.column column: "control" do |user_event| %>
+ <%= external_link_to "https://ipinfo.io/#{user_event.ip_addr}", "IP Info" %>
+ <% end %>
+ <% end %>
+
+ <%= numbered_paginator(@user_events) %>
+
+
diff --git a/app/views/user_sessions/index.html.erb b/app/views/user_sessions/index.html.erb
new file mode 100644
index 000000000..f8b3c5af9
--- /dev/null
+++ b/app/views/user_sessions/index.html.erb
@@ -0,0 +1,31 @@
+
+
+ <%= search_form_for(user_sessions_path) do |f| %>
+ <%= f.input :session_id, label: "Session", input_html: { value: params[:search][:session_id] } %>
+ <%= f.input :ip_addr, label: "IP Address", input_html: { value: params[:search][:ip_addr] } %>
+ <%= f.submit "Search" %>
+ <% end %>
+
+ <%= table_for @user_sessions, class: "striped autofit" do |t| %>
+ <% t.column "Session" do |user_session| %>
+ <%= link_to user_session.session_id, user_sessions_path(search: { session_id: user_session.session_id }) %>
+ <%= link_to "»", user_events_path(search: { user_session: { session_id: user_session.session_id }}) %>
+ <% end %>
+
+ <% t.column "IP Address" do |user_session| %>
+ <%= link_to user_session.ip_addr, user_sessions_path(search: { ip_addr: user_session.ip_addr }) %>
+ <%= link_to "»", user_events_path(search: { user_session: { ip_addr: user_session.ip_addr }}) %>
+ <% end %>
+
+ <% t.column "Browser" do |user_session| %>
+ <%= user_session.user_agent %>
+ <% end %>
+
+ <% t.column "Created" do |user_session| %>
+ <%= time_ago_in_words_tagged(user_session.created_at) %>
+ <% end %>
+ <% end %>
+
+ <%= numbered_paginator(@user_sessions) %>
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 7b32daaf4..5457e8719 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -140,6 +140,7 @@ Rails.application.routes.draw do
resources :forum_topic_visits, only: [:index]
resources :ip_bans, only: [:index, :new, :create, :update]
resources :ip_addresses, only: [:show, :index], id: /.+?(?=\.json|\.xml|\.html)|.+/
+ resources :ip_geolocations, only: [:index]
resource :iqdb_queries, :only => [:show, :create] do
collection do
get :preview
@@ -260,7 +261,9 @@ Rails.application.routes.draw do
get :payment, on: :member
put :refund, on: :member
end
+ resources :user_events, only: [:index]
resources :user_feedbacks, except: [:destroy]
+ resources :user_sessions, only: [:index]
resources :user_name_change_requests, only: [:new, :create, :show, :index]
resources :webhooks do
post :receive, on: :collection
diff --git a/db/migrate/20210108030722_create_ip_geolocations.rb b/db/migrate/20210108030722_create_ip_geolocations.rb
new file mode 100644
index 000000000..7a9da0176
--- /dev/null
+++ b/db/migrate/20210108030722_create_ip_geolocations.rb
@@ -0,0 +1,21 @@
+class CreateIpGeolocations < ActiveRecord::Migration[6.1]
+ def change
+ create_table :ip_geolocations do |t|
+ t.timestamps null: false, index: true
+
+ t.inet :ip_addr, null: false, index: { unique: true }
+ t.inet :network, index: true
+ t.integer :asn, index: true
+ t.boolean :is_proxy, null: false, index: true
+ t.float :latitude, index: true
+ t.float :longitude, index: true
+ t.string :organization, index: true
+ t.string :time_zone, index: true
+ t.string :continent, index: true
+ t.string :country, index: true
+ t.string :region, index: true
+ t.string :city, index: true
+ t.string :carrier, index: true
+ end
+ end
+end
diff --git a/db/migrate/20210108030723_create_user_sessions.rb b/db/migrate/20210108030723_create_user_sessions.rb
new file mode 100644
index 000000000..56210cc6e
--- /dev/null
+++ b/db/migrate/20210108030723_create_user_sessions.rb
@@ -0,0 +1,10 @@
+class CreateUserSessions < ActiveRecord::Migration[6.1]
+ def change
+ create_table :user_sessions do |t|
+ t.timestamps null: false, index: true
+ t.inet :ip_addr, null: false, index: true
+ t.string :session_id, null: false, index: true
+ t.string :user_agent
+ end
+ end
+end
diff --git a/db/migrate/20210108030724_create_user_events.rb b/db/migrate/20210108030724_create_user_events.rb
new file mode 100644
index 000000000..dec3a7bdf
--- /dev/null
+++ b/db/migrate/20210108030724_create_user_events.rb
@@ -0,0 +1,10 @@
+class CreateUserEvents < ActiveRecord::Migration[6.1]
+ def change
+ create_table :user_events do |t|
+ t.timestamps null: false, index: true
+ t.references :user, null: false, index: true
+ t.references :user_session, null: false, index: true
+ t.integer :category, null: false, index: true
+ end
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 2d978ad70..9508f40a1 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -2364,6 +2364,49 @@ CREATE SEQUENCE public.ip_bans_id_seq
ALTER SEQUENCE public.ip_bans_id_seq OWNED BY public.ip_bans.id;
+--
+-- Name: ip_geolocations; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.ip_geolocations (
+ id bigint NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL,
+ ip_addr inet NOT NULL,
+ network inet,
+ asn integer,
+ is_proxy boolean NOT NULL,
+ latitude double precision,
+ longitude double precision,
+ organization character varying,
+ time_zone character varying,
+ continent character varying,
+ country character varying,
+ region character varying,
+ city character varying,
+ carrier character varying
+);
+
+
+--
+-- Name: ip_geolocations_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.ip_geolocations_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: ip_geolocations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.ip_geolocations_id_seq OWNED BY public.ip_geolocations.id;
+
+
--
-- Name: mod_actions; Type: TABLE; Schema: public; Owner: -
--
@@ -3038,6 +3081,39 @@ CREATE SEQUENCE public.uploads_id_seq
ALTER SEQUENCE public.uploads_id_seq OWNED BY public.uploads.id;
+--
+-- Name: user_events; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.user_events (
+ id bigint NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL,
+ user_id bigint NOT NULL,
+ user_session_id bigint NOT NULL,
+ category integer NOT NULL
+);
+
+
+--
+-- Name: user_events_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.user_events_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: user_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.user_events_id_seq OWNED BY public.user_events.id;
+
+
--
-- Name: user_feedback; Type: TABLE; Schema: public; Owner: -
--
@@ -3106,6 +3182,39 @@ CREATE SEQUENCE public.user_name_change_requests_id_seq
ALTER SEQUENCE public.user_name_change_requests_id_seq OWNED BY public.user_name_change_requests.id;
+--
+-- Name: user_sessions; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.user_sessions (
+ id bigint NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL,
+ ip_addr inet NOT NULL,
+ session_id character varying NOT NULL,
+ user_agent character varying
+);
+
+
+--
+-- Name: user_sessions_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.user_sessions_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: user_sessions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.user_sessions_id_seq OWNED BY public.user_sessions.id;
+
+
--
-- Name: user_upgrades; Type: TABLE; Schema: public; Owner: -
--
@@ -4062,6 +4171,13 @@ ALTER TABLE ONLY public.forum_topics ALTER COLUMN id SET DEFAULT nextval('public
ALTER TABLE ONLY public.ip_bans ALTER COLUMN id SET DEFAULT nextval('public.ip_bans_id_seq'::regclass);
+--
+-- Name: ip_geolocations id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.ip_geolocations ALTER COLUMN id SET DEFAULT nextval('public.ip_geolocations_id_seq'::regclass);
+
+
--
-- Name: mod_actions id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -4195,6 +4311,13 @@ ALTER TABLE ONLY public.tags ALTER COLUMN id SET DEFAULT nextval('public.tags_id
ALTER TABLE ONLY public.uploads ALTER COLUMN id SET DEFAULT nextval('public.uploads_id_seq'::regclass);
+--
+-- Name: user_events id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_events ALTER COLUMN id SET DEFAULT nextval('public.user_events_id_seq'::regclass);
+
+
--
-- Name: user_feedback id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -4209,6 +4332,13 @@ ALTER TABLE ONLY public.user_feedback ALTER COLUMN id SET DEFAULT nextval('publi
ALTER TABLE ONLY public.user_name_change_requests ALTER COLUMN id SET DEFAULT nextval('public.user_name_change_requests_id_seq'::regclass);
+--
+-- Name: user_sessions id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_sessions ALTER COLUMN id SET DEFAULT nextval('public.user_sessions_id_seq'::regclass);
+
+
--
-- Name: user_upgrades id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -4414,6 +4544,13 @@ ALTER TABLE ONLY public.ip_bans
--
+-- Name: ip_geolocations ip_geolocations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.ip_geolocations
+ ADD CONSTRAINT ip_geolocations_pkey PRIMARY KEY (id);
+
+
-- Name: mod_actions mod_actions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -4565,6 +4702,14 @@ ALTER TABLE ONLY public.uploads
ADD CONSTRAINT uploads_pkey PRIMARY KEY (id);
+--
+-- Name: user_events user_events_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_events
+ ADD CONSTRAINT user_events_pkey PRIMARY KEY (id);
+
+
--
-- Name: user_feedback user_feedback_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -4581,6 +4726,14 @@ ALTER TABLE ONLY public.user_name_change_requests
ADD CONSTRAINT user_name_change_requests_pkey PRIMARY KEY (id);
+--
+-- Name: user_sessions user_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.user_sessions
+ ADD CONSTRAINT user_sessions_pkey PRIMARY KEY (id);
+
+
--
-- Name: user_upgrades user_upgrades_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -6531,6 +6684,110 @@ CREATE INDEX index_ip_bans_on_is_deleted ON public.ip_bans USING btree (is_delet
--
+-- Name: index_ip_geolocations_on_asn; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_asn ON public.ip_geolocations USING btree (asn);
+
+
+--
+-- Name: index_ip_geolocations_on_carrier; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_carrier ON public.ip_geolocations USING btree (carrier);
+
+
+--
+-- Name: index_ip_geolocations_on_city; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_city ON public.ip_geolocations USING btree (city);
+
+
+--
+-- Name: index_ip_geolocations_on_continent; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_continent ON public.ip_geolocations USING btree (continent);
+
+
+--
+-- Name: index_ip_geolocations_on_country; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_country ON public.ip_geolocations USING btree (country);
+
+
+--
+-- Name: index_ip_geolocations_on_created_at; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_created_at ON public.ip_geolocations USING btree (created_at);
+
+
+--
+-- Name: index_ip_geolocations_on_ip_addr; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE UNIQUE INDEX index_ip_geolocations_on_ip_addr ON public.ip_geolocations USING btree (ip_addr);
+
+
+--
+-- Name: index_ip_geolocations_on_is_proxy; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_is_proxy ON public.ip_geolocations USING btree (is_proxy);
+
+
+--
+-- Name: index_ip_geolocations_on_latitude; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_latitude ON public.ip_geolocations USING btree (latitude);
+
+
+--
+-- Name: index_ip_geolocations_on_longitude; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_longitude ON public.ip_geolocations USING btree (longitude);
+
+
+--
+-- Name: index_ip_geolocations_on_network; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_network ON public.ip_geolocations USING btree (network);
+
+
+--
+-- Name: index_ip_geolocations_on_organization; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_organization ON public.ip_geolocations USING btree (organization);
+
+
+--
+-- Name: index_ip_geolocations_on_region; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_region ON public.ip_geolocations USING btree (region);
+
+
+--
+-- Name: index_ip_geolocations_on_time_zone; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_time_zone ON public.ip_geolocations USING btree (time_zone);
+
+
+--
+-- Name: index_ip_geolocations_on_updated_at; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_ip_geolocations_on_updated_at ON public.ip_geolocations USING btree (updated_at);
+
+
-- Name: index_mod_actions_on_created_at; Type: INDEX; Schema: public; Owner: -
--
@@ -7062,6 +7319,41 @@ CREATE INDEX index_uploads_on_uploader_id ON public.uploads USING btree (uploade
CREATE INDEX index_uploads_on_uploader_ip_addr ON public.uploads USING btree (uploader_ip_addr);
+--
+-- Name: index_user_events_on_category; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_events_on_category ON public.user_events USING btree (category);
+
+
+--
+-- Name: index_user_events_on_created_at; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_events_on_created_at ON public.user_events USING btree (created_at);
+
+
+--
+-- Name: index_user_events_on_updated_at; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_events_on_updated_at ON public.user_events USING btree (updated_at);
+
+
+--
+-- Name: index_user_events_on_user_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_events_on_user_id ON public.user_events USING btree (user_id);
+
+
+--
+-- Name: index_user_events_on_user_session_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_events_on_user_session_id ON public.user_events USING btree (user_session_id);
+
+
--
-- Name: index_user_feedback_on_created_at; Type: INDEX; Schema: public; Owner: -
--
@@ -7097,6 +7389,34 @@ CREATE INDEX index_user_name_change_requests_on_original_name ON public.user_nam
CREATE INDEX index_user_name_change_requests_on_user_id ON public.user_name_change_requests USING btree (user_id);
+--
+-- Name: index_user_sessions_on_created_at; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_sessions_on_created_at ON public.user_sessions USING btree (created_at);
+
+
+--
+-- Name: index_user_sessions_on_ip_addr; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_sessions_on_ip_addr ON public.user_sessions USING btree (ip_addr);
+
+
+--
+-- Name: index_user_sessions_on_session_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_sessions_on_session_id ON public.user_sessions USING btree (session_id);
+
+
+--
+-- Name: index_user_sessions_on_updated_at; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_user_sessions_on_updated_at ON public.user_sessions USING btree (updated_at);
+
+
--
-- Name: index_user_upgrades_on_purchaser_id; Type: INDEX; Schema: public; Owner: -
--
@@ -7526,6 +7846,9 @@ INSERT INTO "schema_migrations" (version) VALUES
('20201213052805'),
('20201219201007'),
('20201224101208'),
-('20210106212805');
+('20210106212805'),
+('20210108030722'),
+('20210108030723'),
+('20210108030724');
diff --git a/test/factories/user_event.rb b/test/factories/user_event.rb
new file mode 100644
index 000000000..37957ea71
--- /dev/null
+++ b/test/factories/user_event.rb
@@ -0,0 +1,5 @@
+FactoryBot.define do
+ factory(:user_event) do
+ user
+ end
+end
diff --git a/test/functional/emails_controller_test.rb b/test/functional/emails_controller_test.rb
index ce6c5ae5c..ec6ad52d0 100644
--- a/test/functional/emails_controller_test.rb
+++ b/test/functional/emails_controller_test.rb
@@ -89,6 +89,7 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest
assert_equal("abc@ogres.net", @user.reload.email_address.address)
assert_equal(false, @user.email_address.is_verified)
assert_enqueued_email_with UserMailer, :email_change_confirmation, args: [@user], queue: "default"
+ assert_equal(true, @user.user_events.email_change.exists?)
end
should "create a new address" do
@@ -102,6 +103,7 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest
assert_equal("abc@ogres.net", @user.reload.email_address.address)
assert_equal(false, @user.reload.email_address.is_verified)
assert_enqueued_email_with UserMailer, :email_change_confirmation, args: [@user], queue: "default"
+ assert_equal(true, @user.user_events.email_change.exists?)
end
end
@@ -112,6 +114,7 @@ class EmailsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
assert_equal("bob@ogres.net", @user.reload.email_address.address)
assert_no_emails
+ assert_equal(false, @user.user_events.email_change.exists?)
end
end
end
diff --git a/test/functional/maintenance/user/deletions_controller_test.rb b/test/functional/maintenance/user/deletions_controller_test.rb
index bf4ce204e..07fab37c3 100644
--- a/test/functional/maintenance/user/deletions_controller_test.rb
+++ b/test/functional/maintenance/user/deletions_controller_test.rb
@@ -18,18 +18,22 @@ module Maintenance
context "#destroy" do
should "delete the user when given the correct password" do
delete_auth maintenance_user_deletion_path, @user, params: { user: { password: "password" }}
+
assert_redirected_to posts_path
assert_equal(true, @user.reload.is_deleted?)
assert_equal("Your account has been deactivated", flash[:notice])
assert_nil(session[:user_id])
+ assert_equal(true, @user.user_events.user_deletion.exists?)
end
should "not delete the user when given an incorrect password" do
delete_auth maintenance_user_deletion_path, @user, params: { user: { password: "hunter2" }}
+
assert_redirected_to maintenance_user_deletion_path
assert_equal(false, @user.reload.is_deleted?)
assert_equal("Password is incorrect", flash[:notice])
assert_equal(@user.id, session[:user_id])
+ assert_equal(false, @user.user_events.user_deletion.exists?)
end
end
end
diff --git a/test/functional/password_resets_controller_test.rb b/test/functional/password_resets_controller_test.rb
index 16c6c8588..c4ad7b123 100644
--- a/test/functional/password_resets_controller_test.rb
+++ b/test/functional/password_resets_controller_test.rb
@@ -16,6 +16,7 @@ class PasswordResetsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to new_session_path
assert_enqueued_email_with UserMailer, :password_reset, args: [@user], queue: "default"
+ assert_equal(true, @user.user_events.password_reset.exists?)
end
should "should fail if the user doesn't have a verified email address" do
diff --git a/test/functional/passwords_controller_test.rb b/test/functional/passwords_controller_test.rb
index f865957f2..e67822180 100644
--- a/test/functional/passwords_controller_test.rb
+++ b/test/functional/passwords_controller_test.rb
@@ -20,6 +20,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to @user
assert_equal(false, @user.reload.authenticate_password("12345"))
assert_equal(@user, @user.authenticate_password("abcde"))
+ assert_equal(true, @user.user_events.password_change.exists?)
end
should "update the password when given a valid login key" do
@@ -29,6 +30,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to @user
assert_equal(false, @user.reload.authenticate_password("12345"))
assert_equal(@user, @user.authenticate_password("abcde"))
+ assert_equal(true, @user.user_events.password_change.exists?)
end
should "allow the site owner to change the password of other users" do
@@ -55,6 +57,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
assert_equal(@user, @user.reload.authenticate_password("12345"))
assert_equal(false, @user.authenticate_password("abcde"))
+ assert_equal(false, @user.user_events.password_change.exists?)
end
should "not update the password when password confirmation fails for the new password" do
diff --git a/test/functional/sessions_controller_test.rb b/test/functional/sessions_controller_test.rb
index 1eff49170..7e233b924 100644
--- a/test/functional/sessions_controller_test.rb
+++ b/test/functional/sessions_controller_test.rb
@@ -20,11 +20,20 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to posts_path
assert_equal(@user.id, session[:user_id])
assert_not_nil(@user.reload.last_ip_addr)
+ assert_equal(true, @user.user_events.login.exists?)
end
should "not log the user in when given an incorrect password" do
post session_path, params: { name: @user.name, password: "wrong"}
+ assert_response 401
+ assert_nil(nil, session[:user_id])
+ assert_equal(true, @user.user_events.failed_login.exists?)
+ end
+
+ should "not log the user in when given an incorrect username" do
+ post session_path, params: { name: "dne", password: "password" }
+
assert_response 401
assert_nil(nil, session[:user_id])
end
@@ -66,11 +75,18 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
end
context "destroy action" do
- should "clear the session" do
+ setup do
delete_auth session_path, @user
+ end
+
+ should "clear the session" do
assert_redirected_to posts_path
assert_nil(session[:user_id])
end
+
+ should "generate a logout event" do
+ assert_equal(true, @user.user_events.logout.exists?)
+ end
end
context "sign_out action" do
diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb
index 0b807e4c3..e71d27c12 100644
--- a/test/functional/users_controller_test.rb
+++ b/test/functional/users_controller_test.rb
@@ -260,6 +260,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(User.last, User.last.authenticate_password("xxxxx1"))
assert_nil(User.last.email_address)
assert_no_enqueued_emails
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
should "create a user with a valid email" do
@@ -270,6 +271,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(User.last, User.last.authenticate_password("xxxxx1"))
assert_equal("webmaster@danbooru.donmai.us", User.last.email_address.address)
assert_enqueued_email_with UserMailer, :welcome_user, args: [User.last], queue: "default"
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
should "not create a user with an invalid email" do
@@ -307,6 +309,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(true, User.last.is_member?)
assert_equal(false, User.last.is_restricted?)
assert_equal(false, User.last.requires_verification)
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
should "mark accounts created by already logged in users as restricted" do
@@ -318,6 +321,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(false, User.last.is_member?)
assert_equal(true, User.last.is_restricted?)
assert_equal(true, User.last.requires_verification)
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
should "mark users signing up from proxies as restricted" do
@@ -330,6 +334,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(false, User.last.is_member?)
assert_equal(true, User.last.is_restricted?)
assert_equal(true, User.last.requires_verification)
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
should "mark users signing up from a partial banned IP as restricted" do
@@ -344,6 +349,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(true, User.last.requires_verification)
assert_equal(1, @ip_ban.reload.hit_count)
assert(@ip_ban.last_hit_at > 1.minute.ago)
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
should "not mark users signing up from non-proxies as restricted" do
@@ -356,6 +362,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(true, User.last.is_member?)
assert_equal(false, User.last.is_restricted?)
assert_equal(false, User.last.requires_verification)
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
should "mark accounts registered from an IPv4 address recently used for another account as restricted" do
@@ -368,6 +375,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(false, User.last.is_member?)
assert_equal(true, User.last.is_restricted?)
assert_equal(true, User.last.requires_verification)
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
should "not mark users signing up from localhost as restricted" do
@@ -379,6 +387,7 @@ class UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal(true, User.last.is_member?)
assert_equal(false, User.last.is_restricted?)
assert_equal(false, User.last.requires_verification)
+ assert_equal(true, User.last.user_events.user_creation.exists?)
end
end
end
diff --git a/test/unit/ip_geolocation_test.rb b/test/unit/ip_geolocation_test.rb
new file mode 100644
index 000000000..b89a44e3a
--- /dev/null
+++ b/test/unit/ip_geolocation_test.rb
@@ -0,0 +1,72 @@
+require 'test_helper'
+
+class IpGeolocationTest < ActiveSupport::TestCase
+ context "IpGeolocation: " do
+ context "the create_or_update! method" do
+ should "create a new record if the IP record doesn't already exist" do
+ assert_difference("IpGeolocation.count", 1) do
+ IpGeolocation.create_or_update!("1.1.1.1")
+ end
+ end
+
+ should "update an existing record if the IP record already exists" do
+ @ip1 = IpGeolocation.create_or_update!("1.1.1.1")
+ @ip1.update(asn: -1)
+ @ip2 = IpGeolocation.create_or_update!("1.1.1.1")
+
+ assert_equal(1, IpGeolocation.count)
+ assert_equal(@ip1.id, @ip2.id)
+ assert_equal(13335, @ip1.reload.asn)
+ end
+
+ should "return nothing for an invalid IP" do
+ assert_nil(IpGeolocation.create_or_update!("0.0.0.0"))
+ end
+
+ should "return nothing for a local IP" do
+ assert_nil(IpGeolocation.create_or_update!("127.0.0.1"))
+ assert_nil(IpGeolocation.create_or_update!("10.0.0.1"))
+ assert_nil(IpGeolocation.create_or_update!("fe80::1"))
+ assert_nil(IpGeolocation.create_or_update!("::1"))
+ end
+
+ should "work for a residential IP" do
+ @ip = IpGeolocation.create_or_update!("2a01:0e35:2f22:e3d0::1")
+
+ assert_equal(28, @ip.network.prefix)
+ assert_equal(false, @ip.is_proxy?)
+ assert_equal(48.75919, @ip.latitude)
+ assert_equal(2.16969, @ip.longitude)
+ assert_equal("Free SAS", @ip.organization)
+ assert_equal("Europe/Paris", @ip.time_zone)
+ assert_equal("EU", @ip.continent)
+ assert_equal("FR", @ip.country)
+ assert_equal("FR-IDF", @ip.region)
+ assert_equal("Jouy-en-Josas", @ip.city)
+ assert_nil(@ip.carrier)
+ end
+
+ should "work for a mobile IP" do
+ @ip = IpGeolocation.create_or_update!("37.173.153.166")
+ assert_equal("Free Mobile", @ip.carrier)
+ end
+
+ should "work for a proxy IP" do
+ @ip = IpGeolocation.create_or_update!("31.214.184.59")
+ assert_equal("Soluciones Corporativas IP SL", @ip.organization)
+ assert_equal(true, @ip.is_proxy?)
+ end
+
+ should "work for a cloud hosting IP" do
+ @ip = IpGeolocation.create_or_update!("157.230.244.215")
+ assert_equal("DigitalOcean LLC", @ip.organization)
+ assert_equal(true, @ip.is_proxy?)
+ end
+
+ should "work for a bogon IP" do
+ @ip = IpGeolocation.create_or_update!("103.10.192.0")
+ assert_equal(true, @ip.is_proxy?)
+ end
+ end
+ end
+end
diff --git a/test/unit/user_deletion_test.rb b/test/unit/user_deletion_test.rb
index a1d8b15df..b89c45306 100644
--- a/test/unit/user_deletion_test.rb
+++ b/test/unit/user_deletion_test.rb
@@ -1,11 +1,18 @@
require 'test_helper'
class UserDeletionTest < ActiveSupport::TestCase
+ setup do
+ @request = mock
+ @request.stubs(:remote_ip).returns("1.1.1.1")
+ @request.stubs(:user_agent).returns("Firefox")
+ @request.stubs(:session).returns(session_id: "1234")
+ end
+
context "an invalid user deletion" do
context "for an invalid password" do
should "fail" do
@user = create(:user)
- @deletion = UserDeletion.new(@user, "wrongpassword")
+ @deletion = UserDeletion.new(@user, "wrongpassword", @request)
@deletion.delete!
assert_includes(@deletion.errors[:base], "Password is incorrect")
end
@@ -14,7 +21,7 @@ class UserDeletionTest < ActiveSupport::TestCase
context "for an admin" do
should "fail" do
@user = create(:admin_user)
- @deletion = UserDeletion.new(@user, "password")
+ @deletion = UserDeletion.new(@user, "password", @request)
@deletion.delete!
assert_includes(@deletion.errors[:base], "Admins cannot delete their account")
end
@@ -24,7 +31,7 @@ class UserDeletionTest < ActiveSupport::TestCase
context "a valid user deletion" do
setup do
@user = create(:user, name: "foo", email_address: build(:email_address))
- @deletion = UserDeletion.new(@user, "password")
+ @deletion = UserDeletion.new(@user, "password", @request)
end
should "blank out the email" do