diff --git a/Gemfile b/Gemfile
index 3d20436eabe..dd079ee8294 100644
--- a/Gemfile
+++ b/Gemfile
@@ -194,3 +194,4 @@ end
gem 'webpush', require: false
gem 'colored2', require: false
+gem 'maxminddb'
diff --git a/Gemfile.lock b/Gemfile.lock
index e4102ed8882..bdf24bd5eff 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -191,6 +191,7 @@ GEM
lru_redux (1.1.0)
mail (2.7.1.rc1)
mini_mime (>= 0.1.1)
+ maxminddb (0.1.21)
memory_profiler (0.9.12)
message_bus (2.1.5)
rack (>= 1.1.3)
@@ -488,6 +489,7 @@ DEPENDENCIES
logster
lru_redux
mail (= 2.7.1.rc1)
+ maxminddb
memory_profiler
message_bus
mini_mime
diff --git a/app/assets/javascripts/discourse/components/auth-token-dropdown.es6 b/app/assets/javascripts/discourse/components/auth-token-dropdown.es6
new file mode 100644
index 00000000000..ecb72e3e583
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/auth-token-dropdown.es6
@@ -0,0 +1,40 @@
+import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box";
+
+export default DropdownSelectBoxComponent.extend({
+ classNames: ["auth-token-dropdown"],
+ headerIcon: "wrench",
+ allowInitialValueMutation: false,
+ showFullTitle: false,
+
+ computeContent() {
+ const content = [
+ {
+ id: "notYou",
+ icon: "user-times",
+ name: I18n.t("user.auth_tokens.not_you"),
+ description: ""
+ },
+ {
+ id: "logOut",
+ icon: "sign-out",
+ name: I18n.t("user.log_out"),
+ description: ""
+ }
+ ];
+
+ return content;
+ },
+
+ actions: {
+ onSelect(id) {
+ switch (id) {
+ case "notYou":
+ this.sendAction("showToken", this.get("token"));
+ break;
+ case "logOut":
+ this.sendAction("revokeAuthToken", this.get("token"));
+ break;
+ }
+ }
+ }
+});
diff --git a/app/assets/javascripts/discourse/controllers/auth-token.js.es6 b/app/assets/javascripts/discourse/controllers/auth-token.js.es6
new file mode 100644
index 00000000000..88ca16b4065
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/auth-token.js.es6
@@ -0,0 +1,41 @@
+import ModalFunctionality from "discourse/mixins/modal-functionality";
+import { ajax } from "discourse/lib/ajax";
+import { userPath } from "discourse/lib/url";
+
+export default Ember.Controller.extend(ModalFunctionality, {
+ expanded: false,
+
+ onShow() {
+ ajax(
+ userPath(`${this.get("currentUser.username_lower")}/activity.json`)
+ ).then(posts => {
+ if (posts.length > 0) {
+ this.set("latest_post", posts[0]);
+ }
+ });
+ },
+
+ actions: {
+ toggleExpanded() {
+ this.set("expanded", !this.get("expanded"));
+ },
+
+ highlightSecure() {
+ this.send("closeModal");
+
+ Ember.run.next(() => {
+ const $prefPasswordDiv = $(".pref-password");
+
+ $prefPasswordDiv.addClass("highlighted");
+ $prefPasswordDiv.on("animationend", () =>
+ $prefPasswordDiv.removeClass("highlighted")
+ );
+
+ window.scrollTo({
+ top: $prefPasswordDiv.offset().top,
+ behavior: "smooth"
+ });
+ });
+ }
+ }
+});
diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6
index b91f0e66ffa..ca3fe8e7eee 100644
--- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6
+++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6
@@ -9,6 +9,9 @@ import { findAll } from "discourse/models/login-method";
import { ajax } from "discourse/lib/ajax";
import { userPath } from "discourse/lib/url";
+// Number of tokens shown by default.
+const DEFAULT_AUTH_TOKENS_COUNT = 2;
+
export default Ember.Controller.extend(
CanCheckEmails,
PreferencesTabController,
@@ -23,6 +26,8 @@ export default Ember.Controller.extend(
passwordProgress: null,
+ showAllAuthTokens: false,
+
cannotDeleteAccount: Em.computed.not("currentUser.can_delete_account"),
deleteDisabled: Em.computed.or(
"model.isSaving",
@@ -99,6 +104,22 @@ export default Ember.Controller.extend(
);
},
+ @computed("showAllAuthTokens", "model.user_auth_tokens")
+ authTokens(showAllAuthTokens, tokens) {
+ tokens.sort(
+ (a, b) => (a.is_active ? -1 : b.is_active ? 1 : a.seen_at < b.seen_at)
+ );
+
+ return showAllAuthTokens
+ ? tokens
+ : tokens.slice(0, DEFAULT_AUTH_TOKENS_COUNT);
+ },
+
+ @computed("model.user_auth_tokens")
+ canShowAllAuthTokens(tokens) {
+ return tokens.length > DEFAULT_AUTH_TOKENS_COUNT;
+ },
+
actions: {
save() {
this.set("saved", false);
@@ -200,19 +221,26 @@ export default Ember.Controller.extend(
});
},
- toggleToken(token) {
- Ember.set(token, "visible", !token.visible);
+ toggleShowAllAuthTokens() {
+ this.set("showAllAuthTokens", !this.get("showAllAuthTokens"));
},
- revokeAuthToken() {
+ revokeAuthToken(token) {
ajax(
userPath(
`${this.get("model.username_lower")}/preferences/revoke-auth-token`
),
- { type: "POST" }
+ {
+ type: "POST",
+ data: token ? { token_id: token.id } : {}
+ }
);
},
+ showToken(token) {
+ showModal("auth-token", { model: token });
+ },
+
connectAccount(method) {
method.doLogin();
}
diff --git a/app/assets/javascripts/discourse/templates/modal/auth-token.hbs b/app/assets/javascripts/discourse/templates/modal/auth-token.hbs
new file mode 100644
index 00000000000..7a9cd6ab090
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/auth-token.hbs
@@ -0,0 +1,33 @@
+{{#d-modal-body title="user.auth_tokens.was_this_you"}}
+
+
{{i18n 'user.auth_tokens.was_this_you_description'}}
+
{{{i18n 'user.second_factor.extended_description'}}}
+
+
+
+
{{i18n 'user.auth_tokens.details'}}
+
{{d-icon "clock-o"}} {{format-date model.seen_at}}
+
{{d-icon "map-marker"}} {{model.location}}
+
{{d-icon model.icon}} {{i18n "user.auth_tokens.browser_and_device" browser=model.browser device=model.device}}
+
+
+ {{#if latest_post}}
+
+
+
+ {{#if expanded}}
+
{{{latest_post.cooked}}}
+ {{else}}
+
{{{latest_post.excerpt}}}
+ {{/if}}
+
+ {{/if}}
+{{/d-modal-body}}
+
+
diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs
index 84b58b05c7f..b6985ba8d3a 100644
--- a/app/assets/javascripts/discourse/templates/preferences/account.hbs
+++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs
@@ -51,6 +51,48 @@
{{/if}}
+{{#if canCheckEmails}}
+
+{{/if}}
+
{{#if canChangePassword}}
diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss
index f49a959d000..c7540f02543 100644
--- a/app/assets/stylesheets/common/base/discourse.scss
+++ b/app/assets/stylesheets/common/base/discourse.scss
@@ -538,6 +538,11 @@ select {
text-align: center;
}
}
+
+ &.highlighted {
+ animation: background-fade-highlight 2.5s ease-out;
+ background-color: dark-light-choose($highlight-low, $highlight);
+ }
}
.control-label {
@@ -609,49 +614,57 @@ select {
}
.pref-auth-tokens {
- .control-label {
- display: inline-block;
- }
-
.row {
+ border-bottom: 1px solid #ddd;
margin: 5px 0px;
- }
+ padding-bottom: 5px;
- .muted {
- color: $primary-medium;
- }
-
- .perf-auth-token {
- background-color: $primary-very-low;
- color: $primary;
- display: block;
- padding: 5px;
- margin-bottom: 10px;
- }
-
- .auth-token-summary {
- padding: 0px 10px;
-
- .auth-token-label,
- .auth-token-value {
- font-size: 1.2em;
- margin-top: 5px;
+ &:last-child {
+ border-bottom: 0;
}
}
- .auth-token-details {
- background: $secondary;
- padding: 5px 10px;
- margin: 10px 5px 5px 5px;
-
- .auth-token-label {
- color: $primary-medium;
- }
- }
-
- .auth-token-label,
- .auth-token-value {
+ .auth-token-icon {
+ font-size: 2.25em;
float: left;
- width: 50%;
+ margin-right: 10px;
+ }
+
+ .auth-token-first {
+ font-size: 1.1em;
+
+ .auth-token-device {
+ font-weight: bold;
+ }
+ }
+
+ .auth-token-second {
+ color: $primary-medium;
+
+ .active {
+ color: $success;
+ font-weight: bold;
+ }
+ }
+
+ .auth-token-dropdown {
+ float: right;
+
+ .btn,
+ .btn:hover {
+ background: transparent;
+
+ .d-icon {
+ color: $primary;
+ }
+ }
+ }
+
+ .dropdown-menu {
+ width: 120px;
+
+ & .icon {
+ margin-top: auto;
+ }
}
}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index ff5abffe881..20133fb1ae0 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -412,7 +412,7 @@ class ApplicationController < ActionController::Base
end
def guardian
- @guardian ||= Guardian.new(current_user)
+ @guardian ||= Guardian.new(current_user, request)
end
def current_homepage
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index 9719d769386..eb30dd808cb 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -123,11 +123,24 @@ class PostsController < ApplicationController
posts = posts.reject { |post| !guardian.can_see?(post) || post.topic.blank? }
- @posts = posts
- @title = "#{SiteSetting.title} - #{I18n.t("rss_description.user_posts", username: user.username)}"
- @link = "#{Discourse.base_url}/u/#{user.username}/activity"
- @description = I18n.t("rss_description.user_posts", username: user.username)
- render 'posts/latest', formats: [:rss]
+ respond_to do |format|
+ format.rss do
+ @posts = posts
+ @title = "#{SiteSetting.title} - #{I18n.t("rss_description.user_posts", username: user.username)}"
+ @link = "#{Discourse.base_url}/u/#{user.username}/activity"
+ @description = I18n.t("rss_description.user_posts", username: user.username)
+ render 'posts/latest', formats: [:rss]
+ end
+
+ format.json do
+ render_json_dump(serialize_data(posts,
+ PostSerializer,
+ scope: guardian,
+ add_excerpt: true)
+ )
+ end
+ end
+
end
def cooked
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 44df9dad04d..12ada62ff06 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1108,7 +1108,11 @@ class UsersController < ApplicationController
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
- UserAuthToken.where(user_id: user.id).each(&:destroy!)
+ if !SiteSetting.log_out_strict && params[:token_id]
+ UserAuthToken.where(id: params[:token_id], user_id: user.id).each(&:destroy!)
+ else
+ UserAuthToken.where(user_id: user.id).each(&:destroy!)
+ end
MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id]
diff --git a/app/serializers/concerns/user_auth_tokens_mixin.rb b/app/serializers/concerns/user_auth_tokens_mixin.rb
index e4dac0e2a54..39e6e3232a9 100644
--- a/app/serializers/concerns/user_auth_tokens_mixin.rb
+++ b/app/serializers/concerns/user_auth_tokens_mixin.rb
@@ -1,11 +1,16 @@
+require_dependency 'browser_detection'
+require_dependency 'discourse_ip_info'
+
module UserAuthTokensMixin
extend ActiveSupport::Concern
included do
attributes :id,
:client_ip,
+ :location,
+ :browser,
+ :device,
:os,
- :device_name,
:icon,
:created_at
end
@@ -14,55 +19,39 @@ module UserAuthTokensMixin
object.client_ip.to_s
end
- def os
- case object.user_agent
- when /Android/i
- 'Android'
- when /iPhone|iPad|iPod/i
- 'iOS'
- when /Macintosh/i
- 'macOS'
- when /Linux/i
- 'Linux'
- when /Windows/i
- 'Windows'
- else
- I18n.t('staff_action_logs.unknown')
- end
+ def location
+ ipinfo = DiscourseIpInfo.get(client_ip)
+
+ location = [ipinfo[:city], ipinfo[:region], ipinfo[:country]].reject { |x| x.blank? }.join(", ")
+ return I18n.t('staff_action_logs.unknown') if location.blank?
+
+ location
end
- def device_name
- case object.user_agent
- when /Android/i
- I18n.t('user_auth_tokens.devices.android')
- when /iPad/i
- I18n.t('user_auth_tokens.devices.ipad')
- when /iPhone/i
- I18n.t('user_auth_tokens.devices.iphone')
- when /iPod/i
- I18n.t('user_auth_tokens.devices.ipod')
- when /Mobile/i
- I18n.t('user_auth_tokens.devices.mobile')
- when /Macintosh/i
- I18n.t('user_auth_tokens.devices.mac')
- when /Linux/i
- I18n.t('user_auth_tokens.devices.linux')
- when /Windows/i
- I18n.t('user_auth_tokens.devices.windows')
- else
- I18n.t('user_auth_tokens.devices.unknown')
- end
+ def browser
+ val = BrowserDetection.browser(object.user_agent)
+ I18n.t("user_auth_tokens.browser.#{val}")
+ end
+
+ def device
+ val = BrowserDetection.device(object.user_agent)
+ I18n.t("user_auth_tokens.device.#{val}")
+ end
+
+ def os
+ val = BrowserDetection.os(object.user_agent)
+ I18n.t("user_auth_tokens.os.#{val}")
end
def icon
- case os
- when 'Android'
+ case BrowserDetection.os(object.user_agent)
+ when :android
'android'
- when 'macOS', 'iOS'
+ when :macos, :ios
'apple'
- when 'Linux'
+ when :linux
'linux'
- when 'Windows'
+ when :windows
'windows'
else
'question'
diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb
index d389cecafef..fc8c7f46116 100644
--- a/app/serializers/post_serializer.rb
+++ b/app/serializers/post_serializer.rb
@@ -9,7 +9,8 @@ class PostSerializer < BasicPostSerializer
:single_post_link_counts,
:draft_sequence,
:post_actions,
- :all_post_actions
+ :all_post_actions,
+ :add_excerpt
]
INSTANCE_VARS.each do |v|
@@ -70,7 +71,8 @@ class PostSerializer < BasicPostSerializer
:action_code,
:action_code_who,
:last_wiki_edit,
- :locked
+ :locked,
+ :excerpt
def initialize(object, opts)
super(object, opts)
@@ -97,6 +99,10 @@ class PostSerializer < BasicPostSerializer
@add_title
end
+ def include_excerpt?
+ @add_excerpt
+ end
+
def topic_title
topic&.title
end
diff --git a/app/serializers/user_auth_token_serializer.rb b/app/serializers/user_auth_token_serializer.rb
index 8a9fa01c992..d7dd0ad489f 100644
--- a/app/serializers/user_auth_token_serializer.rb
+++ b/app/serializers/user_auth_token_serializer.rb
@@ -2,4 +2,21 @@ class UserAuthTokenSerializer < ApplicationSerializer
include UserAuthTokensMixin
attributes :seen_at
+ attributes :is_active
+
+ def include_is_active?
+ scope && scope.request
+ end
+
+ def is_active
+ cookie = scope.request.cookies[Auth::DefaultCurrentUserProvider::TOKEN_COOKIE]
+
+ UserAuthToken.hash_token(cookie) == object.auth_token
+ end
+
+ def seen_at
+ return object.created_at unless object.seen_at.present?
+
+ object.seen_at
+ end
end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
index eedadd87bfd..cc39293e2d1 100644
--- a/app/serializers/user_serializer.rb
+++ b/app/serializers/user_serializer.rb
@@ -197,8 +197,9 @@ class UserSerializer < BasicUserSerializer
def user_auth_tokens
ActiveModel::ArraySerializer.new(
- object.user_auth_tokens.order(:seen_at).reverse_order,
- each_serializer: UserAuthTokenSerializer
+ object.user_auth_tokens,
+ each_serializer: UserAuthTokenSerializer,
+ scope: scope
)
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 4617ea7c505..54b9783a9f1 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -865,16 +865,18 @@ en:
auth_tokens:
title: "Recently Used Devices"
- title_logs: "Authentication Logs"
- ip_address: "IP Address"
- created: "Created"
- first_seen: "First Seen"
- last_seen: "Last Seen"
- operating_system: "Operating System"
- location: "Location"
- action: "Action"
- login: "Log in"
- logout: "Log out everywhere"
+ ip: "IP"
+ details: "Details"
+ log_out_all: "Log out all"
+ active: "active now"
+ not_you: "Not you?"
+ show_all: "Show all ({{count}})"
+ show_few: "Show fewer"
+ was_this_you: "Was this you?"
+ was_this_you_description: "If it wasn't you who logged in, we recommend you to change your password and log out of all devices. We also recommend setting up second-factor authentication for a better protection of your account."
+ browser_and_device: "{{browser}} on {{device}}"
+ secure_account: "Secure my account"
+ latest_post: "You last posted..."
last_posted: "Last Post"
last_emailed: "Last Emailed"
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index e5236174f00..21fbb68dd42 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -696,16 +696,30 @@ en:
title: "Email login"
user_auth_tokens:
- devices:
- android: 'Android Device'
- linux: 'Linux Computer'
- windows: 'Windows Computer'
- mac: 'Mac'
- iphone: 'iPhone'
- ipad: 'iPad'
- ipod: 'iPod'
- mobile: 'Mobile Device'
- unknown: 'Unknown device'
+ browser:
+ chrome: "Google Chrome"
+ safari: "Safari"
+ firefox: "Firefox"
+ opera: "Opera"
+ ie: "Internet Explorer"
+ unknown: "unknown browser"
+ device:
+ android: "Android Device"
+ ipad: "iPad"
+ iphone: "iPhone"
+ ipod: "iPod"
+ mobile: "Mobile Device"
+ mac: "Mac"
+ linux: "Linux Computer"
+ windows: "Windows Computer"
+ unknown: "unknown device"
+ os:
+ android: "Android"
+ ios: "iOS"
+ macos: "macOS"
+ linux: "Linux"
+ windows: "Microsoft Windows"
+ unknown: "unknown operating system"
change_email:
confirmed: "Your email has been updated."
diff --git a/config/routes.rb b/config/routes.rb
index 631c0dcaaf7..d1d9f36575a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -420,6 +420,7 @@ Discourse::Application.routes.draw do
get "#{root_path}/:username/summary" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity/topics.rss" => "list#user_topics_feed", format: :rss, constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity.rss" => "posts#user_posts_feed", format: :rss, constraints: { username: RouteFormat.username }
+ get "#{root_path}/:username/activity.json" => "posts#user_posts_feed", format: :json, constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/activity/:filter" => "users#show", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username }
diff --git a/lib/browser_detection.rb b/lib/browser_detection.rb
new file mode 100644
index 00000000000..cf3ea57e6ae
--- /dev/null
+++ b/lib/browser_detection.rb
@@ -0,0 +1,60 @@
+module BrowserDetection
+
+ def self.browser(user_agent)
+ case user_agent
+ when /Opera/i, /OPR/i
+ :opera
+ when /Firefox/i
+ :firefox
+ when /Chrome/i, /CriOS/i
+ :chrome
+ when /Safari/i
+ :safari
+ when /MSIE/i, /Trident/i
+ :ie
+ else
+ :unknown
+ end
+ end
+
+ def self.device(user_agent)
+ case user_agent
+ when /Android/i
+ :android
+ when /iPad/i
+ :ipad
+ when /iPhone/i
+ :iphone
+ when /iPod/i
+ :ipod
+ when /Mobile/i
+ :mobile
+ when /Macintosh/i
+ :mac
+ when /Linux/i
+ :linux
+ when /Windows/i
+ :windows
+ else
+ :unknown
+ end
+ end
+
+ def self.os(user_agent)
+ case user_agent
+ when /Android/i
+ :android
+ when /iPhone|iPad|iPod/i
+ :ios
+ when /Macintosh/i
+ :macos
+ when /Linux/i
+ :linux
+ when /Windows/i
+ :windows
+ else
+ :unknown
+ end
+ end
+
+end
diff --git a/lib/discourse_ip_info.rb b/lib/discourse_ip_info.rb
new file mode 100644
index 00000000000..2f627f4c8f4
--- /dev/null
+++ b/lib/discourse_ip_info.rb
@@ -0,0 +1,46 @@
+require_dependency 'maxminddb'
+
+class DiscourseIpInfo
+ include Singleton
+
+ def initialize
+ begin
+ @mmdb_filename = File.join(Rails.root, 'vendor', 'data', 'GeoLite2-City.mmdb')
+ @mmdb = MaxMindDB.new(@mmdb_filename, MaxMindDB::LOW_MEMORY_FILE_READER)
+ @cache = LruRedux::ThreadSafeCache.new(1000)
+ rescue Errno::ENOENT => e
+ Rails.logger.warn("MaxMindDB could not be found: #{e}")
+ rescue
+ Rails.logger.warn("MaxMindDB could not be loaded.")
+ end
+ end
+
+ def lookup(ip)
+ return {} unless @mmdb
+
+ begin
+ result = @mmdb.lookup(ip)
+ rescue
+ Rails.logger.error("IP #{ip} could not be looked up in MaxMindDB.")
+ end
+
+ return {} if !result || !result.found?
+
+ {
+ country: result.country.name,
+ country_code: result.country.iso_code,
+ region: result.subdivisions.most_specific.name,
+ city: result.city.name,
+ }
+ end
+
+ def get(ip)
+ return {} unless @mmdb
+
+ @cache[ip] ||= lookup(ip)
+ end
+
+ def self.get(ip)
+ instance.get(ip)
+ end
+end
diff --git a/lib/guardian.rb b/lib/guardian.rb
index e8bb517f80b..2804b23c84e 100644
--- a/lib/guardian.rb
+++ b/lib/guardian.rb
@@ -33,9 +33,11 @@ class Guardian
end
attr_accessor :can_see_emails
+ attr_reader :request
- def initialize(user = nil)
+ def initialize(user = nil, request = nil)
@user = user.presence || AnonymousUser.new
+ @request = request
end
def user
diff --git a/lib/tasks/maxminddb.rake b/lib/tasks/maxminddb.rake
new file mode 100644
index 00000000000..2af862a72dd
--- /dev/null
+++ b/lib/tasks/maxminddb.rake
@@ -0,0 +1,22 @@
+require 'rubygems/package'
+require 'zlib'
+
+desc "downloads MaxMind's GeoLite2-City database"
+task "maxminddb:get" => :environment do
+ uri = URI("http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz")
+ tar_gz_archive = Net::HTTP.get(uri)
+
+ extractor = Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(tar_gz_archive)))
+ extractor.rewind
+
+ extractor.each do |entry|
+ next unless entry.full_name.ends_with?(".mmdb")
+
+ filename = File.join(Rails.root, 'vendor', 'data', 'GeoLite2-City.mmdb')
+ File.open(filename, "wb") do |f|
+ f.write(entry.read)
+ end
+ end
+
+ extractor.close
+end
diff --git a/spec/lib/browser_detection_spec.rb b/spec/lib/browser_detection_spec.rb
new file mode 100644
index 00000000000..6a4722ca741
--- /dev/null
+++ b/spec/lib/browser_detection_spec.rb
@@ -0,0 +1,37 @@
+require 'rails_helper'
+require 'browser_detection'
+
+describe BrowserDetection do
+
+ it "detects browser, device and operating system" do
+ [
+ ["Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", :ie, :windows, :windows],
+ ["Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)", :ie, :windows, :windows],
+ ["Mozilla/5.0 (iPad; CPU OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13F69 Safari/601.1", :safari, :ipad, :ios],
+ ["Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1", :safari, :iphone, :ios],
+ ["Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/51.0.2704.104 Mobile/13F69 Safari/601.1.46", :chrome, :iphone, :ios],
+ ["Mozilla/5.0 (Linux; Android 4.1.1; Nexus 7 Build/JRO03D) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Safari/535.19", :chrome, :android, :android],
+ ["Mozilla/5.0 (Linux; Android 4.4.2; XMP-6250 Build/HAWK) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Safari/537.36 ADAPI/2.0 (UUID:9e7df0ed-2a5c-4a19-bec7-2cc54800f99d) RK3188-ADAPI/1.2.84.533 (MODEL:XMP-6250)", :chrome, :android, :android],
+ ["Mozilla/5.0 (Linux; Android 5.1; Nexus 7 Build/LMY47O) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.105 Safari/537.36", :chrome, :android, :android],
+ ["Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36", :chrome, :android, :android],
+ ["Mozilla/5.0 (Linux; Android; 4.1.2; GT-I9100 Build/000000) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1234.12 Mobile Safari/537.22 OPR/14.0.123.123", :opera, :android, :android],
+ ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :mac, :macos],
+ ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :mac, :macos],
+ ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36", :chrome, :mac, :macos],
+ ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36", :chrome, :windows, :windows],
+ ["Mozilla/5.0 (Windows NT 5.1; rv:7.0.1) Gecko/20100101 Firefox/7.0.1", :firefox, :windows, :windows],
+ ["Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko", :ie, :windows, :windows],
+ ["Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36", :chrome, :windows, :windows],
+ ["Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1", :firefox, :windows, :windows],
+ ["Mozilla/5.0 (Windows NT 6.1; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :windows, :windows],
+ ["Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36", :chrome, :linux, :linux],
+ ["Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20150101 Firefox/47.0 (Chrome)", :firefox, :linux, :linux],
+ ["Opera/9.80 (X11; Linux zvav; U; en) Presto/2.12.423 Version/12.16", :opera, :linux, :linux],
+ ].each do |user_agent, browser, device, os|
+ expect(BrowserDetection.browser(user_agent)).to eq(browser)
+ expect(BrowserDetection.device(user_agent)).to eq(device)
+ expect(BrowserDetection.os(user_agent)).to eq(os)
+ end
+ end
+
+end
diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb
index 2e0264726f4..57ffcea60bc 100644
--- a/spec/requests/posts_controller_spec.rb
+++ b/spec/requests/posts_controller_spec.rb
@@ -1460,6 +1460,20 @@ describe PostsController do
expect(body).to_not include(private_post.url)
expect(body).to include(public_post.url)
end
+
+ it 'returns public posts as JSON' do
+ public_post
+ private_post
+
+ get "/u/#{user.username}/activity.json"
+
+ expect(response.status).to eq(200)
+
+ body = response.body
+
+ expect(body).to_not include(private_post.topic.slug)
+ expect(body).to include(public_post.topic.slug)
+ end
end
describe '#latest' do
diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb
index 7697f083453..c7e76f97acb 100644
--- a/spec/requests/users_controller_spec.rb
+++ b/spec/requests/users_controller_spec.rb
@@ -3242,10 +3242,36 @@ describe UsersController do
context 'while logged in' do
before do
sign_in(user)
+ sign_in(user)
end
it 'logs user out' do
+ SiteSetting.log_out_strict = false
+ expect(user.user_auth_tokens.count).to eq(2)
+
+ ids = user.user_auth_tokens.map { |token| token.id }
+ post "/u/#{user.username}/preferences/revoke-auth-token.json", params: { token_id: ids[0] }
+
+ expect(response.status).to eq(200)
+
+ user.user_auth_tokens.reload
expect(user.user_auth_tokens.count).to eq(1)
+ expect(user.user_auth_tokens.first.id).to eq(ids[1])
+ end
+
+ it 'logs user out from everywhere if log_out_strict is enabled' do
+ SiteSetting.log_out_strict = true
+ expect(user.user_auth_tokens.count).to eq(2)
+
+ ids = user.user_auth_tokens.map { |token| token.id }
+ post "/u/#{user.username}/preferences/revoke-auth-token.json", params: { token_id: ids[0] }
+
+ expect(response.status).to eq(200)
+ expect(user.user_auth_tokens.count).to eq(0)
+ end
+
+ it 'logs user out from everywhere if token_id is not present' do
+ expect(user.user_auth_tokens.count).to eq(2)
post "/u/#{user.username}/preferences/revoke-auth-token.json"
diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb
index a137b1e12d3..8877d23a9f0 100644
--- a/spec/support/helpers.rb
+++ b/spec/support/helpers.rb
@@ -58,7 +58,7 @@ module Helpers
def stub_guardian(user)
guardian = Guardian.new(user)
yield(guardian) if block_given?
- Guardian.stubs(new: guardian).with(user)
+ Guardian.stubs(new: guardian).with(user, anything)
end
def wait_for(on_fail: nil, &blk)
diff --git a/test/javascripts/acceptance/preferences-test.js.es6 b/test/javascripts/acceptance/preferences-test.js.es6
index 66d3269b211..71961b489fc 100644
--- a/test/javascripts/acceptance/preferences-test.js.es6
+++ b/test/javascripts/acceptance/preferences-test.js.es6
@@ -39,6 +39,10 @@ acceptance("User Preferences", {
gravatar_avatar_template: "something"
});
});
+
+ server.get("/u/eviltrout/activity.json", () => {
+ return helper.response({});
+ });
}
});
@@ -248,3 +252,38 @@ QUnit.test("visit my preferences", async assert => {
);
assert.ok(exists(".user-preferences"), "it shows the preferences");
});
+
+QUnit.test("recently connected devices", async assert => {
+ await visit("/u/eviltrout/preferences");
+
+ assert.equal(
+ find(".pref-auth-tokens > a:first")
+ .text()
+ .trim(),
+ I18n.t("user.auth_tokens.show_all", { count: 3 }),
+ "it should display two tokens"
+ );
+ assert.ok(
+ find(".pref-auth-tokens .auth-token").length === 2,
+ "it should display two tokens"
+ );
+
+ await click(".pref-auth-tokens > a:first");
+
+ assert.ok(
+ find(".pref-auth-tokens .auth-token").length === 3,
+ "it should display three tokens"
+ );
+
+ await click(".auth-token-dropdown:first button");
+ await click("li[data-value='notYou']");
+
+ assert.ok(find(".d-modal:visible").length === 1, "modal should appear");
+
+ await click(".modal-footer .btn-primary");
+
+ assert.ok(
+ find(".pref-password.highlighted").length === 1,
+ "it should highlight password preferences"
+ );
+});
diff --git a/test/javascripts/fixtures/user_fixtures.js.es6 b/test/javascripts/fixtures/user_fixtures.js.es6
index e397ce918c7..b0fd0f7f97b 100644
--- a/test/javascripts/fixtures/user_fixtures.js.es6
+++ b/test/javascripts/fixtures/user_fixtures.js.es6
@@ -236,7 +236,45 @@ export default {
badge_grouping_id: 8,
system: false,
badge_type_id: 3
- }
+ },
+ user_auth_tokens: [
+ {
+ id: 2,
+ client_ip: "188.192.99.49",
+ location: "Augsburg, Bavaria, Germany",
+ browser: "Google Chrome",
+ device: "Linux Computer",
+ os: "Linux",
+ icon: "linux",
+ created_at: "2018-09-08T21:22:56.225Z",
+ seen_at: "2018-09-08T21:22:56.512Z",
+ is_active: false
+ },
+ {
+ id: 3,
+ client_ip: "188.120.223.89",
+ location: "České Budějovice, České Budějovice District, Czechia",
+ browser: "Google Chrome",
+ device: "Linux Computer",
+ os: "Linux",
+ icon: "linux",
+ created_at: "2018-09-08T21:33:41.616Z",
+ seen_at: "2018-09-08T21:33:42.209Z",
+ is_active: true
+ },
+ {
+ id: 6,
+ client_ip: "188.233.223.89",
+ location: "Tula, Tul'skaya Oblast, Russia",
+ browser: "Internet Explorer",
+ device: "Windows Computer",
+ os: "Windows",
+ icon: "windows",
+ created_at: "2018-09-07T21:44:41.616Z",
+ seen_at: "2018-09-08T21:44:42.209Z",
+ is_active: false
+ }
+ ]
}
},
"/user_actions.json": {