mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 11:44:49 +08:00
FEATURE: Show "Recently used devices" in user preferences (#6335)
* FEATURE: Added MaxMindDb to resolve IP information. * FEATURE: Added browser detection based on user agent. * FEATURE: Added recently used devices in user preferences. * DEV: Added acceptance test for recently used devices. * UX: Do not show 'Show more' button if there aren't more tokens. * DEV: Fix unit tests. * DEV: Make changes after code review. * Add more detailed unit tests. * Improve logging messages. * Minor coding style fixes. * DEV: Use DropdownSelectBoxComponent and run Prettier. * DEV: Fix unit tests.
This commit is contained in:
parent
1fb1f4c790
commit
1d26a473e7
1
Gemfile
1
Gemfile
|
@ -194,3 +194,4 @@ end
|
|||
|
||||
gem 'webpush', require: false
|
||||
gem 'colored2', require: false
|
||||
gem 'maxminddb'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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"
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
{{#d-modal-body title="user.auth_tokens.was_this_you"}}
|
||||
<div>
|
||||
<p>{{i18n 'user.auth_tokens.was_this_you_description'}}</p>
|
||||
<p>{{{i18n 'user.second_factor.extended_description'}}}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>{{i18n 'user.auth_tokens.details'}}</h3>
|
||||
<p>{{d-icon "clock-o"}} {{format-date model.seen_at}}</p>
|
||||
<p>{{d-icon "map-marker"}} {{model.location}}</p>
|
||||
<p>{{d-icon model.icon}} {{i18n "user.auth_tokens.browser_and_device" browser=model.browser device=model.device}}</p>
|
||||
</div>
|
||||
|
||||
{{#if latest_post}}
|
||||
<div>
|
||||
<h3>
|
||||
{{i18n 'user.auth_tokens.latest_post'}}
|
||||
<a {{action "toggleExpanded"}}>{{d-icon (if expanded "caret-up" "caret-down")}}</a>
|
||||
</h3>
|
||||
|
||||
{{#if expanded}}
|
||||
<blockquote>{{{latest_post.cooked}}}</blockquote>
|
||||
{{else}}
|
||||
<blockquote>{{{latest_post.excerpt}}}</blockquote>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/d-modal-body}}
|
||||
|
||||
<div class="modal-footer">
|
||||
{{d-button class="btn btn-primary" icon="lock" label="user.auth_tokens.secure_account" action="highlightSecure"}}
|
||||
{{d-modal-cancel close=(action "closeModal")}}
|
||||
</div>
|
|
@ -51,6 +51,48 @@
|
|||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if canCheckEmails}}
|
||||
<div class="control-group pref-auth-tokens">
|
||||
<label class="control-label">{{i18n 'user.auth_tokens.title'}}</label>
|
||||
|
||||
<div class="auth-tokens">
|
||||
{{#each authTokens as |token|}}
|
||||
<div class="row auth-token">
|
||||
<div class="auth-token-icon">{{d-icon token.icon}}</div>
|
||||
{{#unless token.is_active}}
|
||||
{{auth-token-dropdown token=token
|
||||
revokeAuthToken=(action "revokeAuthToken")
|
||||
showToken=(action "showToken")}}
|
||||
{{/unless}}
|
||||
<div class="auth-token-first">
|
||||
<span class="auth-token-device">{{token.device}}</span> – <span title="{{i18n "user.auth_tokens.ip"}}: {{token.client_ip}}">{{token.location}}</span>
|
||||
</div>
|
||||
<div class="auth-token-second">
|
||||
{{token.browser}} |
|
||||
{{#if token.is_active}}
|
||||
<span class="active">{{i18n 'user.auth_tokens.active'}}</span>
|
||||
{{else}}
|
||||
{{format-date token.seen_at}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
{{#if canShowAllAuthTokens}}
|
||||
<a {{action "toggleShowAllAuthTokens"}}>
|
||||
{{#if showAllAuthTokens}}
|
||||
{{d-icon "caret-up"}} {{i18n 'user.auth_tokens.show_few'}}
|
||||
{{else}}
|
||||
{{d-icon "caret-down"}} {{i18n 'user.auth_tokens.show_all' count=model.user_auth_tokens.length}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{/if}}
|
||||
|
||||
<a {{action "revokeAuthToken"}} class="pull-right text-danger">{{d-icon "sign-out"}} {{i18n 'user.auth_tokens.log_out_all'}}</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if canChangePassword}}
|
||||
<div class="control-group pref-password">
|
||||
<label class="control-label">{{i18n 'user.password.title'}}</label>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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 }
|
||||
|
|
60
lib/browser_detection.rb
Normal file
60
lib/browser_detection.rb
Normal file
|
@ -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
|
46
lib/discourse_ip_info.rb
Normal file
46
lib/discourse_ip_info.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
22
lib/tasks/maxminddb.rake
Normal file
22
lib/tasks/maxminddb.rake
Normal file
|
@ -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
|
37
spec/lib/browser_detection_spec.rb
Normal file
37
spec/lib/browser_detection_spec.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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": {
|
||||
|
|
Loading…
Reference in New Issue
Block a user