DEV: Apply syntax_tree formatting to lib/*

This commit is contained in:
David Taylor 2023-01-09 12:10:19 +00:00
parent b0fda61a8e
commit 6417173082
No known key found for this signature in database
GPG Key ID: 46904C18B1D3F434
507 changed files with 16550 additions and 12627 deletions

View File

@ -1,4 +1,3 @@
--print-width=100 --print-width=100
--plugins=plugin/trailing_comma,disable_ternary --plugins=plugin/trailing_comma,disable_ternary
--ignore-files=app/* --ignore-files=app/*
--ignore-files=lib/*

View File

@ -9,9 +9,7 @@ class ActionDispatch::Session::DiscourseCookieStore < ActionDispatch::Session::C
def set_cookie(request, session_id, cookie) def set_cookie(request, session_id, cookie)
if Hash === cookie if Hash === cookie
if SiteSetting.force_https cookie[:secure] = true if SiteSetting.force_https
cookie[:secure] = true
end
unless SiteSetting.same_site_cookies == "Disabled" unless SiteSetting.same_site_cookies == "Disabled"
cookie[:same_site] = SiteSetting.same_site_cookies cookie[:same_site] = SiteSetting.same_site_cookies
end end

View File

@ -17,10 +17,7 @@ class AdminConfirmation
@token = SecureRandom.hex @token = SecureRandom.hex
Discourse.redis.setex("admin-confirmation:#{@target_user.id}", 3.hours.to_i, @token) Discourse.redis.setex("admin-confirmation:#{@target_user.id}", 3.hours.to_i, @token)
payload = { payload = { target_user_id: @target_user.id, performed_by: @performed_by.id }
target_user_id: @target_user.id,
performed_by: @performed_by.id
}
Discourse.redis.setex("admin-confirmation-token:#{@token}", 3.hours.to_i, payload.to_json) Discourse.redis.setex("admin-confirmation-token:#{@token}", 3.hours.to_i, payload.to_json)
Jobs.enqueue( Jobs.enqueue(
@ -28,7 +25,7 @@ class AdminConfirmation
to_address: @performed_by.email, to_address: @performed_by.email,
target_email: @target_user.email, target_email: @target_user.email,
target_username: @target_user.username, target_username: @target_user.username,
token: @token token: @token,
) )
end end
@ -51,8 +48,8 @@ class AdminConfirmation
return nil unless json return nil unless json
parsed = JSON.parse(json) parsed = JSON.parse(json)
target_user = User.find(parsed['target_user_id'].to_i) target_user = User.find(parsed["target_user_id"].to_i)
performed_by = User.find(parsed['performed_by'].to_i) performed_by = User.find(parsed["performed_by"].to_i)
ac = AdminConfirmation.new(target_user, performed_by) ac = AdminConfirmation.new(target_user, performed_by)
ac.token = token ac.token = token

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class AdminConstraint class AdminConstraint
def initialize(options = {}) def initialize(options = {})
@require_master = options[:require_master] @require_master = options[:require_master]
end end
@ -19,5 +18,4 @@ class AdminConstraint
def custom_admin_check(request) def custom_admin_check(request)
true true
end end
end end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class AdminUserIndexQuery class AdminUserIndexQuery
def initialize(params = {}, klass = User, trust_levels = TrustLevel.levels) def initialize(params = {}, klass = User, trust_levels = TrustLevel.levels)
@params = params @params = params
@query = initialize_query_with_order(klass) @query = initialize_query_with_order(klass)
@ -11,24 +10,22 @@ class AdminUserIndexQuery
attr_reader :params, :trust_levels attr_reader :params, :trust_levels
SORTABLE_MAPPING = { SORTABLE_MAPPING = {
'created' => 'created_at', "created" => "created_at",
'last_emailed' => "COALESCE(last_emailed_at, to_date('1970-01-01', 'YYYY-MM-DD'))", "last_emailed" => "COALESCE(last_emailed_at, to_date('1970-01-01', 'YYYY-MM-DD'))",
'seen' => "COALESCE(last_seen_at, to_date('1970-01-01', 'YYYY-MM-DD'))", "seen" => "COALESCE(last_seen_at, to_date('1970-01-01', 'YYYY-MM-DD'))",
'username' => 'username', "username" => "username",
'email' => 'email', "email" => "email",
'trust_level' => 'trust_level', "trust_level" => "trust_level",
'days_visited' => 'user_stats.days_visited', "days_visited" => "user_stats.days_visited",
'posts_read' => 'user_stats.posts_read_count', "posts_read" => "user_stats.posts_read_count",
'topics_viewed' => 'user_stats.topics_entered', "topics_viewed" => "user_stats.topics_entered",
'posts' => 'user_stats.post_count', "posts" => "user_stats.post_count",
'read_time' => 'user_stats.time_read' "read_time" => "user_stats.time_read",
} }
def find_users(limit = 100) def find_users(limit = 100)
page = params[:page].to_i - 1 page = params[:page].to_i - 1
if page < 0 page = 0 if page < 0
page = 0
end
find_users_query.limit(limit).offset(page * limit) find_users_query.limit(limit).offset(page * limit)
end end
@ -37,7 +34,13 @@ class AdminUserIndexQuery
end end
def custom_direction def custom_direction
Discourse.deprecate(":ascending is deprecated please use :asc instead", output_in_test: true, drop_from: '2.9.0') if params[:ascending] if params[:ascending]
Discourse.deprecate(
":ascending is deprecated please use :asc instead",
output_in_test: true,
drop_from: "2.9.0",
)
end
asc = params[:asc] || params[:ascending] asc = params[:asc] || params[:ascending]
asc.present? && asc ? "ASC" : "DESC" asc.present? && asc ? "ASC" : "DESC"
end end
@ -47,7 +50,7 @@ class AdminUserIndexQuery
custom_order = params[:order] custom_order = params[:order]
if custom_order.present? && if custom_order.present? &&
without_dir = SORTABLE_MAPPING[custom_order.downcase.sub(/ (asc|desc)$/, '')] without_dir = SORTABLE_MAPPING[custom_order.downcase.sub(/ (asc|desc)$/, "")]
order << "#{without_dir} #{custom_direction}" order << "#{without_dir} #{custom_direction}"
end end
@ -61,13 +64,9 @@ class AdminUserIndexQuery
order << "users.username" order << "users.username"
end end
query = klass query = klass.includes(:totps).order(order.reject(&:blank?).join(","))
.includes(:totps)
.order(order.reject(&:blank?).join(","))
unless params[:stats].present? && params[:stats] == false query = query.includes(:user_stat) unless params[:stats].present? && params[:stats] == false
query = query.includes(:user_stat)
end
query = query.joins(:primary_email) if params[:show_emails] == "true" query = query.joins(:primary_email) if params[:show_emails] == "true"
@ -77,32 +76,44 @@ class AdminUserIndexQuery
def filter_by_trust def filter_by_trust
levels = trust_levels.map { |key, _| key.to_s } levels = trust_levels.map { |key, _| key.to_s }
if levels.include?(params[:query]) if levels.include?(params[:query])
@query.where('trust_level = ?', trust_levels[params[:query].to_sym]) @query.where("trust_level = ?", trust_levels[params[:query].to_sym])
end end
end end
def filter_by_query_classification def filter_by_query_classification
case params[:query] case params[:query]
when 'staff' then @query.where("admin or moderator") when "staff"
when 'admins' then @query.where(admin: true) @query.where("admin or moderator")
when 'moderators' then @query.where(moderator: true) when "admins"
when 'silenced' then @query.silenced @query.where(admin: true)
when 'suspended' then @query.suspended when "moderators"
when 'pending' then @query.not_suspended.where(approved: false, active: true) @query.where(moderator: true)
when 'staged' then @query.where(staged: true) when "silenced"
@query.silenced
when "suspended"
@query.suspended
when "pending"
@query.not_suspended.where(approved: false, active: true)
when "staged"
@query.where(staged: true)
end end
end end
def filter_by_search def filter_by_search
if params[:email].present? if params[:email].present?
return @query.joins(:primary_email).where('user_emails.email = ?', params[:email].downcase) return @query.joins(:primary_email).where("user_emails.email = ?", params[:email].downcase)
end end
filter = params[:filter] filter = params[:filter]
if filter.present? if filter.present?
filter = filter.strip filter = filter.strip
if ip = IPAddr.new(filter) rescue nil if ip =
@query.where('ip_address <<= :ip OR registration_ip_address <<= :ip', ip: ip.to_cidr_s) begin
IPAddr.new(filter)
rescue StandardError
nil
end
@query.where("ip_address <<= :ip OR registration_ip_address <<= :ip", ip: ip.to_cidr_s)
else else
@query.filter_by_username_or_email(filter) @query.filter_by_username_or_email(filter)
end end
@ -111,14 +122,12 @@ class AdminUserIndexQuery
def filter_by_ip def filter_by_ip
if params[:ip].present? if params[:ip].present?
@query.where('ip_address = :ip OR registration_ip_address = :ip', ip: params[:ip].strip) @query.where("ip_address = :ip OR registration_ip_address = :ip", ip: params[:ip].strip)
end end
end end
def filter_exclude def filter_exclude
if params[:exclude].present? @query.where("users.id != ?", params[:exclude]) if params[:exclude].present?
@query.where('users.id != ?', params[:exclude])
end
end end
# this might not be needed in rails 4 ? # this might not be needed in rails 4 ?
@ -134,5 +143,4 @@ class AdminUserIndexQuery
append filter_by_search append filter_by_search
@query @query
end end
end end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module AgeWords module AgeWords
def self.age_words(secs) def self.age_words(secs)
if secs.blank? if secs.blank?
"&mdash;" "&mdash;"
@ -10,5 +9,4 @@ module AgeWords
FreedomPatches::Rails4.distance_of_time_in_words(now, now + secs) FreedomPatches::Rails4.distance_of_time_in_words(now, now + secs)
end end
end end
end end

View File

@ -11,22 +11,19 @@ class Archetype
end end
def attributes def attributes
{ { id: @id, options: @options }
id: @id,
options: @options
}
end end
def self.default def self.default
'regular' "regular"
end end
def self.private_message def self.private_message
'private_message' "private_message"
end end
def self.banner def self.banner
'banner' "banner"
end end
def self.list def self.list
@ -40,8 +37,7 @@ class Archetype
end end
# default archetypes # default archetypes
register 'regular' register "regular"
register 'private_message' register "private_message"
register 'banner' register "banner"
end end

View File

@ -1,13 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
module Auth; end module Auth
end
require 'auth/auth_provider' require "auth/auth_provider"
require 'auth/result' require "auth/result"
require 'auth/authenticator' require "auth/authenticator"
require 'auth/managed_authenticator' require "auth/managed_authenticator"
require 'auth/facebook_authenticator' require "auth/facebook_authenticator"
require 'auth/github_authenticator' require "auth/github_authenticator"
require 'auth/twitter_authenticator' require "auth/twitter_authenticator"
require 'auth/google_oauth2_authenticator' require "auth/google_oauth2_authenticator"
require 'auth/discord_authenticator' require "auth/discord_authenticator"

View File

@ -8,32 +8,60 @@ class Auth::AuthProvider
end end
def self.auth_attributes def self.auth_attributes
[:authenticator, :pretty_name, :title, :message, :frame_width, :frame_height, %i[
:pretty_name_setting, :title_setting, :enabled_setting, :full_screen_login, :full_screen_login_setting, authenticator
:custom_url, :background_color, :icon] pretty_name
title
message
frame_width
frame_height
pretty_name_setting
title_setting
enabled_setting
full_screen_login
full_screen_login_setting
custom_url
background_color
icon
]
end end
attr_accessor(*auth_attributes) attr_accessor(*auth_attributes)
def enabled_setting=(val) def enabled_setting=(val)
Discourse.deprecate("(#{authenticator.name}) enabled_setting is deprecated. Please define authenticator.enabled? instead", drop_from: '2.9.0') Discourse.deprecate(
"(#{authenticator.name}) enabled_setting is deprecated. Please define authenticator.enabled? instead",
drop_from: "2.9.0",
)
@enabled_setting = val @enabled_setting = val
end end
def background_color=(val) def background_color=(val)
Discourse.deprecate("(#{authenticator.name}) background_color is no longer functional. Please use CSS instead", drop_from: '2.9.0') Discourse.deprecate(
"(#{authenticator.name}) background_color is no longer functional. Please use CSS instead",
drop_from: "2.9.0",
)
end end
def full_screen_login=(val) def full_screen_login=(val)
Discourse.deprecate("(#{authenticator.name}) full_screen_login is now forced. The full_screen_login parameter can be removed from the auth_provider.", drop_from: '2.9.0') Discourse.deprecate(
"(#{authenticator.name}) full_screen_login is now forced. The full_screen_login parameter can be removed from the auth_provider.",
drop_from: "2.9.0",
)
end end
def full_screen_login_setting=(val) def full_screen_login_setting=(val)
Discourse.deprecate("(#{authenticator.name}) full_screen_login is now forced. The full_screen_login_setting parameter can be removed from the auth_provider.", drop_from: '2.9.0') Discourse.deprecate(
"(#{authenticator.name}) full_screen_login is now forced. The full_screen_login_setting parameter can be removed from the auth_provider.",
drop_from: "2.9.0",
)
end end
def message=(val) def message=(val)
Discourse.deprecate("(#{authenticator.name}) message is no longer used because all logins are full screen. It should be removed from the auth_provider", drop_from: '2.9.0') Discourse.deprecate(
"(#{authenticator.name}) message is no longer used because all logins are full screen. It should be removed from the auth_provider",
drop_from: "2.9.0",
)
end end
def name def name
@ -47,5 +75,4 @@ class Auth::AuthProvider
def can_revoke def can_revoke
authenticator.can_revoke? authenticator.can_revoke?
end end
end end

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module Auth; end module Auth
end
class Auth::CurrentUserProvider class Auth::CurrentUserProvider
# do all current user initialization here # do all current user initialization here
def initialize(env) def initialize(env)
raise NotImplementedError raise NotImplementedError

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require_relative '../route_matcher' require_relative "../route_matcher"
# You may have seen references to v0 and v1 of our auth cookie in the codebase # You may have seen references to v0 and v1 of our auth cookie in the codebase
# and you're not sure how they differ, so here is an explanation: # and you're not sure how they differ, so here is an explanation:
@ -23,7 +23,6 @@ require_relative '../route_matcher'
# We'll drop support for v0 after Discourse 2.9 is released. # We'll drop support for v0 after Discourse 2.9 is released.
class Auth::DefaultCurrentUserProvider class Auth::DefaultCurrentUserProvider
CURRENT_USER_KEY ||= "_DISCOURSE_CURRENT_USER" CURRENT_USER_KEY ||= "_DISCOURSE_CURRENT_USER"
USER_TOKEN_KEY ||= "_DISCOURSE_USER_TOKEN" USER_TOKEN_KEY ||= "_DISCOURSE_USER_TOKEN"
API_KEY ||= "api_key" API_KEY ||= "api_key"
@ -37,7 +36,7 @@ class Auth::DefaultCurrentUserProvider
USER_API_CLIENT_ID ||= "HTTP_USER_API_CLIENT_ID" USER_API_CLIENT_ID ||= "HTTP_USER_API_CLIENT_ID"
API_KEY_ENV ||= "_DISCOURSE_API" API_KEY_ENV ||= "_DISCOURSE_API"
USER_API_KEY_ENV ||= "_DISCOURSE_USER_API" USER_API_KEY_ENV ||= "_DISCOURSE_USER_API"
TOKEN_COOKIE ||= ENV['DISCOURSE_TOKEN_COOKIE'] || "_t" TOKEN_COOKIE ||= ENV["DISCOURSE_TOKEN_COOKIE"] || "_t"
PATH_INFO ||= "PATH_INFO" PATH_INFO ||= "PATH_INFO"
COOKIE_ATTEMPTS_PER_MIN ||= 10 COOKIE_ATTEMPTS_PER_MIN ||= 10
BAD_TOKEN ||= "_DISCOURSE_BAD_TOKEN" BAD_TOKEN ||= "_DISCOURSE_BAD_TOKEN"
@ -59,30 +58,20 @@ class Auth::DefaultCurrentUserProvider
"badges#show", "badges#show",
"tags#tag_feed", "tags#tag_feed",
"tags#show", "tags#show",
*[:latest, :unread, :new, :read, :posted, :bookmarks].map { |f| "list##{f}_feed" }, *%i[latest unread new read posted bookmarks].map { |f| "list##{f}_feed" },
*[:all, :yearly, :quarterly, :monthly, :weekly, :daily].map { |p| "list#top_#{p}_feed" }, *%i[all yearly quarterly monthly weekly daily].map { |p| "list#top_#{p}_feed" },
*[:latest, :unread, :new, :read, :posted, :bookmarks].map { |f| "tags#show_#{f}" } *%i[latest unread new read posted bookmarks].map { |f| "tags#show_#{f}" },
], ],
formats: :rss formats: :rss,
),
RouteMatcher.new(
methods: :get,
actions: "users#bookmarks",
formats: :ics
),
RouteMatcher.new(
methods: :post,
actions: "admin/email#handle_mail",
formats: nil
), ),
RouteMatcher.new(methods: :get, actions: "users#bookmarks", formats: :ics),
RouteMatcher.new(methods: :post, actions: "admin/email#handle_mail", formats: nil),
] ]
def self.find_v0_auth_cookie(request) def self.find_v0_auth_cookie(request)
cookie = request.cookies[TOKEN_COOKIE] cookie = request.cookies[TOKEN_COOKIE]
if cookie&.valid_encoding? && cookie.present? && cookie.size == TOKEN_SIZE cookie if cookie&.valid_encoding? && cookie.present? && cookie.size == TOKEN_SIZE
cookie
end
end end
def self.find_v1_auth_cookie(env) def self.find_v1_auth_cookie(env)
@ -111,12 +100,10 @@ class Auth::DefaultCurrentUserProvider
return @env[CURRENT_USER_KEY] if @env.key?(CURRENT_USER_KEY) return @env[CURRENT_USER_KEY] if @env.key?(CURRENT_USER_KEY)
# bypass if we have the shared session header # bypass if we have the shared session header
if shared_key = @env['HTTP_X_SHARED_SESSION_KEY'] if shared_key = @env["HTTP_X_SHARED_SESSION_KEY"]
uid = Discourse.redis.get("shared_session_key_#{shared_key}") uid = Discourse.redis.get("shared_session_key_#{shared_key}")
user = nil user = nil
if uid user = User.find_by(id: uid.to_i) if uid
user = User.find_by(id: uid.to_i)
end
@env[CURRENT_USER_KEY] = user @env[CURRENT_USER_KEY] = user
return user return user
end end
@ -130,28 +117,27 @@ class Auth::DefaultCurrentUserProvider
user_api_key ||= request[PARAMETER_USER_API_KEY] user_api_key ||= request[PARAMETER_USER_API_KEY]
end end
if !@env.blank? && request[API_KEY] && api_parameter_allowed? api_key ||= request[API_KEY] if !@env.blank? && request[API_KEY] && api_parameter_allowed?
api_key ||= request[API_KEY]
end
auth_token = find_auth_token auth_token = find_auth_token
current_user = nil current_user = nil
if auth_token if auth_token
limiter = RateLimiter.new(nil, "cookie_auth_#{request.ip}", COOKIE_ATTEMPTS_PER_MIN , 60) limiter = RateLimiter.new(nil, "cookie_auth_#{request.ip}", COOKIE_ATTEMPTS_PER_MIN, 60)
if limiter.can_perform? if limiter.can_perform?
@env[USER_TOKEN_KEY] = @user_token = begin @env[USER_TOKEN_KEY] = @user_token =
UserAuthToken.lookup( begin
auth_token, UserAuthToken.lookup(
seen: true, auth_token,
user_agent: @env['HTTP_USER_AGENT'], seen: true,
path: @env['REQUEST_PATH'], user_agent: @env["HTTP_USER_AGENT"],
client_ip: @request.ip path: @env["REQUEST_PATH"],
) client_ip: @request.ip,
rescue ActiveRecord::ReadOnlyError )
nil rescue ActiveRecord::ReadOnlyError
end nil
end
current_user = @user_token.try(:user) current_user = @user_token.try(:user)
end end
@ -161,14 +147,10 @@ class Auth::DefaultCurrentUserProvider
begin begin
limiter.performed! limiter.performed!
rescue RateLimiter::LimitExceeded rescue RateLimiter::LimitExceeded
raise Discourse::InvalidAccess.new( raise Discourse::InvalidAccess.new("Invalid Access", nil, delete_cookie: TOKEN_COOKIE)
'Invalid Access',
nil,
delete_cookie: TOKEN_COOKIE
)
end end
end end
elsif @env['HTTP_DISCOURSE_LOGGED_IN'] elsif @env["HTTP_DISCOURSE_LOGGED_IN"]
@env[BAD_TOKEN] = true @env[BAD_TOKEN] = true
end end
@ -177,10 +159,10 @@ class Auth::DefaultCurrentUserProvider
current_user = lookup_api_user(api_key, request) current_user = lookup_api_user(api_key, request)
if !current_user if !current_user
raise Discourse::InvalidAccess.new( raise Discourse::InvalidAccess.new(
I18n.t('invalid_api_credentials'), I18n.t("invalid_api_credentials"),
nil, nil,
custom_message: "invalid_api_credentials" custom_message: "invalid_api_credentials",
) )
end end
raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active
admin_api_key_limiter.performed! if !Rails.env.profile? admin_api_key_limiter.performed! if !Rails.env.profile?
@ -191,12 +173,13 @@ class Auth::DefaultCurrentUserProvider
if user_api_key if user_api_key
@hashed_user_api_key = ApiKey.hash_key(user_api_key) @hashed_user_api_key = ApiKey.hash_key(user_api_key)
user_api_key_obj = UserApiKey user_api_key_obj =
.active UserApiKey
.joins(:user) .active
.where(key_hash: @hashed_user_api_key) .joins(:user)
.includes(:user, :scopes) .where(key_hash: @hashed_user_api_key)
.first .includes(:user, :scopes)
.first
raise Discourse::InvalidAccess unless user_api_key_obj raise Discourse::InvalidAccess unless user_api_key_obj
@ -208,18 +191,14 @@ class Auth::DefaultCurrentUserProvider
current_user = user_api_key_obj.user current_user = user_api_key_obj.user
raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active raise Discourse::InvalidAccess if current_user.suspended? || !current_user.active
if can_write? user_api_key_obj.update_last_used(@env[USER_API_CLIENT_ID]) if can_write?
user_api_key_obj.update_last_used(@env[USER_API_CLIENT_ID])
end
@env[USER_API_KEY_ENV] = true @env[USER_API_KEY_ENV] = true
end end
# keep this rule here as a safeguard # keep this rule here as a safeguard
# under no conditions to suspended or inactive accounts get current_user # under no conditions to suspended or inactive accounts get current_user
if current_user && (current_user.suspended? || !current_user.active) current_user = nil if current_user && (current_user.suspended? || !current_user.active)
current_user = nil
end
if current_user && should_update_last_seen? if current_user && should_update_last_seen?
ip = request.ip ip = request.ip
@ -247,31 +226,40 @@ class Auth::DefaultCurrentUserProvider
if !is_user_api? && @user_token && @user_token.user == user if !is_user_api? && @user_token && @user_token.user == user
rotated_at = @user_token.rotated_at rotated_at = @user_token.rotated_at
needs_rotation = @user_token.auth_token_seen ? rotated_at < UserAuthToken::ROTATE_TIME.ago : rotated_at < UserAuthToken::URGENT_ROTATE_TIME.ago needs_rotation =
(
if @user_token.auth_token_seen
rotated_at < UserAuthToken::ROTATE_TIME.ago
else
rotated_at < UserAuthToken::URGENT_ROTATE_TIME.ago
end
)
if needs_rotation if needs_rotation
if @user_token.rotate!(user_agent: @env['HTTP_USER_AGENT'], if @user_token.rotate!(
client_ip: @request.ip, user_agent: @env["HTTP_USER_AGENT"],
path: @env['REQUEST_PATH']) client_ip: @request.ip,
path: @env["REQUEST_PATH"],
)
set_auth_cookie!(@user_token.unhashed_auth_token, user, cookie_jar) set_auth_cookie!(@user_token.unhashed_auth_token, user, cookie_jar)
DiscourseEvent.trigger(:user_session_refreshed, user) DiscourseEvent.trigger(:user_session_refreshed, user)
end end
end end
end end
if !user && cookie_jar.key?(TOKEN_COOKIE) cookie_jar.delete(TOKEN_COOKIE) if !user && cookie_jar.key?(TOKEN_COOKIE)
cookie_jar.delete(TOKEN_COOKIE)
end
end end
def log_on_user(user, session, cookie_jar, opts = {}) def log_on_user(user, session, cookie_jar, opts = {})
@env[USER_TOKEN_KEY] = @user_token = UserAuthToken.generate!( @env[USER_TOKEN_KEY] = @user_token =
user_id: user.id, UserAuthToken.generate!(
user_agent: @env['HTTP_USER_AGENT'], user_id: user.id,
path: @env['REQUEST_PATH'], user_agent: @env["HTTP_USER_AGENT"],
client_ip: @request.ip, path: @env["REQUEST_PATH"],
staff: user.staff?, client_ip: @request.ip,
impersonate: opts[:impersonate]) staff: user.staff?,
impersonate: opts[:impersonate],
)
set_auth_cookie!(@user_token.unhashed_auth_token, user, cookie_jar) set_auth_cookie!(@user_token.unhashed_auth_token, user, cookie_jar)
user.unstage! user.unstage!
@ -288,23 +276,19 @@ class Auth::DefaultCurrentUserProvider
token: unhashed_auth_token, token: unhashed_auth_token,
user_id: user.id, user_id: user.id,
trust_level: user.trust_level, trust_level: user.trust_level,
issued_at: Time.zone.now.to_i issued_at: Time.zone.now.to_i,
} }
if SiteSetting.persistent_sessions expires = SiteSetting.maximum_session_age.hours.from_now if SiteSetting.persistent_sessions
expires = SiteSetting.maximum_session_age.hours.from_now
end
if SiteSetting.same_site_cookies != "Disabled" same_site = SiteSetting.same_site_cookies if SiteSetting.same_site_cookies != "Disabled"
same_site = SiteSetting.same_site_cookies
end
cookie_jar.encrypted[TOKEN_COOKIE] = { cookie_jar.encrypted[TOKEN_COOKIE] = {
value: data, value: data,
httponly: true, httponly: true,
secure: SiteSetting.force_https, secure: SiteSetting.force_https,
expires: expires, expires: expires,
same_site: same_site same_site: same_site,
} }
end end
@ -313,10 +297,8 @@ class Auth::DefaultCurrentUserProvider
# for signup flow, since all admin emails are stored in # for signup flow, since all admin emails are stored in
# DISCOURSE_DEVELOPER_EMAILS for self-hosters. # DISCOURSE_DEVELOPER_EMAILS for self-hosters.
def make_developer_admin(user) def make_developer_admin(user)
if user.active? && if user.active? && !user.admin && Rails.configuration.respond_to?(:developer_emails) &&
!user.admin && Rails.configuration.developer_emails.include?(user.email)
Rails.configuration.respond_to?(:developer_emails) &&
Rails.configuration.developer_emails.include?(user.email)
user.admin = true user.admin = true
user.save user.save
Group.refresh_automatic_groups!(:staff, :admins) Group.refresh_automatic_groups!(:staff, :admins)
@ -347,7 +329,7 @@ class Auth::DefaultCurrentUserProvider
@user_token.destroy @user_token.destroy
end end
cookie_jar.delete('authentication_data') cookie_jar.delete("authentication_data")
cookie_jar.delete(TOKEN_COOKIE) cookie_jar.delete(TOKEN_COOKIE)
end end
@ -384,9 +366,7 @@ class Auth::DefaultCurrentUserProvider
if api_key = ApiKey.active.with_key(api_key_value).includes(:user).first if api_key = ApiKey.active.with_key(api_key_value).includes(:user).first
api_username = header_api_key? ? @env[HEADER_API_USERNAME] : request[API_USERNAME] api_username = header_api_key? ? @env[HEADER_API_USERNAME] : request[API_USERNAME]
if !api_key.request_allowed?(@env) return nil if !api_key.request_allowed?(@env)
return nil
end
user = user =
if api_key.user if api_key.user
@ -395,7 +375,8 @@ class Auth::DefaultCurrentUserProvider
User.find_by(username_lower: api_username.downcase) User.find_by(username_lower: api_username.downcase)
elsif user_id = header_api_key? ? @env[HEADER_API_USER_ID] : request["api_user_id"] elsif user_id = header_api_key? ? @env[HEADER_API_USER_ID] : request["api_user_id"]
User.find_by(id: user_id.to_i) User.find_by(id: user_id.to_i)
elsif external_id = header_api_key? ? @env[HEADER_API_USER_EXTERNAL_ID] : request["api_user_external_id"] elsif external_id =
header_api_key? ? @env[HEADER_API_USER_EXTERNAL_ID] : request["api_user_external_id"]
SingleSignOnRecord.find_by(external_id: external_id.to_s).try(:user) SingleSignOnRecord.find_by(external_id: external_id.to_s).try(:user)
end end
@ -435,52 +416,48 @@ class Auth::DefaultCurrentUserProvider
limit = GlobalSetting.max_admin_api_reqs_per_minute.to_i limit = GlobalSetting.max_admin_api_reqs_per_minute.to_i
if GlobalSetting.respond_to?(:max_admin_api_reqs_per_key_per_minute) if GlobalSetting.respond_to?(:max_admin_api_reqs_per_key_per_minute)
Discourse.deprecate("DISCOURSE_MAX_ADMIN_API_REQS_PER_KEY_PER_MINUTE is deprecated. Please use DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE", drop_from: '2.9.0') Discourse.deprecate(
limit = [ "DISCOURSE_MAX_ADMIN_API_REQS_PER_KEY_PER_MINUTE is deprecated. Please use DISCOURSE_MAX_ADMIN_API_REQS_PER_MINUTE",
GlobalSetting.max_admin_api_reqs_per_key_per_minute.to_i, drop_from: "2.9.0",
limit )
].max limit = [GlobalSetting.max_admin_api_reqs_per_key_per_minute.to_i, limit].max
end end
@admin_api_key_limiter = RateLimiter.new( @admin_api_key_limiter =
nil, RateLimiter.new(nil, "admin_api_min", limit, 60, error_code: "admin_api_key_rate_limit")
"admin_api_min",
limit,
60,
error_code: "admin_api_key_rate_limit"
)
end end
def user_api_key_limiter_60_secs def user_api_key_limiter_60_secs
@user_api_key_limiter_60_secs ||= RateLimiter.new( @user_api_key_limiter_60_secs ||=
nil, RateLimiter.new(
"user_api_min_#{@hashed_user_api_key}", nil,
GlobalSetting.max_user_api_reqs_per_minute, "user_api_min_#{@hashed_user_api_key}",
60, GlobalSetting.max_user_api_reqs_per_minute,
error_code: "user_api_key_limiter_60_secs" 60,
) error_code: "user_api_key_limiter_60_secs",
)
end end
def user_api_key_limiter_1_day def user_api_key_limiter_1_day
@user_api_key_limiter_1_day ||= RateLimiter.new( @user_api_key_limiter_1_day ||=
nil, RateLimiter.new(
"user_api_day_#{@hashed_user_api_key}", nil,
GlobalSetting.max_user_api_reqs_per_day, "user_api_day_#{@hashed_user_api_key}",
86400, GlobalSetting.max_user_api_reqs_per_day,
error_code: "user_api_key_limiter_1_day" 86_400,
) error_code: "user_api_key_limiter_1_day",
)
end end
def find_auth_token def find_auth_token
return @auth_token if defined?(@auth_token) return @auth_token if defined?(@auth_token)
@auth_token = begin @auth_token =
if v0 = self.class.find_v0_auth_cookie(@request) begin
v0 if v0 = self.class.find_v0_auth_cookie(@request)
elsif v1 = self.class.find_v1_auth_cookie(@env) v0
if v1[:issued_at] >= SiteSetting.maximum_session_age.hours.ago.to_i elsif v1 = self.class.find_v1_auth_cookie(@env)
v1[:token] v1[:token] if v1[:issued_at] >= SiteSetting.maximum_session_age.hours.ago.to_i
end end
end end
end
end end
end end

View File

@ -2,35 +2,34 @@
class Auth::DiscordAuthenticator < Auth::ManagedAuthenticator class Auth::DiscordAuthenticator < Auth::ManagedAuthenticator
class DiscordStrategy < OmniAuth::Strategies::OAuth2 class DiscordStrategy < OmniAuth::Strategies::OAuth2
option :name, 'discord' option :name, "discord"
option :scope, 'identify email guilds' option :scope, "identify email guilds"
option :client_options, option :client_options,
site: 'https://discord.com/api', site: "https://discord.com/api",
authorize_url: 'oauth2/authorize', authorize_url: "oauth2/authorize",
token_url: 'oauth2/token' token_url: "oauth2/token"
option :authorize_options, %i[scope permissions] option :authorize_options, %i[scope permissions]
uid { raw_info['id'] } uid { raw_info["id"] }
info do info do
{ {
name: raw_info['username'], name: raw_info["username"],
email: raw_info['verified'] ? raw_info['email'] : nil, email: raw_info["verified"] ? raw_info["email"] : nil,
image: "https://cdn.discordapp.com/avatars/#{raw_info['id']}/#{raw_info['avatar']}" image: "https://cdn.discordapp.com/avatars/#{raw_info["id"]}/#{raw_info["avatar"]}",
} }
end end
extra do extra { { "raw_info" => raw_info } }
{
'raw_info' => raw_info
}
end
def raw_info def raw_info
@raw_info ||= access_token.get('users/@me').parsed. @raw_info ||=
merge(guilds: access_token.get('users/@me/guilds').parsed) access_token
.get("users/@me")
.parsed
.merge(guilds: access_token.get("users/@me/guilds").parsed)
end end
def callback_url def callback_url
@ -39,7 +38,7 @@ class Auth::DiscordAuthenticator < Auth::ManagedAuthenticator
end end
def name def name
'discord' "discord"
end end
def enabled? def enabled?
@ -48,23 +47,26 @@ class Auth::DiscordAuthenticator < Auth::ManagedAuthenticator
def register_middleware(omniauth) def register_middleware(omniauth)
omniauth.provider DiscordStrategy, omniauth.provider DiscordStrategy,
setup: lambda { |env| setup:
strategy = env["omniauth.strategy"] lambda { |env|
strategy.options[:client_id] = SiteSetting.discord_client_id strategy = env["omniauth.strategy"]
strategy.options[:client_secret] = SiteSetting.discord_secret strategy.options[:client_id] = SiteSetting.discord_client_id
} strategy.options[:client_secret] = SiteSetting.discord_secret
end }
end
def after_authenticate(auth_token, existing_account: nil) def after_authenticate(auth_token, existing_account: nil)
allowed_guild_ids = SiteSetting.discord_trusted_guilds.split("|") allowed_guild_ids = SiteSetting.discord_trusted_guilds.split("|")
if allowed_guild_ids.length > 0 if allowed_guild_ids.length > 0
user_guild_ids = auth_token.extra[:raw_info][:guilds].map { |g| g['id'] } user_guild_ids = auth_token.extra[:raw_info][:guilds].map { |g| g["id"] }
if (user_guild_ids & allowed_guild_ids).empty? # User is not in any allowed guilds if (user_guild_ids & allowed_guild_ids).empty? # User is not in any allowed guilds
return Auth::Result.new.tap do |auth_result| return(
auth_result.failed = true Auth::Result.new.tap do |auth_result|
auth_result.failed_reason = I18n.t("discord.not_in_allowed_guild") auth_result.failed = true
end auth_result.failed_reason = I18n.t("discord.not_in_allowed_guild")
end
)
end end
end end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::FacebookAuthenticator < Auth::ManagedAuthenticator class Auth::FacebookAuthenticator < Auth::ManagedAuthenticator
AVATAR_SIZE ||= 480 AVATAR_SIZE ||= 480
def name def name
@ -14,15 +13,19 @@ class Auth::FacebookAuthenticator < Auth::ManagedAuthenticator
def register_middleware(omniauth) def register_middleware(omniauth)
omniauth.provider :facebook, omniauth.provider :facebook,
setup: lambda { |env| setup:
strategy = env["omniauth.strategy"] lambda { |env|
strategy.options[:client_id] = SiteSetting.facebook_app_id strategy = env["omniauth.strategy"]
strategy.options[:client_secret] = SiteSetting.facebook_app_secret strategy.options[:client_id] = SiteSetting.facebook_app_id
strategy.options[:info_fields] = 'name,first_name,last_name,email' strategy.options[:client_secret] = SiteSetting.facebook_app_secret
strategy.options[:image_size] = { width: AVATAR_SIZE, height: AVATAR_SIZE } strategy.options[:info_fields] = "name,first_name,last_name,email"
strategy.options[:secure_image_url] = true strategy.options[:image_size] = {
}, width: AVATAR_SIZE,
scope: "email" height: AVATAR_SIZE,
}
strategy.options[:secure_image_url] = true
},
scope: "email"
end end
# facebook doesn't return unverified email addresses so it's safe to assume # facebook doesn't return unverified email addresses so it's safe to assume

View File

@ -1,9 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'has_errors' require "has_errors"
class Auth::GithubAuthenticator < Auth::ManagedAuthenticator class Auth::GithubAuthenticator < Auth::ManagedAuthenticator
def name def name
"github" "github"
end end
@ -50,12 +49,13 @@ class Auth::GithubAuthenticator < Auth::ManagedAuthenticator
def register_middleware(omniauth) def register_middleware(omniauth)
omniauth.provider :github, omniauth.provider :github,
setup: lambda { |env| setup:
strategy = env["omniauth.strategy"] lambda { |env|
strategy.options[:client_id] = SiteSetting.github_client_id strategy = env["omniauth.strategy"]
strategy.options[:client_secret] = SiteSetting.github_client_secret strategy.options[:client_id] = SiteSetting.github_client_id
}, strategy.options[:client_secret] = SiteSetting.github_client_secret
scope: "user:email" },
scope: "user:email"
end end
# the omniauth-github gem only picks up the primary email if it's verified: # the omniauth-github gem only picks up the primary email if it's verified:

View File

@ -22,47 +22,46 @@ class Auth::GoogleOAuth2Authenticator < Auth::ManagedAuthenticator
def register_middleware(omniauth) def register_middleware(omniauth)
options = { options = {
setup: lambda { |env| setup:
strategy = env["omniauth.strategy"] lambda do |env|
strategy.options[:client_id] = SiteSetting.google_oauth2_client_id strategy = env["omniauth.strategy"]
strategy.options[:client_secret] = SiteSetting.google_oauth2_client_secret strategy.options[:client_id] = SiteSetting.google_oauth2_client_id
strategy.options[:client_secret] = SiteSetting.google_oauth2_client_secret
if (google_oauth2_hd = SiteSetting.google_oauth2_hd).present? if (google_oauth2_hd = SiteSetting.google_oauth2_hd).present?
strategy.options[:hd] = google_oauth2_hd strategy.options[:hd] = google_oauth2_hd
end end
if (google_oauth2_prompt = SiteSetting.google_oauth2_prompt).present? if (google_oauth2_prompt = SiteSetting.google_oauth2_prompt).present?
strategy.options[:prompt] = google_oauth2_prompt.gsub("|", " ") strategy.options[:prompt] = google_oauth2_prompt.gsub("|", " ")
end end
# All the data we need for the `info` and `credentials` auth hash # All the data we need for the `info` and `credentials` auth hash
# are obtained via the user info API, not the JWT. Using and verifying # are obtained via the user info API, not the JWT. Using and verifying
# the JWT can fail due to clock skew, so let's skip it completely. # the JWT can fail due to clock skew, so let's skip it completely.
# https://github.com/zquestz/omniauth-google-oauth2/pull/392 # https://github.com/zquestz/omniauth-google-oauth2/pull/392
strategy.options[:skip_jwt] = true strategy.options[:skip_jwt] = true
} end,
} }
omniauth.provider :google_oauth2, options omniauth.provider :google_oauth2, options
end end
def after_authenticate(auth_token, existing_account: nil) def after_authenticate(auth_token, existing_account: nil)
groups = provides_groups? ? raw_groups(auth_token.uid) : nil groups = provides_groups? ? raw_groups(auth_token.uid) : nil
if groups auth_token.extra[:raw_groups] = groups if groups
auth_token.extra[:raw_groups] = groups
end
result = super result = super
if groups if groups
result.associated_groups = groups.map { |group| group.with_indifferent_access.slice(:id, :name) } result.associated_groups =
groups.map { |group| group.with_indifferent_access.slice(:id, :name) }
end end
result result
end end
def provides_groups? def provides_groups?
SiteSetting.google_oauth2_hd.present? && SiteSetting.google_oauth2_hd.present? && SiteSetting.google_oauth2_hd_groups &&
SiteSetting.google_oauth2_hd_groups &&
SiteSetting.google_oauth2_hd_groups_service_account_admin_email.present? && SiteSetting.google_oauth2_hd_groups_service_account_admin_email.present? &&
SiteSetting.google_oauth2_hd_groups_service_account_json.present? SiteSetting.google_oauth2_hd_groups_service_account_json.present?
end end
@ -77,20 +76,20 @@ class Auth::GoogleOAuth2Authenticator < Auth::ManagedAuthenticator
return if client.nil? return if client.nil?
loop do loop do
params = { params = { userKey: uid }
userKey: uid
}
params[:pageToken] = page_token if page_token params[:pageToken] = page_token if page_token
response = client.get(groups_url, params: params, raise_errors: false) response = client.get(groups_url, params: params, raise_errors: false)
if response.status == 200 if response.status == 200
response = response.parsed response = response.parsed
groups.push(*response['groups']) groups.push(*response["groups"])
page_token = response['nextPageToken'] page_token = response["nextPageToken"]
break if page_token.nil? break if page_token.nil?
else else
Rails.logger.error("[Discourse Google OAuth2] failed to retrieve groups for #{uid} - status #{response.status}") Rails.logger.error(
"[Discourse Google OAuth2] failed to retrieve groups for #{uid} - status #{response.status}",
)
break break
end end
end end
@ -107,26 +106,35 @@ class Auth::GoogleOAuth2Authenticator < Auth::ManagedAuthenticator
scope: GROUPS_SCOPE, scope: GROUPS_SCOPE,
iat: Time.now.to_i, iat: Time.now.to_i,
exp: Time.now.to_i + 60, exp: Time.now.to_i + 60,
sub: SiteSetting.google_oauth2_hd_groups_service_account_admin_email sub: SiteSetting.google_oauth2_hd_groups_service_account_admin_email,
} }
headers = { "alg" => "RS256", "typ" => "JWT" } headers = { "alg" => "RS256", "typ" => "JWT" }
key = OpenSSL::PKey::RSA.new(service_account_info["private_key"]) key = OpenSSL::PKey::RSA.new(service_account_info["private_key"])
encoded_jwt = ::JWT.encode(payload, key, 'RS256', headers) encoded_jwt = ::JWT.encode(payload, key, "RS256", headers)
client = OAuth2::Client.new( client =
SiteSetting.google_oauth2_client_id, OAuth2::Client.new(
SiteSetting.google_oauth2_client_secret, SiteSetting.google_oauth2_client_id,
site: OAUTH2_BASE_URL SiteSetting.google_oauth2_client_secret,
) site: OAUTH2_BASE_URL,
)
token_response = client.request(:post, '/token', body: { token_response =
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", client.request(
assertion: encoded_jwt :post,
}, raise_errors: false) "/token",
body: {
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: encoded_jwt,
},
raise_errors: false,
)
if token_response.status != 200 if token_response.status != 200
Rails.logger.error("[Discourse Google OAuth2] failed to retrieve group fetch token - status #{token_response.status}") Rails.logger.error(
"[Discourse Google OAuth2] failed to retrieve group fetch token - status #{token_response.status}",
)
return return
end end

View File

@ -56,28 +56,27 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
def after_authenticate(auth_token, existing_account: nil) def after_authenticate(auth_token, existing_account: nil)
# Try and find an association for this account # Try and find an association for this account
association = UserAssociatedAccount.find_or_initialize_by(provider_name: auth_token[:provider], provider_uid: auth_token[:uid]) association =
UserAssociatedAccount.find_or_initialize_by(
provider_name: auth_token[:provider],
provider_uid: auth_token[:uid],
)
# Reconnecting to existing account # Reconnecting to existing account
if can_connect_existing_user? && existing_account && (association.user.nil? || existing_account.id != association.user_id) if can_connect_existing_user? && existing_account &&
(association.user.nil? || existing_account.id != association.user_id)
association.user = existing_account association.user = existing_account
end end
# Matching an account by email # Matching an account by email
if match_by_email && if match_by_email && association.user.nil? && (user = find_user_by_email(auth_token))
association.user.nil? &&
(user = find_user_by_email(auth_token))
UserAssociatedAccount.where(user: user, provider_name: auth_token[:provider]).destroy_all # Destroy existing associations for the new user UserAssociatedAccount.where(user: user, provider_name: auth_token[:provider]).destroy_all # Destroy existing associations for the new user
association.user = user association.user = user
end end
# Matching an account by username # Matching an account by username
if match_by_username && if match_by_username && association.user.nil? && SiteSetting.username_change_period.zero? &&
association.user.nil? && (user = find_user_by_username(auth_token))
SiteSetting.username_change_period.zero? &&
(user = find_user_by_username(auth_token))
UserAssociatedAccount.where(user: user, provider_name: auth_token[:provider]).destroy_all # Destroy existing associations for the new user UserAssociatedAccount.where(user: user, provider_name: auth_token[:provider]).destroy_all # Destroy existing associations for the new user
association.user = user association.user = user
end end
@ -100,7 +99,14 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
result = Auth::Result.new result = Auth::Result.new
info = auth_token[:info] info = auth_token[:info]
result.email = info[:email] result.email = info[:email]
result.name = (info[:first_name] && info[:last_name]) ? "#{info[:first_name]} #{info[:last_name]}" : info[:name] result.name =
(
if (info[:first_name] && info[:last_name])
"#{info[:first_name]} #{info[:last_name]}"
else
info[:name]
end
)
if result.name.present? && result.name == result.email if result.name.present? && result.name == result.email
# Some IDPs send the email address in the name parameter (e.g. Auth0 with default configuration) # Some IDPs send the email address in the name parameter (e.g. Auth0 with default configuration)
# We add some generic protection here, so that users don't accidently make their email addresses public # We add some generic protection here, so that users don't accidently make their email addresses public
@ -109,10 +115,7 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
result.username = info[:nickname] result.username = info[:nickname]
result.email_valid = primary_email_verified?(auth_token) if result.email.present? result.email_valid = primary_email_verified?(auth_token) if result.email.present?
result.overrides_email = always_update_user_email? result.overrides_email = always_update_user_email?
result.extra_data = { result.extra_data = { provider: auth_token[:provider], uid: auth_token[:uid] }
provider: auth_token[:provider],
uid: auth_token[:uid]
}
result.user = association.user result.user = association.user
result result
@ -120,7 +123,11 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
def after_create_account(user, auth_result) def after_create_account(user, auth_result)
auth_token = auth_result[:extra_data] auth_token = auth_result[:extra_data]
association = UserAssociatedAccount.find_or_initialize_by(provider_name: auth_token[:provider], provider_uid: auth_token[:uid]) association =
UserAssociatedAccount.find_or_initialize_by(
provider_name: auth_token[:provider],
provider_uid: auth_token[:uid],
)
association.user = user association.user = user
association.save! association.save!
@ -132,16 +139,12 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
def find_user_by_email(auth_token) def find_user_by_email(auth_token)
email = auth_token.dig(:info, :email) email = auth_token.dig(:info, :email)
if email && primary_email_verified?(auth_token) User.find_by_email(email) if email && primary_email_verified?(auth_token)
User.find_by_email(email)
end
end end
def find_user_by_username(auth_token) def find_user_by_username(auth_token)
username = auth_token.dig(:info, :nickname) username = auth_token.dig(:info, :nickname)
if username User.find_by_username(username) if username
User.find_by_username(username)
end
end end
def retrieve_avatar(user, url) def retrieve_avatar(user, url)
@ -158,7 +161,7 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
if bio || location if bio || location
profile = user.user_profile profile = user.user_profile
profile.bio_raw = bio unless profile.bio_raw.present? profile.bio_raw = bio unless profile.bio_raw.present?
profile.location = location unless profile.location.present? profile.location = location unless profile.location.present?
profile.save profile.save
end end

View File

@ -1,48 +1,48 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::Result class Auth::Result
ATTRIBUTES = [ ATTRIBUTES = %i[
:user, user
:name, name
:username, username
:email, email
:email_valid, email_valid
:extra_data, extra_data
:awaiting_activation, awaiting_activation
:awaiting_approval, awaiting_approval
:authenticated, authenticated
:authenticator_name, authenticator_name
:requires_invite, requires_invite
:not_allowed_from_ip_address, not_allowed_from_ip_address
:admin_not_allowed_from_ip_address, admin_not_allowed_from_ip_address
:skip_email_validation, skip_email_validation
:destination_url, destination_url
:omniauth_disallow_totp, omniauth_disallow_totp
:failed, failed
:failed_reason, failed_reason
:failed_code, failed_code
:associated_groups, associated_groups
:overrides_email, overrides_email
:overrides_username, overrides_username
:overrides_name, overrides_name
] ]
attr_accessor *ATTRIBUTES attr_accessor *ATTRIBUTES
# These are stored in the session during # These are stored in the session during
# account creation. The user cannot read or modify them # account creation. The user cannot read or modify them
SESSION_ATTRIBUTES = [ SESSION_ATTRIBUTES = %i[
:email, email
:username, username
:email_valid, email_valid
:name, name
:authenticator_name, authenticator_name
:extra_data, extra_data
:skip_email_validation, skip_email_validation
:associated_groups, associated_groups
:overrides_email, overrides_email
:overrides_username, overrides_username
:overrides_name, overrides_name
] ]
def [](key) def [](key)
@ -59,9 +59,7 @@ class Auth::Result
end end
def email_valid=(val) def email_valid=(val)
if !val.in? [true, false, nil] raise ArgumentError, "email_valid should be boolean or nil" if !val.in? [true, false, nil]
raise ArgumentError, "email_valid should be boolean or nil"
end
@email_valid = !!val @email_valid = !!val
end end
@ -83,14 +81,14 @@ class Auth::Result
def apply_user_attributes! def apply_user_attributes!
change_made = false change_made = false
if (SiteSetting.auth_overrides_username? || overrides_username) && (resolved_username = resolve_username).present? if (SiteSetting.auth_overrides_username? || overrides_username) &&
(resolved_username = resolve_username).present?
change_made = UsernameChanger.override(user, resolved_username) change_made = UsernameChanger.override(user, resolved_username)
end end
if (SiteSetting.auth_overrides_email || overrides_email || user&.email&.ends_with?(".invalid")) && if (
email_valid && SiteSetting.auth_overrides_email || overrides_email || user&.email&.ends_with?(".invalid")
email.present? && ) && email_valid && email.present? && user.email != Email.downcase(email)
user.email != Email.downcase(email)
user.email = email user.email = email
change_made = true change_made = true
end end
@ -109,11 +107,12 @@ class Auth::Result
associated_groups.uniq.each do |associated_group| associated_groups.uniq.each do |associated_group|
begin begin
associated_group = AssociatedGroup.find_or_create_by( associated_group =
name: associated_group[:name], AssociatedGroup.find_or_create_by(
provider_id: associated_group[:id], name: associated_group[:name],
provider_name: extra_data[:provider] provider_id: associated_group[:id],
) provider_name: extra_data[:provider],
)
rescue ActiveRecord::RecordNotUnique rescue ActiveRecord::RecordNotUnique
retry retry
end end
@ -135,22 +134,12 @@ class Auth::Result
end end
def to_client_hash def to_client_hash
if requires_invite return { requires_invite: true } if requires_invite
return { requires_invite: true }
end
if user&.suspended? return { suspended: true, suspended_message: user.suspended_message } if user&.suspended?
return {
suspended: true,
suspended_message: user.suspended_message
}
end
if omniauth_disallow_totp if omniauth_disallow_totp
return { return { omniauth_disallow_totp: !!omniauth_disallow_totp, email: email }
omniauth_disallow_totp: !!omniauth_disallow_totp,
email: email
}
end end
if user if user
@ -159,7 +148,7 @@ class Auth::Result
awaiting_activation: !!awaiting_activation, awaiting_activation: !!awaiting_activation,
awaiting_approval: !!awaiting_approval, awaiting_approval: !!awaiting_approval,
not_allowed_from_ip_address: !!not_allowed_from_ip_address, not_allowed_from_ip_address: !!not_allowed_from_ip_address,
admin_not_allowed_from_ip_address: !!admin_not_allowed_from_ip_address admin_not_allowed_from_ip_address: !!admin_not_allowed_from_ip_address,
} }
result[:destination_url] = destination_url if authenticated && destination_url.present? result[:destination_url] = destination_url if authenticated && destination_url.present?
@ -173,7 +162,7 @@ class Auth::Result
auth_provider: authenticator_name, auth_provider: authenticator_name,
email_valid: !!email_valid, email_valid: !!email_valid,
can_edit_username: can_edit_username, can_edit_username: can_edit_username,
can_edit_name: can_edit_name can_edit_name: can_edit_name,
} }
result[:destination_url] = destination_url if destination_url.present? result[:destination_url] = destination_url if destination_url.present?
@ -190,9 +179,7 @@ class Auth::Result
def staged_user def staged_user
return @staged_user if defined?(@staged_user) return @staged_user if defined?(@staged_user)
if email.present? && email_valid @staged_user = User.where(staged: true).find_by_email(email) if email.present? && email_valid
@staged_user = User.where(staged: true).find_by_email(email)
end
end end
def username_suggester_attributes def username_suggester_attributes

View File

@ -17,11 +17,12 @@ class Auth::TwitterAuthenticator < Auth::ManagedAuthenticator
def register_middleware(omniauth) def register_middleware(omniauth)
omniauth.provider :twitter, omniauth.provider :twitter,
setup: lambda { |env| setup:
strategy = env["omniauth.strategy"] lambda { |env|
strategy.options[:consumer_key] = SiteSetting.twitter_consumer_key strategy = env["omniauth.strategy"]
strategy.options[:consumer_secret] = SiteSetting.twitter_consumer_secret strategy.options[:consumer_key] = SiteSetting.twitter_consumer_key
} strategy.options[:consumer_secret] = SiteSetting.twitter_consumer_secret
}
end end
# twitter doesn't return unverfied email addresses in the API # twitter doesn't return unverfied email addresses in the API

View File

@ -1,9 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module Autospec module Autospec
class BaseRunner class BaseRunner
# used when starting the runner - preloading happens here # used when starting the runner - preloading happens here
def start(opts = {}) def start(opts = {})
end end
@ -32,7 +30,5 @@ module Autospec
# used to stop the runner # used to stop the runner
def stop def stop
end end
end end
end end

View File

@ -3,11 +3,15 @@
require "rspec/core/formatters/base_text_formatter" require "rspec/core/formatters/base_text_formatter"
require "parallel_tests/rspec/logger_base" require "parallel_tests/rspec/logger_base"
module Autospec; end module Autospec
end
class Autospec::Formatter < RSpec::Core::Formatters::BaseTextFormatter class Autospec::Formatter < RSpec::Core::Formatters::BaseTextFormatter
RSpec::Core::Formatters.register self,
RSpec::Core::Formatters.register self, :example_passed, :example_pending, :example_failed, :start_dump :example_passed,
:example_pending,
:example_failed,
:start_dump
RSPEC_RESULT = "./tmp/rspec_result" RSPEC_RESULT = "./tmp/rspec_result"
@ -19,15 +23,15 @@ class Autospec::Formatter < RSpec::Core::Formatters::BaseTextFormatter
end end
def example_passed(_notification) def example_passed(_notification)
output.print RSpec::Core::Formatters::ConsoleCodes.wrap('.', :success) output.print RSpec::Core::Formatters::ConsoleCodes.wrap(".", :success)
end end
def example_pending(_notification) def example_pending(_notification)
output.print RSpec::Core::Formatters::ConsoleCodes.wrap('*', :pending) output.print RSpec::Core::Formatters::ConsoleCodes.wrap("*", :pending)
end end
def example_failed(notification) def example_failed(notification)
output.print RSpec::Core::Formatters::ConsoleCodes.wrap('F', :failure) output.print RSpec::Core::Formatters::ConsoleCodes.wrap("F", :failure)
@fail_file.puts(notification.example.location + " ") @fail_file.puts(notification.example.location + " ")
@fail_file.flush @fail_file.flush
end end
@ -40,5 +44,4 @@ class Autospec::Formatter < RSpec::Core::Formatters::BaseTextFormatter
@fail_file.close @fail_file.close
super(filename) super(filename)
end end
end end

View File

@ -7,7 +7,8 @@ require "autospec/reload_css"
require "autospec/base_runner" require "autospec/base_runner"
require "socket_server" require "socket_server"
module Autospec; end module Autospec
end
class Autospec::Manager class Autospec::Manager
def self.run(opts = {}) def self.run(opts = {})
@ -25,7 +26,10 @@ class Autospec::Manager
end end
def run def run
Signal.trap("HUP") { stop_runners; exit } Signal.trap("HUP") do
stop_runners
exit
end
Signal.trap("INT") do Signal.trap("INT") do
begin begin
@ -47,7 +51,6 @@ class Autospec::Manager
STDIN.gets STDIN.gets
process_queue process_queue
end end
rescue => e rescue => e
fail(e, "failed in run") fail(e, "failed in run")
ensure ensure
@ -71,16 +74,16 @@ class Autospec::Manager
@queue.reject! { |_, s, _| s == "spec" } @queue.reject! { |_, s, _| s == "spec" }
if current_runner @queue.concat [["spec", "spec", current_runner]] if current_runner
@queue.concat [['spec', 'spec', current_runner]]
end
@runners.each do |runner| @runners.each do |runner|
@queue.concat [['spec', 'spec', runner]] unless @queue.any? { |_, s, r| s == "spec" && r == runner } unless @queue.any? { |_, s, r| s == "spec" && r == runner }
@queue.concat [["spec", "spec", runner]]
end
end end
end end
[:start, :stop, :abort].each do |verb| %i[start stop abort].each do |verb|
define_method("#{verb}_runners") do define_method("#{verb}_runners") do
puts "@@@@@@@@@@@@ #{verb}_runners" if @debug puts "@@@@@@@@@@@@ #{verb}_runners" if @debug
@runners.each(&verb) @runners.each(&verb)
@ -89,11 +92,7 @@ class Autospec::Manager
def start_service_queue def start_service_queue
puts "@@@@@@@@@@@@ start_service_queue" if @debug puts "@@@@@@@@@@@@ start_service_queue" if @debug
Thread.new do Thread.new { thread_loop while true }
while true
thread_loop
end
end
end end
# the main loop, will run the specs in the queue till one fails or the queue is empty # the main loop, will run the specs in the queue till one fails or the queue is empty
@ -176,9 +175,7 @@ class Autospec::Manager
Dir[root_path + "/plugins/*"].each do |f| Dir[root_path + "/plugins/*"].each do |f|
next if !File.directory? f next if !File.directory? f
resolved = File.realpath(f) resolved = File.realpath(f)
if resolved != f map[resolved] = f if resolved != f
map[resolved] = f
end
end end
map map
end end
@ -188,9 +185,7 @@ class Autospec::Manager
resolved = file resolved = file
@reverse_map ||= reverse_symlink_map @reverse_map ||= reverse_symlink_map
@reverse_map.each do |location, discourse_location| @reverse_map.each do |location, discourse_location|
if file.start_with?(location) resolved = discourse_location + file[location.length..-1] if file.start_with?(location)
resolved = discourse_location + file[location.length..-1]
end
end end
resolved resolved
@ -199,9 +194,7 @@ class Autospec::Manager
def listen_for_changes def listen_for_changes
puts "@@@@@@@@@@@@ listen_for_changes" if @debug puts "@@@@@@@@@@@@ listen_for_changes" if @debug
options = { options = { ignore: %r{^lib/autospec} }
ignore: /^lib\/autospec/,
}
if @opts[:force_polling] if @opts[:force_polling]
options[:force_polling] = true options[:force_polling] = true
@ -210,14 +203,14 @@ class Autospec::Manager
path = root_path path = root_path
if ENV['VIM_AUTOSPEC'] if ENV["VIM_AUTOSPEC"]
STDERR.puts "Using VIM file listener" STDERR.puts "Using VIM file listener"
socket_path = (Rails.root + "tmp/file_change.sock").to_s socket_path = (Rails.root + "tmp/file_change.sock").to_s
FileUtils.rm_f(socket_path) FileUtils.rm_f(socket_path)
server = SocketServer.new(socket_path) server = SocketServer.new(socket_path)
server.start do |line| server.start do |line|
file, line = line.split(' ') file, line = line.split(" ")
file = reverse_symlink(file) file = reverse_symlink(file)
file = file.sub(Rails.root.to_s + "/", "") file = file.sub(Rails.root.to_s + "/", "")
# process_change can acquire a mutex and block # process_change can acquire a mutex and block
@ -235,20 +228,20 @@ class Autospec::Manager
end end
# to speed up boot we use a thread # to speed up boot we use a thread
["spec", "lib", "app", "config", "test", "vendor", "plugins"].each do |watch| %w[spec lib app config test vendor plugins].each do |watch|
puts "@@@@@@@@@ Listen to #{path}/#{watch} #{options}" if @debug puts "@@@@@@@@@ Listen to #{path}/#{watch} #{options}" if @debug
Thread.new do Thread.new do
begin begin
listener = Listen.to("#{path}/#{watch}", options) do |modified, added, _| listener =
paths = [modified, added].flatten Listen.to("#{path}/#{watch}", options) do |modified, added, _|
paths.compact! paths = [modified, added].flatten
paths.map! do |long| paths.compact!
long = reverse_symlink(long) paths.map! do |long|
long[(path.length + 1)..-1] long = reverse_symlink(long)
long[(path.length + 1)..-1]
end
process_change(paths)
end end
process_change(paths)
end
listener.start listener.start
sleep sleep
rescue => e rescue => e
@ -257,7 +250,6 @@ class Autospec::Manager
end end
end end
end end
end end
def process_change(files) def process_change(files)
@ -285,13 +277,9 @@ class Autospec::Manager
hit = true hit = true
spec = v ? (v.arity == 1 ? v.call(m) : v.call) : file spec = v ? (v.arity == 1 ? v.call(m) : v.call) : file
with_line = spec with_line = spec
if spec == file && line with_line = spec + ":" << line.to_s if spec == file && line
with_line = spec + ":" << line.to_s
end
if File.exist?(spec) || Dir.exist?(spec) if File.exist?(spec) || Dir.exist?(spec)
if with_line != spec specs << [file, spec, runner] if with_line != spec
specs << [file, spec, runner]
end
specs << [file, with_line, runner] specs << [file, with_line, runner]
end end
end end
@ -329,9 +317,7 @@ class Autospec::Manager
focus = @queue.shift focus = @queue.shift
@queue.unshift([file, spec, runner]) @queue.unshift([file, spec, runner])
unless spec.include?(":") && focus[1].include?(spec.split(":")[0]) unless spec.include?(":") && focus[1].include?(spec.split(":")[0])
if focus[1].include?(spec) || file != spec @queue.unshift(focus) if focus[1].include?(spec) || file != spec
@queue.unshift(focus)
end
end end
else else
@queue.unshift([file, spec, runner]) @queue.unshift([file, spec, runner])

View File

@ -1,9 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
module Autospec; end module Autospec
end
class Autospec::ReloadCss class Autospec::ReloadCss
WATCHERS = {} WATCHERS = {}
def self.watch(pattern, &blk) def self.watch(pattern, &blk)
WATCHERS[pattern] = blk WATCHERS[pattern] = blk
@ -30,7 +30,7 @@ class Autospec::ReloadCss
if paths.any? { |p| p =~ /\.(css|s[ac]ss)/ } if paths.any? { |p| p =~ /\.(css|s[ac]ss)/ }
# todo connect to dev instead? # todo connect to dev instead?
ActiveRecord::Base.establish_connection ActiveRecord::Base.establish_connection
[:desktop, :mobile].each do |style| %i[desktop mobile].each do |style|
s = DiscourseStylesheets.new(style) s = DiscourseStylesheets.new(style)
s.compile s.compile
paths << "public" + s.stylesheet_relpath_no_digest paths << "public" + s.stylesheet_relpath_no_digest
@ -44,10 +44,9 @@ class Autospec::ReloadCss
p = p.sub(/\.sass\.erb/, "") p = p.sub(/\.sass\.erb/, "")
p = p.sub(/\.sass/, "") p = p.sub(/\.sass/, "")
p = p.sub(/\.scss/, "") p = p.sub(/\.scss/, "")
p = p.sub(/^app\/assets\/stylesheets/, "assets") p = p.sub(%r{^app/assets/stylesheets}, "assets")
{ name: p, hash: hash || SecureRandom.hex } { name: p, hash: hash || SecureRandom.hex }
end end
message_bus.publish "/file-change", paths message_bus.publish "/file-change", paths
end end
end end

View File

@ -1,9 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
module Autospec module Autospec
class RspecRunner < BaseRunner class RspecRunner < BaseRunner
WATCHERS = {} WATCHERS = {}
def self.watch(pattern, &blk) def self.watch(pattern, &blk)
WATCHERS[pattern] = blk WATCHERS[pattern] = blk
@ -13,26 +11,28 @@ module Autospec
end end
# Discourse specific # Discourse specific
watch(%r{^lib/(.+)\.rb$}) { |m| "spec/components/#{m[1]}_spec.rb" } watch(%r{^lib/(.+)\.rb$}) { |m| "spec/components/#{m[1]}_spec.rb" }
watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
watch(%r{^app/(.+)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } watch(%r{^app/(.+)(\.erb|\.haml)$}) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
watch(%r{^spec/.+_spec\.rb$}) watch(%r{^spec/.+_spec\.rb$})
watch(%r{^spec/support/.+\.rb$}) { "spec" } watch(%r{^spec/support/.+\.rb$}) { "spec" }
watch("app/controllers/application_controller.rb") { "spec/requests" } watch("app/controllers/application_controller.rb") { "spec/requests" }
watch(%r{app/controllers/(.+).rb}) { |m| "spec/requests/#{m[1]}_spec.rb" } watch(%r{app/controllers/(.+).rb}) { |m| "spec/requests/#{m[1]}_spec.rb" }
watch(%r{^app/views/(.+)/.+\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" } watch(%r{^app/views/(.+)/.+\.(erb|haml)$}) { |m| "spec/requests/#{m[1]}_spec.rb" }
watch(%r{^spec/fabricators/.+_fabricator\.rb$}) { "spec" } watch(%r{^spec/fabricators/.+_fabricator\.rb$}) { "spec" }
watch(%r{^app/assets/javascripts/pretty-text/.*\.js\.es6$}) { "spec/components/pretty_text_spec.rb" } watch(%r{^app/assets/javascripts/pretty-text/.*\.js\.es6$}) do
"spec/components/pretty_text_spec.rb"
end
watch(%r{^plugins/.*/discourse-markdown/.*\.js\.es6$}) { "spec/components/pretty_text_spec.rb" } watch(%r{^plugins/.*/discourse-markdown/.*\.js\.es6$}) { "spec/components/pretty_text_spec.rb" }
watch(%r{^plugins/.*/spec/.*\.rb}) watch(%r{^plugins/.*/spec/.*\.rb})
watch(%r{^(plugins/.*/)plugin\.rb}) { |m| "#{m[1]}spec" } watch(%r{^(plugins/.*/)plugin\.rb}) { |m| "#{m[1]}spec" }
watch(%r{^(plugins/.*)/(lib|app)}) { |m| "#{m[1]}/spec/integration" } watch(%r{^(plugins/.*)/(lib|app)}) { |m| "#{m[1]}/spec/integration" }
watch(%r{^(plugins/.*)/lib/(.*)\.rb}) { |m| "#{m[1]}/spec/lib/#{m[2]}_spec.rb" } watch(%r{^(plugins/.*)/lib/(.*)\.rb}) { |m| "#{m[1]}/spec/lib/#{m[2]}_spec.rb" }
RELOADERS = Set.new RELOADERS = Set.new
@ -50,11 +50,9 @@ module Autospec
def failed_specs def failed_specs
specs = [] specs = []
path = './tmp/rspec_result' path = "./tmp/rspec_result"
specs = File.readlines(path) if File.exist?(path) specs = File.readlines(path) if File.exist?(path)
specs specs
end end
end end
end end

View File

@ -3,7 +3,6 @@
require "autospec/rspec_runner" require "autospec/rspec_runner"
module Autospec module Autospec
class SimpleRunner < RspecRunner class SimpleRunner < RspecRunner
def initialize def initialize
@mutex = Mutex.new @mutex = Mutex.new
@ -12,36 +11,29 @@ module Autospec
def run(specs) def run(specs)
puts "Running Rspec: #{specs}" puts "Running Rspec: #{specs}"
# kill previous rspec instance # kill previous rspec instance
@mutex.synchronize do @mutex.synchronize { self.abort }
self.abort
end
# we use our custom rspec formatter # we use our custom rspec formatter
args = [ args = ["-r", "#{File.dirname(__FILE__)}/formatter.rb", "-f", "Autospec::Formatter"]
"-r", "#{File.dirname(__FILE__)}/formatter.rb",
"-f", "Autospec::Formatter"
]
command = begin command =
line_specified = specs.split.any? { |s| s =~ /\:/ } # Parallel spec can't run specific line begin
multiple_files = specs.split.count > 1 || specs == "spec" # Only parallelize multiple files line_specified = specs.split.any? { |s| s =~ /\:/ } # Parallel spec can't run specific line
if ENV["PARALLEL_SPEC"] == '1' && multiple_files && !line_specified multiple_files = specs.split.count > 1 || specs == "spec" # Only parallelize multiple files
"bin/turbo_rspec #{args.join(" ")} #{specs.split.join(" ")}" if ENV["PARALLEL_SPEC"] == "1" && multiple_files && !line_specified
else "bin/turbo_rspec #{args.join(" ")} #{specs.split.join(" ")}"
"bin/rspec #{args.join(" ")} #{specs.split.join(" ")}" else
"bin/rspec #{args.join(" ")} #{specs.split.join(" ")}"
end
end end
end
# launch rspec # launch rspec
Dir.chdir(Rails.root) do # rubocop:disable Discourse/NoChdir because this is not part of the app Dir.chdir(Rails.root) do # rubocop:disable Discourse/NoChdir because this is not part of the app
env = { "RAILS_ENV" => "test" } env = { "RAILS_ENV" => "test" }
if specs.split(' ').any? { |s| s =~ /^(.\/)?plugins/ } if specs.split(" ").any? { |s| s =~ %r{^(./)?plugins} }
env["LOAD_PLUGINS"] = "1" env["LOAD_PLUGINS"] = "1"
puts "Loading plugins while running specs" puts "Loading plugins while running specs"
end end
pid = pid = @mutex.synchronize { @pid = Process.spawn(env, command) }
@mutex.synchronize do
@pid = Process.spawn(env, command)
end
_, status = Process.wait2(pid) _, status = Process.wait2(pid)
@ -51,7 +43,11 @@ module Autospec
def abort def abort
if pid = @pid if pid = @pid
Process.kill("TERM", pid) rescue nil begin
Process.kill("TERM", pid)
rescue StandardError
nil
end
wait_for_done(pid) wait_for_done(pid)
pid = nil pid = nil
end end
@ -66,16 +62,26 @@ module Autospec
def wait_for_done(pid) def wait_for_done(pid)
i = 3000 i = 3000
while (i > 0 && Process.getpgid(pid) rescue nil) while (
begin
i > 0 && Process.getpgid(pid)
rescue StandardError
nil
end
)
sleep 0.001 sleep 0.001
i -= 1 i -= 1
end end
if (Process.getpgid(pid) rescue nil) if (
begin
Process.getpgid(pid)
rescue StandardError
nil
end
)
STDERR.puts "Terminating rspec #{pid} by force cause it refused graceful termination" STDERR.puts "Terminating rspec #{pid} by force cause it refused graceful termination"
Process.kill("KILL", pid) Process.kill("KILL", pid)
end end
end end
end end
end end

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module BackupRestore module BackupRestore
class OperationRunningError < RuntimeError
class OperationRunningError < RuntimeError; end end
VERSION_PREFIX = "v" VERSION_PREFIX = "v"
DUMP_FILE = "dump.sql.gz" DUMP_FILE = "dump.sql.gz"
@ -22,9 +22,7 @@ module BackupRestore
def self.rollback! def self.rollback!
raise BackupRestore::OperationRunningError if BackupRestore.is_operation_running? raise BackupRestore::OperationRunningError if BackupRestore.is_operation_running?
if can_rollback? move_tables_between_schemas("backup", "public") if can_rollback?
move_tables_between_schemas("backup", "public")
end
end end
def self.cancel! def self.cancel!
@ -58,7 +56,7 @@ module BackupRestore
{ {
is_operation_running: is_operation_running?, is_operation_running: is_operation_running?,
can_rollback: can_rollback?, can_rollback: can_rollback?,
allow_restore: Rails.env.development? || SiteSetting.allow_restore allow_restore: Rails.env.development? || SiteSetting.allow_restore,
} }
end end
@ -133,7 +131,7 @@ module BackupRestore
config["backup_port"] || config["port"], config["backup_port"] || config["port"],
config["username"] || username || ENV["USER"] || "postgres", config["username"] || username || ENV["USER"] || "postgres",
config["password"] || password, config["password"] || password,
config["database"] config["database"],
) )
end end
@ -194,7 +192,11 @@ module BackupRestore
end end
def self.backup_tables_count def self.backup_tables_count
DB.query_single("SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = 'backup'").first.to_i DB
.query_single(
"SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = 'backup'",
)
.first
.to_i
end end
end end

View File

@ -69,15 +69,22 @@ module BackupRestore
path_transformation = path_transformation =
case tar_implementation case tar_implementation
when :gnu when :gnu
['--transform', 's|var/www/discourse/public/uploads/|uploads/|'] %w[--transform s|var/www/discourse/public/uploads/|uploads/|]
when :bsd when :bsd
['-s', '|var/www/discourse/public/uploads/|uploads/|'] %w[-s |var/www/discourse/public/uploads/|uploads/|]
end end
log "Unzipping archive, this may take a while..." log "Unzipping archive, this may take a while..."
Discourse::Utils.execute_command( Discourse::Utils.execute_command(
'tar', '--extract', '--gzip', '--file', @archive_path, '--directory', @tmp_directory, "tar",
*path_transformation, failure_message: "Failed to decompress archive." "--extract",
"--gzip",
"--file",
@archive_path,
"--directory",
@tmp_directory,
*path_transformation,
failure_message: "Failed to decompress archive.",
) )
end end
@ -86,15 +93,19 @@ module BackupRestore
if @is_archive if @is_archive
# for compatibility with backups from Discourse v1.5 and below # for compatibility with backups from Discourse v1.5 and below
old_dump_path = File.join(@tmp_directory, OLD_DUMP_FILENAME) old_dump_path = File.join(@tmp_directory, OLD_DUMP_FILENAME)
File.exist?(old_dump_path) ? old_dump_path : File.join(@tmp_directory, BackupRestore::DUMP_FILE) if File.exist?(old_dump_path)
old_dump_path
else
File.join(@tmp_directory, BackupRestore::DUMP_FILE)
end
else else
File.join(@tmp_directory, @filename) File.join(@tmp_directory, @filename)
end end
if File.extname(@db_dump_path) == '.gz' if File.extname(@db_dump_path) == ".gz"
log "Extracting dump file..." log "Extracting dump file..."
Compression::Gzip.new.decompress(@tmp_directory, @db_dump_path, available_size) Compression::Gzip.new.decompress(@tmp_directory, @db_dump_path, available_size)
@db_dump_path.delete_suffix!('.gz') @db_dump_path.delete_suffix!(".gz")
end end
@db_dump_path @db_dump_path
@ -105,17 +116,18 @@ module BackupRestore
end end
def tar_implementation def tar_implementation
@tar_version ||= begin @tar_version ||=
tar_version = Discourse::Utils.execute_command('tar', '--version') begin
tar_version = Discourse::Utils.execute_command("tar", "--version")
if tar_version.include?("GNU tar") if tar_version.include?("GNU tar")
:gnu :gnu
elsif tar_version.include?("bsdtar") elsif tar_version.include?("bsdtar")
:bsd :bsd
else else
raise "Unknown tar implementation: #{tar_version}" raise "Unknown tar implementation: #{tar_version}"
end
end end
end
end end
end end
end end

View File

@ -37,9 +37,7 @@ module BackupRestore
return unless cleanup_allowed? return unless cleanup_allowed?
return if (backup_files = files).size <= SiteSetting.maximum_backups return if (backup_files = files).size <= SiteSetting.maximum_backups
backup_files[SiteSetting.maximum_backups..-1].each do |file| backup_files[SiteSetting.maximum_backups..-1].each { |file| delete_file(file.filename) }
delete_file(file.filename)
end
reset_cache reset_cache
end end
@ -74,7 +72,7 @@ module BackupRestore
used_bytes: used_bytes, used_bytes: used_bytes,
free_bytes: free_bytes, free_bytes: free_bytes,
count: files.size, count: files.size,
last_backup_taken_at: latest_file&.last_modified last_backup_taken_at: latest_file&.last_modified,
} }
end end

View File

@ -4,7 +4,6 @@ require "mini_mime"
require "file_store/s3_store" require "file_store/s3_store"
module BackupRestore module BackupRestore
class Backuper class Backuper
attr_reader :success attr_reader :success
@ -84,7 +83,11 @@ module BackupRestore
@dump_filename = File.join(@tmp_directory, BackupRestore::DUMP_FILE) @dump_filename = File.join(@tmp_directory, BackupRestore::DUMP_FILE)
@archive_directory = BackupRestore::LocalBackupStore.base_directory(db: @current_db) @archive_directory = BackupRestore::LocalBackupStore.base_directory(db: @current_db)
filename = @filename_override || "#{get_parameterized_title}-#{@timestamp}" filename = @filename_override || "#{get_parameterized_title}-#{@timestamp}"
@archive_basename = File.join(@archive_directory, "#{filename}-#{BackupRestore::VERSION_PREFIX}#{BackupRestore.current_version}") @archive_basename =
File.join(
@archive_directory,
"#{filename}-#{BackupRestore::VERSION_PREFIX}#{BackupRestore.current_version}",
)
@backup_filename = @backup_filename =
if @with_uploads if @with_uploads
@ -119,9 +122,18 @@ module BackupRestore
BackupMetadata.delete_all BackupMetadata.delete_all
BackupMetadata.create!(name: "base_url", value: Discourse.base_url) BackupMetadata.create!(name: "base_url", value: Discourse.base_url)
BackupMetadata.create!(name: "cdn_url", value: Discourse.asset_host) BackupMetadata.create!(name: "cdn_url", value: Discourse.asset_host)
BackupMetadata.create!(name: "s3_base_url", value: SiteSetting.Upload.enable_s3_uploads ? SiteSetting.Upload.s3_base_url : nil) BackupMetadata.create!(
BackupMetadata.create!(name: "s3_cdn_url", value: SiteSetting.Upload.enable_s3_uploads ? SiteSetting.Upload.s3_cdn_url : nil) name: "s3_base_url",
BackupMetadata.create!(name: "db_name", value: RailsMultisite::ConnectionManagement.current_db) value: SiteSetting.Upload.enable_s3_uploads ? SiteSetting.Upload.s3_base_url : nil,
)
BackupMetadata.create!(
name: "s3_cdn_url",
value: SiteSetting.Upload.enable_s3_uploads ? SiteSetting.Upload.s3_cdn_url : nil,
)
BackupMetadata.create!(
name: "db_name",
value: RailsMultisite::ConnectionManagement.current_db,
)
BackupMetadata.create!(name: "multisite", value: Rails.configuration.multisite) BackupMetadata.create!(name: "multisite", value: Rails.configuration.multisite)
end end
@ -132,7 +144,7 @@ module BackupRestore
pg_dump_running = true pg_dump_running = true
Thread.new do Thread.new do
RailsMultisite::ConnectionManagement::establish_connection(db: @current_db) RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
while pg_dump_running while pg_dump_running
message = logs.pop.strip message = logs.pop.strip
log(message) unless message.blank? log(message) unless message.blank?
@ -159,23 +171,24 @@ module BackupRestore
db_conf = BackupRestore.database_configuration db_conf = BackupRestore.database_configuration
password_argument = "PGPASSWORD='#{db_conf.password}'" if db_conf.password.present? password_argument = "PGPASSWORD='#{db_conf.password}'" if db_conf.password.present?
host_argument = "--host=#{db_conf.host}" if db_conf.host.present? host_argument = "--host=#{db_conf.host}" if db_conf.host.present?
port_argument = "--port=#{db_conf.port}" if db_conf.port.present? port_argument = "--port=#{db_conf.port}" if db_conf.port.present?
username_argument = "--username=#{db_conf.username}" if db_conf.username.present? username_argument = "--username=#{db_conf.username}" if db_conf.username.present?
[ password_argument, # pass the password to pg_dump (if any) [
"pg_dump", # the pg_dump command password_argument, # pass the password to pg_dump (if any)
"--schema=public", # only public schema "pg_dump", # the pg_dump command
"-T public.pg_*", # exclude tables and views whose name starts with "pg_" "--schema=public", # only public schema
"-T public.pg_*", # exclude tables and views whose name starts with "pg_"
"--file='#{@dump_filename}'", # output to the dump.sql file "--file='#{@dump_filename}'", # output to the dump.sql file
"--no-owner", # do not output commands to set ownership of objects "--no-owner", # do not output commands to set ownership of objects
"--no-privileges", # prevent dumping of access privileges "--no-privileges", # prevent dumping of access privileges
"--verbose", # specifies verbose mode "--verbose", # specifies verbose mode
"--compress=4", # Compression level of 4 "--compress=4", # Compression level of 4
host_argument, # the hostname to connect to (if any) host_argument, # the hostname to connect to (if any)
port_argument, # the port to connect to (if any) port_argument, # the port to connect to (if any)
username_argument, # the username to connect as (if any) username_argument, # the username to connect as (if any)
db_conf.database # the name of the database to dump db_conf.database, # the name of the database to dump
].join(" ") ].join(" ")
end end
@ -185,8 +198,10 @@ module BackupRestore
archive_filename = File.join(@archive_directory, @backup_filename) archive_filename = File.join(@archive_directory, @backup_filename)
Discourse::Utils.execute_command( Discourse::Utils.execute_command(
'mv', @dump_filename, archive_filename, "mv",
failure_message: "Failed to move database dump file." @dump_filename,
archive_filename,
failure_message: "Failed to move database dump file.",
) )
remove_tmp_directory remove_tmp_directory
@ -198,17 +213,29 @@ module BackupRestore
tar_filename = "#{@archive_basename}.tar" tar_filename = "#{@archive_basename}.tar"
log "Making sure archive does not already exist..." log "Making sure archive does not already exist..."
Discourse::Utils.execute_command('rm', '-f', tar_filename) Discourse::Utils.execute_command("rm", "-f", tar_filename)
Discourse::Utils.execute_command('rm', '-f', "#{tar_filename}.gz") Discourse::Utils.execute_command("rm", "-f", "#{tar_filename}.gz")
log "Creating empty archive..." log "Creating empty archive..."
Discourse::Utils.execute_command('tar', '--create', '--file', tar_filename, '--files-from', '/dev/null') Discourse::Utils.execute_command(
"tar",
"--create",
"--file",
tar_filename,
"--files-from",
"/dev/null",
)
log "Archiving data dump..." log "Archiving data dump..."
Discourse::Utils.execute_command( Discourse::Utils.execute_command(
'tar', '--append', '--dereference', '--file', tar_filename, File.basename(@dump_filename), "tar",
"--append",
"--dereference",
"--file",
tar_filename,
File.basename(@dump_filename),
failure_message: "Failed to archive data dump.", failure_message: "Failed to archive data dump.",
chdir: File.dirname(@dump_filename) chdir: File.dirname(@dump_filename),
) )
add_local_uploads_to_archive(tar_filename) add_local_uploads_to_archive(tar_filename)
@ -218,8 +245,10 @@ module BackupRestore
log "Gzipping archive, this may take a while..." log "Gzipping archive, this may take a while..."
Discourse::Utils.execute_command( Discourse::Utils.execute_command(
'gzip', "-#{SiteSetting.backup_gzip_compression_level_for_uploads}", tar_filename, "gzip",
failure_message: "Failed to gzip archive." "-#{SiteSetting.backup_gzip_compression_level_for_uploads}",
tar_filename,
failure_message: "Failed to gzip archive.",
) )
end end
@ -244,14 +273,21 @@ module BackupRestore
if SiteSetting.include_thumbnails_in_backups if SiteSetting.include_thumbnails_in_backups
exclude_optimized = "" exclude_optimized = ""
else else
optimized_path = File.join(upload_directory, 'optimized') optimized_path = File.join(upload_directory, "optimized")
exclude_optimized = "--exclude=#{optimized_path}" exclude_optimized = "--exclude=#{optimized_path}"
end end
Discourse::Utils.execute_command( Discourse::Utils.execute_command(
'tar', '--append', '--dereference', exclude_optimized, '--file', tar_filename, upload_directory, "tar",
failure_message: "Failed to archive uploads.", success_status_codes: [0, 1], "--append",
chdir: File.join(Rails.root, "public") "--dereference",
exclude_optimized,
"--file",
tar_filename,
upload_directory,
failure_message: "Failed to archive uploads.",
success_status_codes: [0, 1],
chdir: File.join(Rails.root, "public"),
) )
else else
log "No local uploads found. Skipping archiving of local uploads..." log "No local uploads found. Skipping archiving of local uploads..."
@ -287,9 +323,14 @@ module BackupRestore
log "Appending uploads to archive..." log "Appending uploads to archive..."
Discourse::Utils.execute_command( Discourse::Utils.execute_command(
'tar', '--append', '--file', tar_filename, upload_directory, "tar",
failure_message: "Failed to append uploads to archive.", success_status_codes: [0, 1], "--append",
chdir: @tmp_directory "--file",
tar_filename,
upload_directory,
failure_message: "Failed to append uploads to archive.",
success_status_codes: [0, 1],
chdir: @tmp_directory,
) )
log "No uploads found on S3. Skipping archiving of uploads stored on S3..." if count == 0 log "No uploads found on S3. Skipping archiving of uploads stored on S3..." if count == 0
@ -327,9 +368,7 @@ module BackupRestore
logs = Discourse::Utils.logs_markdown(@logs, user: @user) logs = Discourse::Utils.logs_markdown(@logs, user: @user)
post = SystemMessage.create_from_system_user(@user, status, logs: logs) post = SystemMessage.create_from_system_user(@user, status, logs: logs)
if @user.id == Discourse::SYSTEM_USER_ID post.topic.invite_group(@user, Group[:admins]) if @user.id == Discourse::SYSTEM_USER_ID
post.topic.invite_group(@user, Group[:admins])
end
rescue => ex rescue => ex
log "Something went wrong while notifying user.", ex log "Something went wrong while notifying user.", ex
end end
@ -399,7 +438,12 @@ module BackupRestore
def publish_log(message, timestamp) def publish_log(message, timestamp)
return unless @publish_to_message_bus return unless @publish_to_message_bus
data = { timestamp: timestamp, operation: "backup", message: message } data = { timestamp: timestamp, operation: "backup", message: message }
MessageBus.publish(BackupRestore::LOGS_CHANNEL, data, user_ids: [@user_id], client_ids: [@client_id]) MessageBus.publish(
BackupRestore::LOGS_CHANNEL,
data,
user_ids: [@user_id],
client_ids: [@client_id],
)
end end
def save_log(message, timestamp) def save_log(message, timestamp)
@ -416,5 +460,4 @@ module BackupRestore
end end
end end
end end
end end

View File

@ -46,9 +46,7 @@ module BackupRestore
end end
def self.drop_backup_schema def self.drop_backup_schema
if backup_schema_dropable? ActiveRecord::Base.connection.drop_schema(BACKUP_SCHEMA) if backup_schema_dropable?
ActiveRecord::Base.connection.drop_schema(BACKUP_SCHEMA)
end
end end
def self.core_migration_files def self.core_migration_files
@ -65,13 +63,14 @@ module BackupRestore
last_line = nil last_line = nil
psql_running = true psql_running = true
log_thread = Thread.new do log_thread =
RailsMultisite::ConnectionManagement::establish_connection(db: @current_db) Thread.new do
while psql_running || !logs.empty? RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
message = logs.pop.strip while psql_running || !logs.empty?
log(message) if message.present? message = logs.pop.strip
log(message) if message.present?
end
end end
end
IO.popen(restore_dump_command) do |pipe| IO.popen(restore_dump_command) do |pipe|
begin begin
@ -89,7 +88,9 @@ module BackupRestore
logs << "" logs << ""
log_thread.join log_thread.join
raise DatabaseRestoreError.new("psql failed: #{last_line}") if Process.last_status&.exitstatus != 0 if Process.last_status&.exitstatus != 0
raise DatabaseRestoreError.new("psql failed: #{last_line}")
end
end end
# Removes unwanted SQL added by certain versions of pg_dump and modifies # Removes unwanted SQL added by certain versions of pg_dump and modifies
@ -99,7 +100,7 @@ module BackupRestore
"DROP SCHEMA", # Discourse <= v1.5 "DROP SCHEMA", # Discourse <= v1.5
"CREATE SCHEMA", # PostgreSQL 11+ "CREATE SCHEMA", # PostgreSQL 11+
"COMMENT ON SCHEMA", # PostgreSQL 11+ "COMMENT ON SCHEMA", # PostgreSQL 11+
"SET default_table_access_method" # PostgreSQL 12 "SET default_table_access_method", # PostgreSQL 12
].join("|") ].join("|")
command = "sed -E '/^(#{unwanted_sql})/d' #{@db_dump_path}" command = "sed -E '/^(#{unwanted_sql})/d' #{@db_dump_path}"
@ -117,18 +118,19 @@ module BackupRestore
db_conf = BackupRestore.database_configuration db_conf = BackupRestore.database_configuration
password_argument = "PGPASSWORD='#{db_conf.password}'" if db_conf.password.present? password_argument = "PGPASSWORD='#{db_conf.password}'" if db_conf.password.present?
host_argument = "--host=#{db_conf.host}" if db_conf.host.present? host_argument = "--host=#{db_conf.host}" if db_conf.host.present?
port_argument = "--port=#{db_conf.port}" if db_conf.port.present? port_argument = "--port=#{db_conf.port}" if db_conf.port.present?
username_argument = "--username=#{db_conf.username}" if db_conf.username.present? username_argument = "--username=#{db_conf.username}" if db_conf.username.present?
[ password_argument, # pass the password to psql (if any) [
"psql", # the psql command password_argument, # pass the password to psql (if any)
"psql", # the psql command
"--dbname='#{db_conf.database}'", # connect to database *dbname* "--dbname='#{db_conf.database}'", # connect to database *dbname*
"--single-transaction", # all or nothing (also runs COPY commands faster) "--single-transaction", # all or nothing (also runs COPY commands faster)
"--variable=ON_ERROR_STOP=1", # stop on first error "--variable=ON_ERROR_STOP=1", # stop on first error
host_argument, # the hostname to connect to (if any) host_argument, # the hostname to connect to (if any)
port_argument, # the port to connect to (if any) port_argument, # the port to connect to (if any)
username_argument # the username to connect as (if any) username_argument, # the username to connect as (if any)
].compact.join(" ") ].compact.join(" ")
end end
@ -136,21 +138,22 @@ module BackupRestore
log "Migrating the database..." log "Migrating the database..."
log Discourse::Utils.execute_command( log Discourse::Utils.execute_command(
{ {
"SKIP_POST_DEPLOYMENT_MIGRATIONS" => "0", "SKIP_POST_DEPLOYMENT_MIGRATIONS" => "0",
"SKIP_OPTIMIZE_ICONS" => "1", "SKIP_OPTIMIZE_ICONS" => "1",
"DISABLE_TRANSLATION_OVERRIDES" => "1" "DISABLE_TRANSLATION_OVERRIDES" => "1",
}, },
"rake", "db:migrate", "rake",
failure_message: "Failed to migrate database.", "db:migrate",
chdir: Rails.root failure_message: "Failed to migrate database.",
) chdir: Rails.root,
)
end end
def reconnect_database def reconnect_database
log "Reconnecting to the database..." log "Reconnecting to the database..."
RailsMultisite::ConnectionManagement::reload if RailsMultisite::ConnectionManagement::instance RailsMultisite::ConnectionManagement.reload if RailsMultisite::ConnectionManagement.instance
RailsMultisite::ConnectionManagement::establish_connection(db: @current_db) RailsMultisite::ConnectionManagement.establish_connection(db: @current_db)
end end
def create_missing_discourse_functions def create_missing_discourse_functions
@ -179,10 +182,12 @@ module BackupRestore
end end
end end
existing_function_names = Migration::BaseDropper.existing_discourse_function_names.map { |name| "#{name}()" } existing_function_names =
Migration::BaseDropper.existing_discourse_function_names.map { |name| "#{name}()" }
all_readonly_table_columns.each do |table_name, column_name| all_readonly_table_columns.each do |table_name, column_name|
function_name = Migration::BaseDropper.readonly_function_name(table_name, column_name, with_schema: false) function_name =
Migration::BaseDropper.readonly_function_name(table_name, column_name, with_schema: false)
if !existing_function_names.include?(function_name) if !existing_function_names.include?(function_name)
Migration::BaseDropper.create_readonly_function(table_name, column_name) Migration::BaseDropper.create_readonly_function(table_name, column_name)

View File

@ -12,7 +12,12 @@ module BackupRestore
end end
def self.chunk_path(identifier, filename, chunk_number) def self.chunk_path(identifier, filename, chunk_number)
File.join(LocalBackupStore.base_directory, "tmp", identifier, "#{filename}.part#{chunk_number}") File.join(
LocalBackupStore.base_directory,
"tmp",
identifier,
"#{filename}.part#{chunk_number}",
)
end end
def initialize(opts = {}) def initialize(opts = {})
@ -39,7 +44,7 @@ module BackupRestore
def download_file(filename, destination, failure_message = "") def download_file(filename, destination, failure_message = "")
path = path_from_filename(filename) path = path_from_filename(filename)
Discourse::Utils.execute_command('cp', path, destination, failure_message: failure_message) Discourse::Utils.execute_command("cp", path, destination, failure_message: failure_message)
end end
private private
@ -59,7 +64,7 @@ module BackupRestore
filename: File.basename(path), filename: File.basename(path),
size: File.size(path), size: File.size(path),
last_modified: File.mtime(path).utc, last_modified: File.mtime(path).utc,
source: include_download_source ? path : nil source: include_download_source ? path : nil,
) )
end end

View File

@ -32,7 +32,12 @@ module BackupRestore
def publish_log(message, timestamp) def publish_log(message, timestamp)
return unless @publish_to_message_bus return unless @publish_to_message_bus
data = { timestamp: timestamp, operation: "restore", message: message } data = { timestamp: timestamp, operation: "restore", message: message }
MessageBus.publish(BackupRestore::LOGS_CHANNEL, data, user_ids: [@user_id], client_ids: [@client_id]) MessageBus.publish(
BackupRestore::LOGS_CHANNEL,
data,
user_ids: [@user_id],
client_ids: [@client_id],
)
end end
def save_log(message, timestamp) def save_log(message, timestamp)

View File

@ -28,8 +28,10 @@ module BackupRestore
log " Restored version: #{metadata[:version]}" log " Restored version: #{metadata[:version]}"
if metadata[:version] > @current_version if metadata[:version] > @current_version
raise MigrationRequiredError.new("You're trying to restore a more recent version of the schema. " \ raise MigrationRequiredError.new(
"You should migrate first!") "You're trying to restore a more recent version of the schema. " \
"You should migrate first!",
)
end end
metadata metadata

View File

@ -65,8 +65,8 @@ module BackupRestore
after_restore_hook after_restore_hook
rescue Compression::Strategy::ExtractFailed rescue Compression::Strategy::ExtractFailed
log 'ERROR: The uncompressed file is too big. Consider increasing the hidden ' \ log "ERROR: The uncompressed file is too big. Consider increasing the hidden " \
'"decompressed_backup_max_file_size_mb" setting.' '"decompressed_backup_max_file_size_mb" setting.'
@database_restorer.rollback @database_restorer.rollback
rescue SystemExit rescue SystemExit
log "Restore process was cancelled!" log "Restore process was cancelled!"
@ -118,10 +118,10 @@ module BackupRestore
DiscourseEvent.trigger(:site_settings_restored) DiscourseEvent.trigger(:site_settings_restored)
if @disable_emails && SiteSetting.disable_emails == 'no' if @disable_emails && SiteSetting.disable_emails == "no"
log "Disabling outgoing emails for non-staff users..." log "Disabling outgoing emails for non-staff users..."
user = User.find_by_email(@user_info[:email]) || Discourse.system_user user = User.find_by_email(@user_info[:email]) || Discourse.system_user
SiteSetting.set_and_log(:disable_emails, 'non-staff', user) SiteSetting.set_and_log(:disable_emails, "non-staff", user)
end end
end end
@ -152,7 +152,7 @@ module BackupRestore
post = SystemMessage.create_from_system_user(user, status, logs: logs) post = SystemMessage.create_from_system_user(user, status, logs: logs)
else else
log "Could not send notification to '#{@user_info[:username]}' " \ log "Could not send notification to '#{@user_info[:username]}' " \
"(#{@user_info[:email]}), because the user does not exist." "(#{@user_info[:email]}), because the user does not exist."
end end
rescue => ex rescue => ex
log "Something went wrong while notifying user.", ex log "Something went wrong while notifying user.", ex

View File

@ -4,8 +4,11 @@ module BackupRestore
class S3BackupStore < BackupStore class S3BackupStore < BackupStore
UPLOAD_URL_EXPIRES_AFTER_SECONDS ||= 6.hours.to_i UPLOAD_URL_EXPIRES_AFTER_SECONDS ||= 6.hours.to_i
delegate :abort_multipart, :presign_multipart_part, :list_multipart_parts, delegate :abort_multipart,
:complete_multipart, to: :s3_helper :presign_multipart_part,
:list_multipart_parts,
:complete_multipart,
to: :s3_helper
def initialize(opts = {}) def initialize(opts = {})
@s3_options = S3Helper.s3_options(SiteSetting) @s3_options = S3Helper.s3_options(SiteSetting)
@ -13,7 +16,7 @@ module BackupRestore
end end
def s3_helper def s3_helper
@s3_helper ||= S3Helper.new(s3_bucket_name_with_prefix, '', @s3_options.clone) @s3_helper ||= S3Helper.new(s3_bucket_name_with_prefix, "", @s3_options.clone)
end end
def remote? def remote?
@ -57,11 +60,17 @@ module BackupRestore
presigned_url(obj, :put, UPLOAD_URL_EXPIRES_AFTER_SECONDS) presigned_url(obj, :put, UPLOAD_URL_EXPIRES_AFTER_SECONDS)
rescue Aws::Errors::ServiceError => e rescue Aws::Errors::ServiceError => e
Rails.logger.warn("Failed to generate upload URL for S3: #{e.message.presence || e.class.name}") Rails.logger.warn(
"Failed to generate upload URL for S3: #{e.message.presence || e.class.name}",
)
raise StorageError.new(e.message.presence || e.class.name) raise StorageError.new(e.message.presence || e.class.name)
end end
def signed_url_for_temporary_upload(file_name, expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, metadata: {}) def signed_url_for_temporary_upload(
file_name,
expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS,
metadata: {}
)
obj = object_from_path(file_name) obj = object_from_path(file_name)
raise BackupFileExists.new if obj.exists? raise BackupFileExists.new if obj.exists?
key = temporary_upload_path(file_name) key = temporary_upload_path(file_name)
@ -71,8 +80,8 @@ module BackupRestore
expires_in: expires_in, expires_in: expires_in,
opts: { opts: {
metadata: metadata, metadata: metadata,
acl: "private" acl: "private",
} },
) )
end end
@ -84,7 +93,7 @@ module BackupRestore
folder_prefix = s3_helper.s3_bucket_folder_path.nil? ? "" : s3_helper.s3_bucket_folder_path folder_prefix = s3_helper.s3_bucket_folder_path.nil? ? "" : s3_helper.s3_bucket_folder_path
if Rails.env.test? if Rails.env.test?
folder_prefix = File.join(folder_prefix, "test_#{ENV['TEST_ENV_NUMBER'].presence || '0'}") folder_prefix = File.join(folder_prefix, "test_#{ENV["TEST_ENV_NUMBER"].presence || "0"}")
end end
folder_prefix folder_prefix
@ -105,7 +114,10 @@ module BackupRestore
s3_helper.copy( s3_helper.copy(
existing_external_upload_key, existing_external_upload_key,
File.join(s3_helper.s3_bucket_folder_path, original_filename), File.join(s3_helper.s3_bucket_folder_path, original_filename),
options: { acl: "private", apply_metadata_to_destination: true } options: {
acl: "private",
apply_metadata_to_destination: true,
},
) )
s3_helper.delete_object(existing_external_upload_key) s3_helper.delete_object(existing_external_upload_key)
end end
@ -120,9 +132,7 @@ module BackupRestore
objects = [] objects = []
s3_helper.list.each do |obj| s3_helper.list.each do |obj|
if obj.key.match?(file_regex) objects << create_file_from_object(obj) if obj.key.match?(file_regex)
objects << create_file_from_object(obj)
end
end end
objects objects
@ -137,7 +147,7 @@ module BackupRestore
filename: File.basename(obj.key), filename: File.basename(obj.key),
size: obj.size, size: obj.size,
last_modified: obj.last_modified, last_modified: obj.last_modified,
source: include_download_source ? presigned_url(obj, :get, expires) : nil source: include_download_source ? presigned_url(obj, :get, expires) : nil,
) )
end end
@ -154,16 +164,17 @@ module BackupRestore
end end
def file_regex def file_regex
@file_regex ||= begin @file_regex ||=
path = s3_helper.s3_bucket_folder_path || "" begin
path = s3_helper.s3_bucket_folder_path || ""
if path.present? if path.present?
path = "#{path}/" unless path.end_with?("/") path = "#{path}/" unless path.end_with?("/")
path = Regexp.quote(path) path = Regexp.quote(path)
end
%r{^#{path}[^/]*\.t?gz$}i
end end
/^#{path}[^\/]*\.t?gz$/i
end
end end
def free_bytes def free_bytes

View File

@ -98,9 +98,7 @@ module BackupRestore
def flush_redis def flush_redis
redis = Discourse.redis redis = Discourse.redis
redis.scan_each(match: "*") do |key| redis.scan_each(match: "*") { |key| redis.del(key) unless key == SidekiqPauser::PAUSED_KEY }
redis.del(key) unless key == SidekiqPauser::PAUSED_KEY
end
end end
def clear_sidekiq_queues def clear_sidekiq_queues

View File

@ -11,11 +11,12 @@ module BackupRestore
def self.s3_regex_string(s3_base_url) def self.s3_regex_string(s3_base_url)
clean_url = s3_base_url.sub(S3_ENDPOINT_REGEX, ".s3.amazonaws.com") clean_url = s3_base_url.sub(S3_ENDPOINT_REGEX, ".s3.amazonaws.com")
regex_string = clean_url regex_string =
.split(".s3.amazonaws.com") clean_url
.map { |s| Regexp.escape(s) } .split(".s3.amazonaws.com")
.insert(1, S3_ENDPOINT_REGEX.source) .map { |s| Regexp.escape(s) }
.join("") .insert(1, S3_ENDPOINT_REGEX.source)
.join("")
[regex_string, clean_url] [regex_string, clean_url]
end end
@ -25,12 +26,16 @@ module BackupRestore
end end
def restore(tmp_directory) def restore(tmp_directory)
upload_directories = Dir.glob(File.join(tmp_directory, "uploads", "*")) upload_directories =
.reject { |path| File.basename(path).start_with?("PaxHeaders") } Dir
.glob(File.join(tmp_directory, "uploads", "*"))
.reject { |path| File.basename(path).start_with?("PaxHeaders") }
if upload_directories.count > 1 if upload_directories.count > 1
raise UploadsRestoreError.new("Could not find uploads, because the uploads " \ raise UploadsRestoreError.new(
"directory contains multiple folders.") "Could not find uploads, because the uploads " \
"directory contains multiple folders.",
)
end end
@tmp_uploads_path = upload_directories.first @tmp_uploads_path = upload_directories.first
@ -55,7 +60,9 @@ module BackupRestore
if !store.respond_to?(:copy_from) if !store.respond_to?(:copy_from)
# a FileStore implementation from a plugin might not support this method, so raise a helpful error # a FileStore implementation from a plugin might not support this method, so raise a helpful error
store_name = Discourse.store.class.name store_name = Discourse.store.class.name
raise UploadsRestoreError.new("The current file store (#{store_name}) does not support restoring uploads.") raise UploadsRestoreError.new(
"The current file store (#{store_name}) does not support restoring uploads.",
)
end end
log "Restoring uploads, this may take a while..." log "Restoring uploads, this may take a while..."
@ -89,13 +96,17 @@ module BackupRestore
remap(old_base_url, Discourse.base_url) remap(old_base_url, Discourse.base_url)
end end
current_s3_base_url = SiteSetting::Upload.enable_s3_uploads ? SiteSetting::Upload.s3_base_url : nil current_s3_base_url =
if (old_s3_base_url = BackupMetadata.value_for("s3_base_url")) && old_s3_base_url != current_s3_base_url SiteSetting::Upload.enable_s3_uploads ? SiteSetting::Upload.s3_base_url : nil
if (old_s3_base_url = BackupMetadata.value_for("s3_base_url")) &&
old_s3_base_url != current_s3_base_url
remap_s3("#{old_s3_base_url}/", uploads_folder) remap_s3("#{old_s3_base_url}/", uploads_folder)
end end
current_s3_cdn_url = SiteSetting::Upload.enable_s3_uploads ? SiteSetting::Upload.s3_cdn_url : nil current_s3_cdn_url =
if (old_s3_cdn_url = BackupMetadata.value_for("s3_cdn_url")) && old_s3_cdn_url != current_s3_cdn_url SiteSetting::Upload.enable_s3_uploads ? SiteSetting::Upload.s3_cdn_url : nil
if (old_s3_cdn_url = BackupMetadata.value_for("s3_cdn_url")) &&
old_s3_cdn_url != current_s3_cdn_url
base_url = current_s3_cdn_url || Discourse.base_url base_url = current_s3_cdn_url || Discourse.base_url
remap("#{old_s3_cdn_url}/", UrlHelper.schemaless("#{base_url}#{uploads_folder}")) remap("#{old_s3_cdn_url}/", UrlHelper.schemaless("#{base_url}#{uploads_folder}"))
@ -113,10 +124,7 @@ module BackupRestore
remap(old_host, new_host) if old_host != new_host remap(old_host, new_host) if old_host != new_host
end end
if @previous_db_name != @current_db_name remap("/uploads/#{@previous_db_name}/", upload_path) if @previous_db_name != @current_db_name
remap("/uploads/#{@previous_db_name}/", upload_path)
end
rescue => ex rescue => ex
log "Something went wrong while remapping uploads.", ex log "Something went wrong while remapping uploads.", ex
end end
@ -130,7 +138,12 @@ module BackupRestore
if old_s3_base_url.include?("amazonaws.com") if old_s3_base_url.include?("amazonaws.com")
from_regex, from_clean_url = self.class.s3_regex_string(old_s3_base_url) from_regex, from_clean_url = self.class.s3_regex_string(old_s3_base_url)
log "Remapping with regex from '#{from_clean_url}' to '#{uploads_folder}'" log "Remapping with regex from '#{from_clean_url}' to '#{uploads_folder}'"
DbHelper.regexp_replace(from_regex, uploads_folder, verbose: true, excluded_tables: ["backup_metadata"]) DbHelper.regexp_replace(
from_regex,
uploads_folder,
verbose: true,
excluded_tables: ["backup_metadata"],
)
else else
remap(old_s3_base_url, uploads_folder) remap(old_s3_base_url, uploads_folder)
end end
@ -141,13 +154,15 @@ module BackupRestore
DB.exec("TRUNCATE TABLE optimized_images") DB.exec("TRUNCATE TABLE optimized_images")
SiteIconManager.ensure_optimized! SiteIconManager.ensure_optimized!
User.where("uploaded_avatar_id IS NOT NULL").find_each do |user| User
Jobs.enqueue(:create_avatar_thumbnails, upload_id: user.uploaded_avatar_id) .where("uploaded_avatar_id IS NOT NULL")
end .find_each do |user|
Jobs.enqueue(:create_avatar_thumbnails, upload_id: user.uploaded_avatar_id)
end
end end
def rebake_posts_with_uploads def rebake_posts_with_uploads
log 'Posts will be rebaked by a background job in sidekiq. You will see missing images until that has completed.' log "Posts will be rebaked by a background job in sidekiq. You will see missing images until that has completed."
log 'You can expedite the process by manually running "rake posts:rebake_uncooked_posts"' log 'You can expedite the process by manually running "rake posts:rebake_uncooked_posts"'
DB.exec(<<~SQL) DB.exec(<<~SQL)

View File

@ -173,7 +173,7 @@ module BadgeQueries
<<~SQL <<~SQL
SELECT p.user_id, p.id post_id, current_timestamp granted_at SELECT p.user_id, p.id post_id, current_timestamp granted_at
FROM badge_posts p FROM badge_posts p
WHERE #{is_topic ? "p.post_number = 1" : "p.post_number > 1" } AND p.like_count >= #{count.to_i} AND WHERE #{is_topic ? "p.post_number = 1" : "p.post_number > 1"} AND p.like_count >= #{count.to_i} AND
(:backfill OR p.id IN (:post_ids) ) (:backfill OR p.id IN (:post_ids) )
SQL SQL
end end
@ -271,5 +271,4 @@ module BadgeQueries
WHERE "rank" = 1 WHERE "rank" = 1
SQL SQL
end end
end end

View File

@ -11,9 +11,7 @@ class BookmarkQuery
def self.preload(bookmarks, object) def self.preload(bookmarks, object)
preload_polymorphic_associations(bookmarks, object.guardian) preload_polymorphic_associations(bookmarks, object.guardian)
if @preload @preload.each { |preload| preload.call(bookmarks, object) } if @preload
@preload.each { |preload| preload.call(bookmarks, object) }
end
end end
# These polymorphic associations are loaded to make the UserBookmarkListSerializer's # These polymorphic associations are loaded to make the UserBookmarkListSerializer's
@ -42,24 +40,27 @@ class BookmarkQuery
ts_query = search_term.present? ? Search.ts_query(term: search_term) : nil ts_query = search_term.present? ? Search.ts_query(term: search_term) : nil
search_term_wildcard = search_term.present? ? "%#{search_term}%" : nil search_term_wildcard = search_term.present? ? "%#{search_term}%" : nil
queries = Bookmark.registered_bookmarkables.map do |bookmarkable| queries =
interim_results = bookmarkable.perform_list_query(@user, @guardian) Bookmark
.registered_bookmarkables
.map do |bookmarkable|
interim_results = bookmarkable.perform_list_query(@user, @guardian)
# this could occur if there is some security reason that the user cannot # this could occur if there is some security reason that the user cannot
# access the bookmarkables that they have bookmarked, e.g. if they had 1 bookmark # access the bookmarkables that they have bookmarked, e.g. if they had 1 bookmark
# on a topic and that topic was moved into a private category # on a topic and that topic was moved into a private category
next if interim_results.blank? next if interim_results.blank?
if search_term.present? if search_term.present?
interim_results = bookmarkable.perform_search_query( interim_results =
interim_results, search_term_wildcard, ts_query bookmarkable.perform_search_query(interim_results, search_term_wildcard, ts_query)
) end
end
# this is purely to make the query easy to read and debug, otherwise it's # this is purely to make the query easy to read and debug, otherwise it's
# all mashed up into a massive ball in MiniProfiler :) # all mashed up into a massive ball in MiniProfiler :)
"---- #{bookmarkable.model.to_s} bookmarkable ---\n\n #{interim_results.to_sql}" "---- #{bookmarkable.model.to_s} bookmarkable ---\n\n #{interim_results.to_sql}"
end.compact end
.compact
# same for interim results being blank, the user might have been locked out # same for interim results being blank, the user might have been locked out
# from all their various bookmarks, in which case they will see nothing and # from all their various bookmarks, in which case they will see nothing and
@ -68,17 +69,16 @@ class BookmarkQuery
union_sql = queries.join("\n\nUNION\n\n") union_sql = queries.join("\n\nUNION\n\n")
results = Bookmark.select("bookmarks.*").from("(\n\n#{union_sql}\n\n) as bookmarks") results = Bookmark.select("bookmarks.*").from("(\n\n#{union_sql}\n\n) as bookmarks")
results = results.order( results =
"(CASE WHEN bookmarks.pinned THEN 0 ELSE 1 END), results.order(
"(CASE WHEN bookmarks.pinned THEN 0 ELSE 1 END),
bookmarks.reminder_at ASC, bookmarks.reminder_at ASC,
bookmarks.updated_at DESC" bookmarks.updated_at DESC",
) )
@count = results.count @count = results.count
if @page.positive? results = results.offset(@page * @params[:per_page]) if @page.positive?
results = results.offset(@page * @params[:per_page])
end
if updated_results = blk&.call(results) if updated_results = blk&.call(results)
results = updated_results results = updated_results

View File

@ -28,12 +28,10 @@ class BookmarkReminderNotificationHandler
def clear_reminder def clear_reminder
Rails.logger.debug( Rails.logger.debug(
"Clearing bookmark reminder for bookmark_id #{bookmark.id}. reminder at: #{bookmark.reminder_at}" "Clearing bookmark reminder for bookmark_id #{bookmark.id}. reminder at: #{bookmark.reminder_at}",
) )
if bookmark.auto_clear_reminder_when_reminder_sent? bookmark.reminder_at = nil if bookmark.auto_clear_reminder_when_reminder_sent?
bookmark.reminder_at = nil
end
bookmark.clear_reminder! bookmark.clear_reminder!
end end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module BrowserDetection module BrowserDetection
def self.browser(user_agent) def self.browser(user_agent)
case user_agent case user_agent
when /Edg/i when /Edg/i
@ -66,5 +65,4 @@ module BrowserDetection
:unknown :unknown
end end
end end
end end

View File

@ -15,12 +15,11 @@
# this makes it harder to reason about the API # this makes it harder to reason about the API
class Cache class Cache
# nothing is cached for longer than 1 day EVER # nothing is cached for longer than 1 day EVER
# there is no reason to have data older than this clogging redis # there is no reason to have data older than this clogging redis
# it is dangerous cause if we rename keys we will be stuck with # it is dangerous cause if we rename keys we will be stuck with
# pointless data # pointless data
MAX_CACHE_AGE = 1.day unless defined? MAX_CACHE_AGE MAX_CACHE_AGE = 1.day unless defined?(MAX_CACHE_AGE)
attr_reader :namespace attr_reader :namespace
@ -47,9 +46,7 @@ class Cache
end end
def clear def clear
keys.each do |k| keys.each { |k| redis.del(k) }
redis.del(k)
end
end end
def normalize_key(key) def normalize_key(key)
@ -80,9 +77,7 @@ class Cache
key = normalize_key(name) key = normalize_key(name)
raw = nil raw = nil
if !force raw = redis.get(key) if !force
raw = redis.get(key)
end
if raw if raw
begin begin
@ -96,7 +91,8 @@ class Cache
val val
end end
elsif force elsif force
raise ArgumentError, "Missing block: Calling `Cache#fetch` with `force: true` requires a block." raise ArgumentError,
"Missing block: Calling `Cache#fetch` with `force: true` requires a block."
else else
read(name) read(name)
end end
@ -105,7 +101,7 @@ class Cache
protected protected
def log_first_exception(e) def log_first_exception(e)
if !defined? @logged_a_warning if !defined?(@logged_a_warning)
@logged_a_warning = true @logged_a_warning = true
Discourse.warn_exception(e, "Corrupt cache... skipping entry for key #{key}") Discourse.warn_exception(e, "Corrupt cache... skipping entry for key #{key}")
end end
@ -129,5 +125,4 @@ class Cache
redis.setex(key, expiry, dumped) redis.setex(key, expiry, dumped)
true true
end end
end end

View File

@ -2,7 +2,7 @@
module CanonicalURL module CanonicalURL
module ControllerExtensions module ControllerExtensions
ALLOWED_CANONICAL_PARAMS = %w(page) ALLOWED_CANONICAL_PARAMS = %w[page]
def canonical_url(url_for_options = {}) def canonical_url(url_for_options = {})
case url_for_options case url_for_options
@ -14,14 +14,15 @@ module CanonicalURL
end end
def default_canonical def default_canonical
@default_canonical ||= begin @default_canonical ||=
canonical = +"#{Discourse.base_url_no_prefix}#{request.path}" begin
allowed_params = params.select { |key| ALLOWED_CANONICAL_PARAMS.include?(key) } canonical = +"#{Discourse.base_url_no_prefix}#{request.path}"
if allowed_params.present? allowed_params = params.select { |key| ALLOWED_CANONICAL_PARAMS.include?(key) }
canonical << "?#{allowed_params.keys.zip(allowed_params.values).map { |key, value| "#{key}=#{value}" }.join("&")}" if allowed_params.present?
canonical << "?#{allowed_params.keys.zip(allowed_params.values).map { |key, value| "#{key}=#{value}" }.join("&")}"
end
canonical
end end
canonical
end
end end
def self.included(base) def self.included(base)
@ -31,7 +32,7 @@ module CanonicalURL
module Helpers module Helpers
def canonical_link_tag(url = nil) def canonical_link_tag(url = nil)
tag('link', rel: 'canonical', href: url || @canonical_url || default_canonical) tag("link", rel: "canonical", href: url || @canonical_url || default_canonical)
end end
end end
end end

View File

@ -1,23 +1,26 @@
# frozen_string_literal: true # frozen_string_literal: true
module CategoryBadge module CategoryBadge
def self.category_stripe(color, classes) def self.category_stripe(color, classes)
style = color ? "style='background-color: ##{color};'" : '' style = color ? "style='background-color: ##{color};'" : ""
"<span class='#{classes}' #{style}></span>" "<span class='#{classes}' #{style}></span>"
end end
def self.inline_category_stripe(color, styles = '', insert_blank = false) def self.inline_category_stripe(color, styles = "", insert_blank = false)
"<span style='background-color: ##{color};#{styles}'>#{insert_blank ? '&nbsp;' : ''}</span>" "<span style='background-color: ##{color};#{styles}'>#{insert_blank ? "&nbsp;" : ""}</span>"
end end
def self.inline_badge_wrapper_style(category) def self.inline_badge_wrapper_style(category)
style = style =
case (SiteSetting.category_style || :box).to_sym case (SiteSetting.category_style || :box).to_sym
when :bar then 'line-height: 1.25; margin-right: 5px;' when :bar
when :box then "background-color:##{category.color}; line-height: 1.5; margin-top: 5px; margin-right: 5px;" "line-height: 1.25; margin-right: 5px;"
when :bullet then 'line-height: 1; margin-right: 10px;' when :box
when :none then '' "background-color:##{category.color}; line-height: 1.5; margin-top: 5px; margin-right: 5px;"
when :bullet
"line-height: 1; margin-right: 10px;"
when :none
""
end end
" style='font-size: 0.857em; white-space: nowrap; display: inline-block; position: relative; #{style}'" " style='font-size: 0.857em; white-space: nowrap; display: inline-block; position: relative; #{style}'"
@ -34,73 +37,88 @@ module CategoryBadge
extra_classes = "#{opts[:extra_classes]} #{SiteSetting.category_style}" extra_classes = "#{opts[:extra_classes]} #{SiteSetting.category_style}"
result = +'' result = +""
# parent span # parent span
unless category.parent_category_id.nil? || opts[:hide_parent] unless category.parent_category_id.nil? || opts[:hide_parent]
parent_category = Category.find_by(id: category.parent_category_id) parent_category = Category.find_by(id: category.parent_category_id)
result << result << if opts[:inline_style]
if opts[:inline_style] case (SiteSetting.category_style || :box).to_sym
case (SiteSetting.category_style || :box).to_sym when :bar
when :bar inline_category_stripe(
inline_category_stripe(parent_category.color, 'display: inline-block; padding: 1px;', true) parent_category.color,
when :box "display: inline-block; padding: 1px;",
inline_category_stripe(parent_category.color, 'display: inline-block; padding: 0 1px;', true) true,
when :bullet )
inline_category_stripe(parent_category.color, 'display: inline-block; width: 5px; height: 10px; line-height: 1;') when :box
when :none inline_category_stripe(
'' parent_category.color,
end "display: inline-block; padding: 0 1px;",
else true,
category_stripe(parent_category.color, 'badge-category-parent-bg') )
when :bullet
inline_category_stripe(
parent_category.color,
"display: inline-block; width: 5px; height: 10px; line-height: 1;",
)
when :none
""
end end
else
category_stripe(parent_category.color, "badge-category-parent-bg")
end
end end
# sub parent or main category span # sub parent or main category span
result << result << if opts[:inline_style]
if opts[:inline_style] case (SiteSetting.category_style || :box).to_sym
case (SiteSetting.category_style || :box).to_sym when :bar
when :bar inline_category_stripe(category.color, "display: inline-block; padding: 1px;", true)
inline_category_stripe(category.color, 'display: inline-block; padding: 1px;', true) when :box
when :box ""
'' when :bullet
when :bullet inline_category_stripe(
inline_category_stripe(category.color, "display: inline-block; width: #{category.parent_category_id.nil? ? 10 : 5}px; height: 10px;") category.color,
when :none "display: inline-block; width: #{category.parent_category_id.nil? ? 10 : 5}px; height: 10px;",
'' )
end when :none
else ""
category_stripe(category.color, 'badge-category-bg')
end end
else
category_stripe(category.color, "badge-category-bg")
end
# category name # category name
class_names = 'badge-category clear-badge' class_names = "badge-category clear-badge"
description = category.description_text ? "title='#{category.description_text}'" : '' description = category.description_text ? "title='#{category.description_text}'" : ""
category_url = opts[:absolute_url] ? "#{Discourse.base_url_no_prefix}#{category.url}" : category.url category_url =
opts[:absolute_url] ? "#{Discourse.base_url_no_prefix}#{category.url}" : category.url
extra_span_classes = extra_span_classes =
if opts[:inline_style] if opts[:inline_style]
case (SiteSetting.category_style || :box).to_sym case (SiteSetting.category_style || :box).to_sym
when :bar when :bar
'color: #222222; padding: 3px; vertical-align: text-top; margin-top: -3px; display: inline-block;' "color: #222222; padding: 3px; vertical-align: text-top; margin-top: -3px; display: inline-block;"
when :box when :box
"color: ##{category.text_color}; padding: 0 5px;" "color: ##{category.text_color}; padding: 0 5px;"
when :bullet when :bullet
'color: #222222; vertical-align: text-top; line-height: 1; margin-left: 4px; padding-left: 2px; display: inline;' "color: #222222; vertical-align: text-top; line-height: 1; margin-left: 4px; padding-left: 2px; display: inline;"
when :none when :none
'' ""
end + 'max-width: 150px; overflow: hidden; text-overflow: ellipsis;' end + "max-width: 150px; overflow: hidden; text-overflow: ellipsis;"
elsif (SiteSetting.category_style).to_sym == :box elsif (SiteSetting.category_style).to_sym == :box
"color: ##{category.text_color}" "color: ##{category.text_color}"
else else
'' ""
end end
result << "<span style='#{extra_span_classes}' data-drop-close='true' class='#{class_names}' result << "<span style='#{extra_span_classes}' data-drop-close='true' class='#{class_names}'
#{description}>" #{description}>"
result << ERB::Util.html_escape(category.name) << '</span>' result << ERB::Util.html_escape(category.name) << "</span>"
result = "<a class='badge-wrapper #{extra_classes}' href='#{category_url}'" + (opts[:inline_style] ? inline_badge_wrapper_style(category) : '') + ">#{result}</a>" result =
"<a class='badge-wrapper #{extra_classes}' href='#{category_url}'" +
(opts[:inline_style] ? inline_badge_wrapper_style(category) : "") + ">#{result}</a>"
result.html_safe result.html_safe
end end

View File

@ -3,13 +3,17 @@
require "rbconfig" require "rbconfig"
class ChromeInstalledChecker class ChromeInstalledChecker
class ChromeError < StandardError; end class ChromeError < StandardError
class ChromeVersionError < ChromeError; end end
class ChromeNotInstalled < ChromeError; end class ChromeVersionError < ChromeError
class ChromeVersionTooLow < ChromeError; end end
class ChromeNotInstalled < ChromeError
end
class ChromeVersionTooLow < ChromeError
end
def self.run def self.run
if RbConfig::CONFIG['host_os'][/darwin|mac os/] if RbConfig::CONFIG["host_os"][/darwin|mac os/]
binary = "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome" binary = "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome"
elsif system("command -v google-chrome-stable >/dev/null;") elsif system("command -v google-chrome-stable >/dev/null;")
binary = "google-chrome-stable" binary = "google-chrome-stable"
@ -18,15 +22,15 @@ class ChromeInstalledChecker
binary ||= "chromium" if system("command -v chromium >/dev/null;") binary ||= "chromium" if system("command -v chromium >/dev/null;")
if !binary if !binary
raise ChromeNotInstalled.new("Chrome is not installed. Download from https://www.google.com/chrome/browser/desktop/index.html") raise ChromeNotInstalled.new(
"Chrome is not installed. Download from https://www.google.com/chrome/browser/desktop/index.html",
)
end end
version = `\"#{binary}\" --version` version = `\"#{binary}\" --version`
version_match = version.match(/[\d\.]+/) version_match = version.match(/[\d\.]+/)
if !version_match raise ChromeError.new("Can't get the #{binary} version") if !version_match
raise ChromeError.new("Can't get the #{binary} version")
end
if Gem::Version.new(version_match[0]) < Gem::Version.new("59") if Gem::Version.new(version_match[0]) < Gem::Version.new("59")
raise ChromeVersionTooLow.new("Chrome 59 or higher is required") raise ChromeVersionTooLow.new("Chrome 59 or higher is required")

View File

@ -28,24 +28,27 @@ class CommentMigration < ActiveRecord::Migration[4.2]
end end
def down def down
replace_nils(comments_up).deep_merge(comments_down).each do |table| replace_nils(comments_up)
table[1].each do |column| .deep_merge(comments_down)
table_name = table[0] .each do |table|
column_name = column[0] table[1].each do |column|
comment = column[1] table_name = table[0]
column_name = column[0]
comment = column[1]
if column_name == :_table if column_name == :_table
DB.exec "COMMENT ON TABLE #{table_name} IS ?", comment DB.exec "COMMENT ON TABLE #{table_name} IS ?", comment
puts " COMMENT ON TABLE #{table_name}" puts " COMMENT ON TABLE #{table_name}"
else else
DB.exec "COMMENT ON COLUMN #{table_name}.#{column_name} IS ?", comment DB.exec "COMMENT ON COLUMN #{table_name}.#{column_name} IS ?", comment
puts " COMMENT ON COLUMN #{table_name}.#{column_name}" puts " COMMENT ON COLUMN #{table_name}.#{column_name}"
end
end end
end end
end
end end
private private
def replace_nils(hash) def replace_nils(hash)
hash.each do |key, value| hash.each do |key, value|
if Hash === value if Hash === value

View File

@ -12,9 +12,8 @@
# Discourse.redis.without_namespace.del CommonPasswords::LIST_KEY # Discourse.redis.without_namespace.del CommonPasswords::LIST_KEY
class CommonPasswords class CommonPasswords
PASSWORD_FILE = File.join(Rails.root, "lib", "common_passwords", "10-char-common-passwords.txt")
PASSWORD_FILE = File.join(Rails.root, 'lib', 'common_passwords', '10-char-common-passwords.txt') LIST_KEY = "discourse-common-passwords"
LIST_KEY = 'discourse-common-passwords'
@mutex = Mutex.new @mutex = Mutex.new
@ -32,9 +31,7 @@ class CommonPasswords
end end
def self.password_list def self.password_list
@mutex.synchronize do @mutex.synchronize { load_passwords unless redis.scard(LIST_KEY) > 0 }
load_passwords unless redis.scard(LIST_KEY) > 0
end
RedisPasswordList.new RedisPasswordList.new
end end
@ -49,5 +46,4 @@ class CommonPasswords
# tolerate this so we don't block signups # tolerate this so we don't block signups
Rails.logger.error "Common passwords file #{PASSWORD_FILE} is not found! Common password checking is skipped." Rails.logger.error "Common passwords file #{PASSWORD_FILE} is not found! Common password checking is skipped."
end end
end end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class ComposerMessagesFinder class ComposerMessagesFinder
def initialize(user, details) def initialize(user, details)
@user = user @user = user
@details = details @details = details
@ -29,26 +28,30 @@ class ComposerMessagesFinder
if creating_topic? if creating_topic?
count = @user.created_topic_count count = @user.created_topic_count
education_key = 'education.new-topic' education_key = "education.new-topic"
else else
count = @user.post_count count = @user.post_count
education_key = 'education.new-reply' education_key = "education.new-reply"
end end
if count < SiteSetting.educate_until_posts if count < SiteSetting.educate_until_posts
return { return(
id: 'education', {
templateName: 'education', id: "education",
wait_for_typing: true, templateName: "education",
body: PrettyText.cook( wait_for_typing: true,
I18n.t( body:
education_key, PrettyText.cook(
education_posts_text: I18n.t('education.until_posts', count: SiteSetting.educate_until_posts), I18n.t(
site_name: SiteSetting.title, education_key,
base_path: Discourse.base_path education_posts_text:
) I18n.t("education.until_posts", count: SiteSetting.educate_until_posts),
) site_name: SiteSetting.title,
} base_path: Discourse.base_path,
),
),
}
)
end end
nil nil
@ -59,35 +62,55 @@ class ComposerMessagesFinder
return unless replying? && @user.posted_too_much_in_topic?(@details[:topic_id]) return unless replying? && @user.posted_too_much_in_topic?(@details[:topic_id])
{ {
id: 'too_many_replies', id: "too_many_replies",
templateName: 'education', templateName: "education",
body: PrettyText.cook(I18n.t('education.too_many_replies', newuser_max_replies_per_topic: SiteSetting.newuser_max_replies_per_topic)) body:
PrettyText.cook(
I18n.t(
"education.too_many_replies",
newuser_max_replies_per_topic: SiteSetting.newuser_max_replies_per_topic,
),
),
} }
end end
# Should a user be contacted to update their avatar? # Should a user be contacted to update their avatar?
def check_avatar_notification def check_avatar_notification
# A user has to be basic at least to be considered for an avatar notification # A user has to be basic at least to be considered for an avatar notification
return unless @user.has_trust_level?(TrustLevel[1]) return unless @user.has_trust_level?(TrustLevel[1])
# We don't notify users who have avatars or who have been notified already. # We don't notify users who have avatars or who have been notified already.
return if @user.uploaded_avatar_id || UserHistory.exists_for_user?(@user, :notified_about_avatar) if @user.uploaded_avatar_id || UserHistory.exists_for_user?(@user, :notified_about_avatar)
return
end
# Do not notify user if any of the following is true: # Do not notify user if any of the following is true:
# - "disable avatar education message" is enabled # - "disable avatar education message" is enabled
# - "sso overrides avatar" is enabled # - "sso overrides avatar" is enabled
# - "allow uploaded avatars" is disabled # - "allow uploaded avatars" is disabled
return if SiteSetting.disable_avatar_education_message || SiteSetting.discourse_connect_overrides_avatar || !TrustLevelAndStaffAndDisabledSetting.matches?(SiteSetting.allow_uploaded_avatars, @user) if SiteSetting.disable_avatar_education_message ||
SiteSetting.discourse_connect_overrides_avatar ||
!TrustLevelAndStaffAndDisabledSetting.matches?(SiteSetting.allow_uploaded_avatars, @user)
return
end
# If we got this far, log that we've nagged them about the avatar # If we got this far, log that we've nagged them about the avatar
UserHistory.create!(action: UserHistory.actions[:notified_about_avatar], target_user_id: @user.id) UserHistory.create!(
action: UserHistory.actions[:notified_about_avatar],
target_user_id: @user.id,
)
# Return the message # Return the message
{ {
id: 'avatar', id: "avatar",
templateName: 'education', templateName: "education",
body: PrettyText.cook(I18n.t('education.avatar', profile_path: "/u/#{@user.username_lower}/preferences/account#profile-picture")) body:
PrettyText.cook(
I18n.t(
"education.avatar",
profile_path: "/u/#{@user.username_lower}/preferences/account#profile-picture",
),
),
} }
end end
@ -96,39 +119,45 @@ class ComposerMessagesFinder
return unless educate_reply?(:notified_about_sequential_replies) return unless educate_reply?(:notified_about_sequential_replies)
# Count the posts made by this user in the last day # Count the posts made by this user in the last day
recent_posts_user_ids = Post.where(topic_id: @details[:topic_id]) recent_posts_user_ids =
.where("created_at > ?", 1.day.ago) Post
.where(post_type: Post.types[:regular]) .where(topic_id: @details[:topic_id])
.order('created_at desc') .where("created_at > ?", 1.day.ago)
.limit(SiteSetting.sequential_replies_threshold) .where(post_type: Post.types[:regular])
.pluck(:user_id) .order("created_at desc")
.limit(SiteSetting.sequential_replies_threshold)
.pluck(:user_id)
# Did we get back as many posts as we asked for, and are they all by the current user? # Did we get back as many posts as we asked for, and are they all by the current user?
return if recent_posts_user_ids.size != SiteSetting.sequential_replies_threshold || if recent_posts_user_ids.size != SiteSetting.sequential_replies_threshold ||
recent_posts_user_ids.detect { |u| u != @user.id } recent_posts_user_ids.detect { |u| u != @user.id }
return
end
# If we got this far, log that we've nagged them about the sequential replies # If we got this far, log that we've nagged them about the sequential replies
UserHistory.create!(action: UserHistory.actions[:notified_about_sequential_replies], UserHistory.create!(
target_user_id: @user.id, action: UserHistory.actions[:notified_about_sequential_replies],
topic_id: @details[:topic_id]) target_user_id: @user.id,
topic_id: @details[:topic_id],
)
{ {
id: 'sequential_replies', id: "sequential_replies",
templateName: 'education', templateName: "education",
wait_for_typing: true, wait_for_typing: true,
extraClass: 'education-message', extraClass: "education-message",
hide_if_whisper: true, hide_if_whisper: true,
body: PrettyText.cook(I18n.t('education.sequential_replies')) body: PrettyText.cook(I18n.t("education.sequential_replies")),
} }
end end
def check_dominating_topic def check_dominating_topic
return unless educate_reply?(:notified_about_dominating_topic) return unless educate_reply?(:notified_about_dominating_topic)
return if @topic.blank? || if @topic.blank? || @topic.user_id == @user.id ||
@topic.user_id == @user.id || @topic.posts_count < SiteSetting.summary_posts_required || @topic.private_message?
@topic.posts_count < SiteSetting.summary_posts_required || return
@topic.private_message? end
posts_by_user = @user.posts.where(topic_id: @topic.id).count posts_by_user = @user.posts.where(topic_id: @topic.id).count
@ -136,16 +165,18 @@ class ComposerMessagesFinder
return if ratio < (SiteSetting.dominating_topic_minimum_percent.to_f / 100.0) return if ratio < (SiteSetting.dominating_topic_minimum_percent.to_f / 100.0)
# Log the topic notification # Log the topic notification
UserHistory.create!(action: UserHistory.actions[:notified_about_dominating_topic], UserHistory.create!(
target_user_id: @user.id, action: UserHistory.actions[:notified_about_dominating_topic],
topic_id: @details[:topic_id]) target_user_id: @user.id,
topic_id: @details[:topic_id],
)
{ {
id: 'dominating_topic', id: "dominating_topic",
templateName: 'dominating-topic', templateName: "dominating-topic",
wait_for_typing: true, wait_for_typing: true,
extraClass: 'education-message dominating-topic-message', extraClass: "education-message dominating-topic-message",
body: PrettyText.cook(I18n.t('education.dominating_topic')) body: PrettyText.cook(I18n.t("education.dominating_topic")),
} }
end end
@ -157,73 +188,85 @@ class ComposerMessagesFinder
reply_to_user_id = Post.where(id: @details[:post_id]).pluck(:user_id)[0] reply_to_user_id = Post.where(id: @details[:post_id]).pluck(:user_id)[0]
# Users's last x posts in the topic # Users's last x posts in the topic
last_x_replies = @topic. last_x_replies =
posts. @topic
where(user_id: @user.id). .posts
order('created_at desc'). .where(user_id: @user.id)
limit(SiteSetting.get_a_room_threshold). .order("created_at desc")
pluck(:reply_to_user_id). .limit(SiteSetting.get_a_room_threshold)
find_all { |uid| uid != @user.id && uid == reply_to_user_id } .pluck(:reply_to_user_id)
.find_all { |uid| uid != @user.id && uid == reply_to_user_id }
return unless last_x_replies.size == SiteSetting.get_a_room_threshold return unless last_x_replies.size == SiteSetting.get_a_room_threshold
return unless @topic.posts.count('distinct user_id') >= min_users_posted return unless @topic.posts.count("distinct user_id") >= min_users_posted
UserHistory.create!(action: UserHistory.actions[:notified_about_get_a_room], UserHistory.create!(
target_user_id: @user.id, action: UserHistory.actions[:notified_about_get_a_room],
topic_id: @details[:topic_id]) target_user_id: @user.id,
topic_id: @details[:topic_id],
)
reply_username = User.where(id: last_x_replies[0]).pluck_first(:username) reply_username = User.where(id: last_x_replies[0]).pluck_first(:username)
{ {
id: 'get_a_room', id: "get_a_room",
templateName: 'get-a-room', templateName: "get-a-room",
wait_for_typing: true, wait_for_typing: true,
reply_username: reply_username, reply_username: reply_username,
extraClass: 'education-message get-a-room', extraClass: "education-message get-a-room",
body: PrettyText.cook( body:
I18n.t( PrettyText.cook(
'education.get_a_room', I18n.t(
count: SiteSetting.get_a_room_threshold, "education.get_a_room",
reply_username: reply_username, count: SiteSetting.get_a_room_threshold,
base_path: Discourse.base_path reply_username: reply_username,
) base_path: Discourse.base_path,
) ),
),
} }
end end
def check_reviving_old_topic def check_reviving_old_topic
return unless replying? return unless replying?
return if @topic.nil? || if @topic.nil? || SiteSetting.warn_reviving_old_topic_age < 1 || @topic.last_posted_at.nil? ||
SiteSetting.warn_reviving_old_topic_age < 1 || @topic.last_posted_at > SiteSetting.warn_reviving_old_topic_age.days.ago
@topic.last_posted_at.nil? || return
@topic.last_posted_at > SiteSetting.warn_reviving_old_topic_age.days.ago end
{ {
id: 'reviving_old', id: "reviving_old",
templateName: 'education', templateName: "education",
wait_for_typing: false, wait_for_typing: false,
extraClass: 'education-message', extraClass: "education-message",
body: PrettyText.cook( body:
I18n.t( PrettyText.cook(
'education.reviving_old_topic', I18n.t(
time_ago: FreedomPatches::Rails4.time_ago_in_words(@topic.last_posted_at, false, scope: :'datetime.distance_in_words_verbose') "education.reviving_old_topic",
) time_ago:
) FreedomPatches::Rails4.time_ago_in_words(
@topic.last_posted_at,
false,
scope: :"datetime.distance_in_words_verbose",
),
),
),
} }
end end
def self.user_not_seen_in_a_while(usernames) def self.user_not_seen_in_a_while(usernames)
User.where(username_lower: usernames).where("last_seen_at < ?", SiteSetting.pm_warn_user_last_seen_months_ago.months.ago).pluck(:username).sort User
.where(username_lower: usernames)
.where("last_seen_at < ?", SiteSetting.pm_warn_user_last_seen_months_ago.months.ago)
.pluck(:username)
.sort
end end
private private
def educate_reply?(type) def educate_reply?(type)
replying? && replying? && @details[:topic_id] && (@topic.present? && !@topic.private_message?) &&
@details[:topic_id] && (@user.post_count >= SiteSetting.educate_until_posts) &&
(@topic.present? && !@topic.private_message?) && !UserHistory.exists_for_user?(@user, type, topic_id: @details[:topic_id])
(@user.post_count >= SiteSetting.educate_until_posts) &&
!UserHistory.exists_for_user?(@user, type, topic_id: @details[:topic_id])
end end
def creating_topic? def creating_topic?
@ -237,5 +280,4 @@ class ComposerMessagesFinder
def editing_post? def editing_post?
@details[:composer_action] == "edit" @details[:composer_action] == "edit"
end end
end end

View File

@ -9,12 +9,13 @@ module Compression
Compression::Zip.new, Compression::Zip.new,
Compression::Pipeline.new([Compression::Tar.new, Compression::Gzip.new]), Compression::Pipeline.new([Compression::Tar.new, Compression::Gzip.new]),
Compression::Gzip.new, Compression::Gzip.new,
Compression::Tar.new Compression::Tar.new,
] ]
end end
def self.engine_for(filename, strategies: default_strategies) def self.engine_for(filename, strategies: default_strategies)
strategy = strategies.detect(-> { raise UnsupportedFileExtension }) { |e| e.can_handle?(filename) } strategy =
strategies.detect(-> { raise UnsupportedFileExtension }) { |e| e.can_handle?(filename) }
new(strategy) new(strategy)
end end

View File

@ -3,12 +3,17 @@
module Compression module Compression
class Gzip < Strategy class Gzip < Strategy
def extension def extension
'.gz' ".gz"
end end
def compress(path, target_name) def compress(path, target_name)
gzip_target = sanitize_path("#{path}/#{target_name}") gzip_target = sanitize_path("#{path}/#{target_name}")
Discourse::Utils.execute_command('gzip', '-5', gzip_target, failure_message: "Failed to gzip file.") Discourse::Utils.execute_command(
"gzip",
"-5",
gzip_target,
failure_message: "Failed to gzip file.",
)
"#{gzip_target}.gz" "#{gzip_target}.gz"
end end
@ -23,7 +28,8 @@ module Compression
true true
end end
def extract_folder(_entry, _entry_path); end def extract_folder(_entry, _entry_path)
end
def get_compressed_file_stream(compressed_file_path) def get_compressed_file_stream(compressed_file_path)
gzip = Zlib::GzipReader.open(compressed_file_path) gzip = Zlib::GzipReader.open(compressed_file_path)
@ -32,7 +38,7 @@ module Compression
def build_entry_path(dest_path, _, compressed_file_path) def build_entry_path(dest_path, _, compressed_file_path)
basename = File.basename(compressed_file_path) basename = File.basename(compressed_file_path)
basename.gsub!(/#{Regexp.escape(extension)}$/, '') basename.gsub!(/#{Regexp.escape(extension)}$/, "")
File.join(dest_path, basename) File.join(dest_path, basename)
end end
@ -44,12 +50,11 @@ module Compression
remaining_size = available_size remaining_size = available_size
if ::File.exist?(entry_path) if ::File.exist?(entry_path)
raise ::Zip::DestinationFileExistsError, raise ::Zip::DestinationFileExistsError, "Destination '#{entry_path}' already exists"
"Destination '#{entry_path}' already exists"
end # Change this later. end # Change this later.
::File.open(entry_path, 'wb') do |os| ::File.open(entry_path, "wb") do |os|
buf = ''.dup buf = "".dup
while (buf = entry.read(chunk_size)) while (buf = entry.read(chunk_size))
remaining_size -= chunk_size remaining_size -= chunk_size
raise ExtractFailed if remaining_size.negative? raise ExtractFailed if remaining_size.negative?

View File

@ -7,25 +7,27 @@ module Compression
end end
def extension def extension
@strategies.reduce('') { |ext, strategy| ext += strategy.extension } @strategies.reduce("") { |ext, strategy| ext += strategy.extension }
end end
def compress(path, target_name) def compress(path, target_name)
current_target = target_name current_target = target_name
@strategies.reduce('') do |compressed_path, strategy| @strategies.reduce("") do |compressed_path, strategy|
compressed_path = strategy.compress(path, current_target) compressed_path = strategy.compress(path, current_target)
current_target = compressed_path.split('/').last current_target = compressed_path.split("/").last
compressed_path compressed_path
end end
end end
def decompress(dest_path, compressed_file_path, max_size) def decompress(dest_path, compressed_file_path, max_size)
@strategies.reverse.reduce(compressed_file_path) do |to_decompress, strategy| @strategies
next_compressed_file = strategy.decompress(dest_path, to_decompress, max_size) .reverse
FileUtils.rm_rf(to_decompress) .reduce(compressed_file_path) do |to_decompress, strategy|
next_compressed_file next_compressed_file = strategy.decompress(dest_path, to_decompress, max_size)
end FileUtils.rm_rf(to_decompress)
next_compressed_file
end
end end
end end
end end

View File

@ -18,9 +18,7 @@ module Compression
entries_of(compressed_file).each do |entry| entries_of(compressed_file).each do |entry|
entry_path = build_entry_path(sanitized_dest_path, entry, sanitized_compressed_file_path) entry_path = build_entry_path(sanitized_dest_path, entry, sanitized_compressed_file_path)
if !is_safe_path_for_extraction?(entry_path, sanitized_dest_path) next if !is_safe_path_for_extraction?(entry_path, sanitized_dest_path)
next
end
FileUtils.mkdir_p(File.dirname(entry_path)) FileUtils.mkdir_p(File.dirname(entry_path))
if is_file?(entry) if is_file?(entry)
@ -45,10 +43,10 @@ module Compression
filename.strip.tap do |name| filename.strip.tap do |name|
# NOTE: File.basename doesn't work right with Windows paths on Unix # NOTE: File.basename doesn't work right with Windows paths on Unix
# get only the filename, not the whole path # get only the filename, not the whole path
name.sub! /\A.*(\\|\/)/, '' name.sub! %r{\A.*(\\|/)}, ""
# Finally, replace all non alphanumeric, underscore # Finally, replace all non alphanumeric, underscore
# or periods with underscore # or periods with underscore
name.gsub! /[^\w\.\-]/, '_' name.gsub! /[^\w\.\-]/, "_"
end end
end end
@ -75,7 +73,7 @@ module Compression
raise DestinationFileExistsError, "Destination '#{entry_path}' already exists" raise DestinationFileExistsError, "Destination '#{entry_path}' already exists"
end end
::File.open(entry_path, 'wb') do |os| ::File.open(entry_path, "wb") do |os|
while (buf = entry.read(chunk_size)) while (buf = entry.read(chunk_size))
remaining_size -= buf.size remaining_size -= buf.size
raise ExtractFailed if remaining_size.negative? raise ExtractFailed if remaining_size.negative?

View File

@ -1,23 +1,31 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rubygems/package' require "rubygems/package"
module Compression module Compression
class Tar < Strategy class Tar < Strategy
def extension def extension
'.tar' ".tar"
end end
def compress(path, target_name) def compress(path, target_name)
tar_filename = sanitize_filename("#{target_name}.tar") tar_filename = sanitize_filename("#{target_name}.tar")
Discourse::Utils.execute_command('tar', '--create', '--file', tar_filename, target_name, failure_message: "Failed to tar file.") Discourse::Utils.execute_command(
"tar",
"--create",
"--file",
tar_filename,
target_name,
failure_message: "Failed to tar file.",
)
sanitize_path("#{path}/#{tar_filename}") sanitize_path("#{path}/#{tar_filename}")
end end
private private
def extract_folder(_entry, _entry_path); end def extract_folder(_entry, _entry_path)
end
def get_compressed_file_stream(compressed_file_path) def get_compressed_file_stream(compressed_file_path)
file_stream = IO.new(IO.sysopen(compressed_file_path)) file_stream = IO.new(IO.sysopen(compressed_file_path))

View File

@ -1,11 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'zip' require "zip"
module Compression module Compression
class Zip < Strategy class Zip < Strategy
def extension def extension
'.zip' ".zip"
end end
def compress(path, target_name) def compress(path, target_name)
@ -15,7 +15,7 @@ module Compression
::Zip::File.open(zip_filename, ::Zip::File::CREATE) do |zipfile| ::Zip::File.open(zip_filename, ::Zip::File::CREATE) do |zipfile|
if File.directory?(absolute_path) if File.directory?(absolute_path)
entries = Dir.entries(absolute_path) - %w[. ..] entries = Dir.entries(absolute_path) - %w[. ..]
write_entries(entries, absolute_path, '', zipfile) write_entries(entries, absolute_path, "", zipfile)
else else
put_into_archive(absolute_path, zipfile, target_name) put_into_archive(absolute_path, zipfile, target_name)
end end
@ -47,15 +47,14 @@ module Compression
remaining_size = available_size remaining_size = available_size
if ::File.exist?(entry_path) if ::File.exist?(entry_path)
raise ::Zip::DestinationFileExistsError, raise ::Zip::DestinationFileExistsError, "Destination '#{entry_path}' already exists"
"Destination '#{entry_path}' already exists"
end end
::File.open(entry_path, 'wb') do |os| ::File.open(entry_path, "wb") do |os|
entry.get_input_stream do |is| entry.get_input_stream do |is|
entry.set_extra_attributes_on_path(entry_path) entry.set_extra_attributes_on_path(entry_path)
buf = ''.dup buf = "".dup
while (buf = is.sysread(chunk_size, buf)) while (buf = is.sysread(chunk_size, buf))
remaining_size -= chunk_size remaining_size -= chunk_size
raise ExtractFailed if remaining_size.negative? raise ExtractFailed if remaining_size.negative?
@ -70,7 +69,7 @@ module Compression
# A helper method to make the recursion work. # A helper method to make the recursion work.
def write_entries(entries, base_path, path, zipfile) def write_entries(entries, base_path, path, zipfile)
entries.each do |e| entries.each do |e|
zipfile_path = path == '' ? e : File.join(path, e) zipfile_path = path == "" ? e : File.join(path, e)
disk_file_path = File.join(base_path, zipfile_path) disk_file_path = File.join(base_path, zipfile_path)
if File.directory? disk_file_path if File.directory? disk_file_path

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module ConfigurableUrls module ConfigurableUrls
def faq_path def faq_path
SiteSetting.faq_url.blank? ? "#{Discourse.base_path}/faq" : SiteSetting.faq_url SiteSetting.faq_url.blank? ? "#{Discourse.base_path}/faq" : SiteSetting.faq_url
end end
@ -11,7 +10,10 @@ module ConfigurableUrls
end end
def privacy_path def privacy_path
SiteSetting.privacy_policy_url.blank? ? "#{Discourse.base_path}/privacy" : SiteSetting.privacy_policy_url if SiteSetting.privacy_policy_url.blank?
"#{Discourse.base_path}/privacy"
else
SiteSetting.privacy_policy_url
end
end end
end end

View File

@ -3,7 +3,6 @@
# this class is used to track changes to an arbitrary buffer # this class is used to track changes to an arbitrary buffer
class ContentBuffer class ContentBuffer
def initialize(initial_content) def initialize(initial_content)
@initial_content = initial_content @initial_content = initial_content
@lines = @initial_content.split("\n") @lines = @initial_content.split("\n")
@ -17,7 +16,6 @@ class ContentBuffer
text = transform[:text] text = transform[:text]
if transform[:operation] == :delete if transform[:operation] == :delete
# fix first line # fix first line
l = @lines[start_row] l = @lines[start_row]
@ -32,16 +30,13 @@ class ContentBuffer
@lines[start_row] = l @lines[start_row] = l
# remove middle lines # remove middle lines
(finish_row - start_row).times do (finish_row - start_row).times { l = @lines.delete_at start_row + 1 }
l = @lines.delete_at start_row + 1
end
# fix last line # fix last line
@lines[start_row] << @lines[finish_row][finish_col - 1..-1] @lines[start_row] << @lines[finish_row][finish_col - 1..-1]
end end
if transform[:operation] == :insert if transform[:operation] == :insert
@lines[start_row].insert(start_col, text) @lines[start_row].insert(start_col, text)
split = @lines[start_row].split("\n") split = @lines[start_row].split("\n")
@ -56,7 +51,6 @@ class ContentBuffer
@lines.insert(i, "") unless @lines.length > i @lines.insert(i, "") unless @lines.length > i
@lines[i] = split[-1] + @lines[i] @lines[i] = split[-1] + @lines[i]
end end
end end
end end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'content_security_policy/builder' require "content_security_policy/builder"
require 'content_security_policy/extension' require "content_security_policy/extension"
class ContentSecurityPolicy class ContentSecurityPolicy
class << self class << self

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'content_security_policy/default' require "content_security_policy/default"
class ContentSecurityPolicy class ContentSecurityPolicy
class Builder class Builder
@ -33,7 +33,9 @@ class ContentSecurityPolicy
def <<(extension) def <<(extension)
return unless valid_extension?(extension) return unless valid_extension?(extension)
extension.each { |directive, sources| extend_directive(normalize_directive(directive), sources) } extension.each do |directive, sources|
extend_directive(normalize_directive(directive), sources)
end
end end
def build def build
@ -53,7 +55,7 @@ class ContentSecurityPolicy
private private
def normalize_directive(directive) def normalize_directive(directive)
directive.to_s.gsub('-', '_').to_sym directive.to_s.gsub("-", "_").to_sym
end end
def normalize_source(source) def normalize_source(source)

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'content_security_policy' require "content_security_policy"
class ContentSecurityPolicy class ContentSecurityPolicy
class Default class Default
@ -7,16 +7,19 @@ class ContentSecurityPolicy
def initialize(base_url:) def initialize(base_url:)
@base_url = base_url @base_url = base_url
@directives = {}.tap do |directives| @directives =
directives[:upgrade_insecure_requests] = [] if SiteSetting.force_https {}.tap do |directives|
directives[:base_uri] = [:self] directives[:upgrade_insecure_requests] = [] if SiteSetting.force_https
directives[:object_src] = [:none] directives[:base_uri] = [:self]
directives[:script_src] = script_src directives[:object_src] = [:none]
directives[:worker_src] = worker_src directives[:script_src] = script_src
directives[:report_uri] = report_uri if SiteSetting.content_security_policy_collect_reports directives[:worker_src] = worker_src
directives[:frame_ancestors] = frame_ancestors if restrict_embed? directives[
directives[:manifest_src] = ["'self'"] :report_uri
end ] = report_uri if SiteSetting.content_security_policy_collect_reports
directives[:frame_ancestors] = frame_ancestors if restrict_embed?
directives[:manifest_src] = ["'self'"]
end
end end
private private
@ -27,27 +30,34 @@ class ContentSecurityPolicy
SCRIPT_ASSET_DIRECTORIES = [ SCRIPT_ASSET_DIRECTORIES = [
# [dir, can_use_s3_cdn, can_use_cdn, for_worker] # [dir, can_use_s3_cdn, can_use_cdn, for_worker]
['/assets/', true, true, true], ["/assets/", true, true, true],
['/brotli_asset/', true, true, true], ["/brotli_asset/", true, true, true],
['/extra-locales/', false, false, false], ["/extra-locales/", false, false, false],
['/highlight-js/', false, true, false], ["/highlight-js/", false, true, false],
['/javascripts/', false, true, true], ["/javascripts/", false, true, true],
['/plugins/', false, true, true], ["/plugins/", false, true, true],
['/theme-javascripts/', false, true, false], ["/theme-javascripts/", false, true, false],
['/svg-sprite/', false, true, false], ["/svg-sprite/", false, true, false],
] ]
def script_assets(base = base_url, s3_cdn = GlobalSetting.s3_asset_cdn_url.presence || GlobalSetting.s3_cdn_url, cdn = GlobalSetting.cdn_url, worker: false) def script_assets(
SCRIPT_ASSET_DIRECTORIES.map do |dir, can_use_s3_cdn, can_use_cdn, for_worker| base = base_url,
next if worker && !for_worker s3_cdn = GlobalSetting.s3_asset_cdn_url.presence || GlobalSetting.s3_cdn_url,
if can_use_s3_cdn && s3_cdn cdn = GlobalSetting.cdn_url,
s3_cdn + dir worker: false
elsif can_use_cdn && cdn )
cdn + Discourse.base_path + dir SCRIPT_ASSET_DIRECTORIES
else .map do |dir, can_use_s3_cdn, can_use_cdn, for_worker|
base + dir next if worker && !for_worker
if can_use_s3_cdn && s3_cdn
s3_cdn + dir
elsif can_use_cdn && cdn
cdn + Discourse.base_path + dir
else
base + dir
end
end end
end.compact .compact
end end
def script_src def script_src
@ -55,7 +65,7 @@ class ContentSecurityPolicy
"#{base_url}/logs/", "#{base_url}/logs/",
"#{base_url}/sidekiq/", "#{base_url}/sidekiq/",
"#{base_url}/mini-profiler-resources/", "#{base_url}/mini-profiler-resources/",
*script_assets *script_assets,
].tap do |sources| ].tap do |sources|
sources << :report_sample if SiteSetting.content_security_policy_collect_reports sources << :report_sample if SiteSetting.content_security_policy_collect_reports
sources << :unsafe_eval if Rails.env.development? # TODO remove this once we have proper source maps in dev sources << :unsafe_eval if Rails.env.development? # TODO remove this once we have proper source maps in dev
@ -67,23 +77,25 @@ class ContentSecurityPolicy
end end
# we need analytics.js still as gtag/js is a script wrapper for it # we need analytics.js still as gtag/js is a script wrapper for it
sources << 'https://www.google-analytics.com/analytics.js' if SiteSetting.ga_universal_tracking_code.present? if SiteSetting.ga_universal_tracking_code.present?
sources << 'https://www.googletagmanager.com/gtag/js' if SiteSetting.ga_universal_tracking_code.present? && SiteSetting.ga_version == "v4_gtag" sources << "https://www.google-analytics.com/analytics.js"
end
if SiteSetting.ga_universal_tracking_code.present? && SiteSetting.ga_version == "v4_gtag"
sources << "https://www.googletagmanager.com/gtag/js"
end
if SiteSetting.gtm_container_id.present? if SiteSetting.gtm_container_id.present?
sources << 'https://www.googletagmanager.com/gtm.js' sources << "https://www.googletagmanager.com/gtm.js"
sources << "'nonce-#{ApplicationHelper.google_tag_manager_nonce}'" sources << "'nonce-#{ApplicationHelper.google_tag_manager_nonce}'"
end end
if SiteSetting.splash_screen sources << "'#{SplashScreenHelper.fingerprint}'" if SiteSetting.splash_screen
sources << "'#{SplashScreenHelper.fingerprint}'"
end
end end
end end
def worker_src def worker_src
[ [
"'self'", # For service worker "'self'", # For service worker
*script_assets(worker: true) *script_assets(worker: true),
] ]
end end
@ -92,15 +104,11 @@ class ContentSecurityPolicy
end end
def frame_ancestors def frame_ancestors
[ ["'self'", *EmbeddableHost.pluck(:host).map { |host| "https://#{host}" }]
"'self'",
*EmbeddableHost.pluck(:host).map { |host| "https://#{host}" }
]
end end
def restrict_embed? def restrict_embed?
SiteSetting.content_security_policy_frame_ancestors && SiteSetting.content_security_policy_frame_ancestors && !SiteSetting.embed_any_origin
!SiteSetting.embed_any_origin
end end
end end
end end

View File

@ -4,12 +4,12 @@ class ContentSecurityPolicy
extend self extend self
def site_setting_extension def site_setting_extension
{ script_src: SiteSetting.content_security_policy_script_src.split('|') } { script_src: SiteSetting.content_security_policy_script_src.split("|") }
end end
def path_specific_extension(path_info) def path_specific_extension(path_info)
{}.tap do |obj| {}.tap do |obj|
for_qunit_route = !Rails.env.production? && ["/qunit", "/wizard/qunit"].include?(path_info) for_qunit_route = !Rails.env.production? && %w[/qunit /wizard/qunit].include?(path_info)
for_qunit_route ||= "/theme-qunit" == path_info for_qunit_route ||= "/theme-qunit" == path_info
obj[:script_src] = :unsafe_eval if for_qunit_route obj[:script_src] = :unsafe_eval if for_qunit_route
end end
@ -23,7 +23,7 @@ class ContentSecurityPolicy
end end
end end
THEME_SETTING = 'extend_content_security_policy' THEME_SETTING = "extend_content_security_policy"
def theme_extensions(theme_id) def theme_extensions(theme_id)
key = "theme_extensions_#{theme_id}" key = "theme_extensions_#{theme_id}"
@ -37,47 +37,55 @@ class ContentSecurityPolicy
private private
def cache def cache
@cache ||= DistributedCache.new('csp_extensions') @cache ||= DistributedCache.new("csp_extensions")
end end
def find_theme_extensions(theme_id) def find_theme_extensions(theme_id)
extensions = [] extensions = []
theme_ids = Theme.transform_ids(theme_id) theme_ids = Theme.transform_ids(theme_id)
Theme.where(id: theme_ids).find_each do |theme| Theme
theme.cached_settings.each do |setting, value| .where(id: theme_ids)
extensions << build_theme_extension(value.split("|")) if setting.to_s == THEME_SETTING .find_each do |theme|
theme.cached_settings.each do |setting, value|
extensions << build_theme_extension(value.split("|")) if setting.to_s == THEME_SETTING
end
end end
end
extensions << build_theme_extension(ThemeModifierHelper.new(theme_ids: theme_ids).csp_extensions) extensions << build_theme_extension(
ThemeModifierHelper.new(theme_ids: theme_ids).csp_extensions,
html_fields = ThemeField.where(
theme_id: theme_ids,
target_id: ThemeField.basic_targets.map { |target| Theme.targets[target.to_sym] },
name: ThemeField.html_fields
) )
html_fields =
ThemeField.where(
theme_id: theme_ids,
target_id: ThemeField.basic_targets.map { |target| Theme.targets[target.to_sym] },
name: ThemeField.html_fields,
)
auto_script_src_extension = { script_src: [] } auto_script_src_extension = { script_src: [] }
html_fields.each(&:ensure_baked!) html_fields.each(&:ensure_baked!)
doc = html_fields.map(&:value_baked).join("\n") doc = html_fields.map(&:value_baked).join("\n")
Nokogiri::HTML5.fragment(doc).css('script[src]').each do |node| Nokogiri::HTML5
src = node['src'] .fragment(doc)
uri = URI(src) .css("script[src]")
.each do |node|
src = node["src"]
uri = URI(src)
next if GlobalSetting.cdn_url && src.starts_with?(GlobalSetting.cdn_url) # Ignore CDN urls (theme-javascripts) next if GlobalSetting.cdn_url && src.starts_with?(GlobalSetting.cdn_url) # Ignore CDN urls (theme-javascripts)
next if uri.host.nil? # Ignore same-domain scripts (theme-javascripts) next if uri.host.nil? # Ignore same-domain scripts (theme-javascripts)
next if uri.path.nil? # Ignore raw hosts next if uri.path.nil? # Ignore raw hosts
uri.query = nil # CSP should not include query part of url uri.query = nil # CSP should not include query part of url
uri_string = uri.to_s.sub(/^\/\//, '') # Protocol-less CSP should not have // at beginning of URL uri_string = uri.to_s.sub(%r{^//}, "") # Protocol-less CSP should not have // at beginning of URL
auto_script_src_extension[:script_src] << uri_string auto_script_src_extension[:script_src] << uri_string
rescue URI::Error rescue URI::Error
# Ignore invalid URI # Ignore invalid URI
end end
extensions << auto_script_src_extension extensions << auto_script_src_extension
@ -87,7 +95,7 @@ class ContentSecurityPolicy
def build_theme_extension(entries) def build_theme_extension(entries)
{}.tap do |extension| {}.tap do |extension|
entries.each do |entry| entries.each do |entry|
directive, source = entry.split(':', 2).map(&:strip) directive, source = entry.split(":", 2).map(&:strip)
extension[directive] ||= [] extension[directive] ||= []
extension[directive] << source extension[directive] << source

View File

@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'content_security_policy' require "content_security_policy"
class ContentSecurityPolicy class ContentSecurityPolicy
class Middleware class Middleware
@ -19,8 +19,16 @@ class ContentSecurityPolicy
theme_id = env[:resolved_theme_id] theme_id = env[:resolved_theme_id]
headers['Content-Security-Policy'] = policy(theme_id, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy headers["Content-Security-Policy"] = policy(
headers['Content-Security-Policy-Report-Only'] = policy(theme_id, base_url: base_url, path_info: env["PATH_INFO"]) if SiteSetting.content_security_policy_report_only theme_id,
base_url: base_url,
path_info: env["PATH_INFO"],
) if SiteSetting.content_security_policy
headers["Content-Security-Policy-Report-Only"] = policy(
theme_id,
base_url: base_url,
path_info: env["PATH_INFO"],
) if SiteSetting.content_security_policy_report_only
response response
end end
@ -30,7 +38,7 @@ class ContentSecurityPolicy
delegate :policy, to: :ContentSecurityPolicy delegate :policy, to: :ContentSecurityPolicy
def html_response?(headers) def html_response?(headers)
headers['Content-Type'] && headers['Content-Type'] =~ /html/ headers["Content-Type"] && headers["Content-Type"] =~ /html/
end end
end end
end end

View File

@ -7,7 +7,7 @@ class CookedPostProcessor
include CookedProcessorMixin include CookedProcessorMixin
LIGHTBOX_WRAPPER_CSS_CLASS = "lightbox-wrapper" LIGHTBOX_WRAPPER_CSS_CLASS = "lightbox-wrapper"
GIF_SOURCES_REGEXP = /(giphy|tenor)\.com\// GIF_SOURCES_REGEXP = %r{(giphy|tenor)\.com/}
attr_reader :cooking_options, :doc attr_reader :cooking_options, :doc
@ -61,25 +61,27 @@ class CookedPostProcessor
return if @post.user.blank? || !Guardian.new.can_see?(@post) return if @post.user.blank? || !Guardian.new.can_see?(@post)
BadgeGranter.grant(Badge.find(Badge::FirstEmoji), @post.user, post_id: @post.id) if has_emoji? BadgeGranter.grant(Badge.find(Badge::FirstEmoji), @post.user, post_id: @post.id) if has_emoji?
BadgeGranter.grant(Badge.find(Badge::FirstOnebox), @post.user, post_id: @post.id) if @has_oneboxes if @has_oneboxes
BadgeGranter.grant(Badge.find(Badge::FirstReplyByEmail), @post.user, post_id: @post.id) if @post.is_reply_by_email? BadgeGranter.grant(Badge.find(Badge::FirstOnebox), @post.user, post_id: @post.id)
end
if @post.is_reply_by_email?
BadgeGranter.grant(Badge.find(Badge::FirstReplyByEmail), @post.user, post_id: @post.id)
end
end end
def post_process_quotes def post_process_quotes
@doc.css("aside.quote").each do |q| @doc
post_number = q['data-post'] .css("aside.quote")
topic_id = q['data-topic'] .each do |q|
if topic_id && post_number post_number = q["data-post"]
comparer = QuoteComparer.new( topic_id = q["data-topic"]
topic_id.to_i, if topic_id && post_number
post_number.to_i, comparer = QuoteComparer.new(topic_id.to_i, post_number.to_i, q.css("blockquote").text)
q.css('blockquote').text
)
q['class'] = ((q['class'] || '') + " quote-post-not-found").strip if comparer.missing? q["class"] = ((q["class"] || "") + " quote-post-not-found").strip if comparer.missing?
q['class'] = ((q['class'] || '') + " quote-modified").strip if comparer.modified? q["class"] = ((q["class"] || "") + " quote-modified").strip if comparer.modified?
end
end end
end
end end
def remove_full_quote_on_direct_reply def remove_full_quote_on_direct_reply
@ -87,66 +89,68 @@ class CookedPostProcessor
return if @post.post_number == 1 return if @post.post_number == 1
return if @doc.xpath("aside[contains(@class, 'quote')]").size != 1 return if @doc.xpath("aside[contains(@class, 'quote')]").size != 1
previous = Post previous =
.where("post_number < ? AND topic_id = ? AND post_type = ? AND NOT hidden", @post.post_number, @post.topic_id, Post.types[:regular]) Post
.order("post_number DESC") .where(
.limit(1) "post_number < ? AND topic_id = ? AND post_type = ? AND NOT hidden",
.pluck(:cooked) @post.post_number,
.first @post.topic_id,
Post.types[:regular],
)
.order("post_number DESC")
.limit(1)
.pluck(:cooked)
.first
return if previous.blank? return if previous.blank?
previous_text = Nokogiri::HTML5::fragment(previous).text.strip previous_text = Nokogiri::HTML5.fragment(previous).text.strip
quoted_text = @doc.css("aside.quote:first-child blockquote").first&.text&.strip || "" quoted_text = @doc.css("aside.quote:first-child blockquote").first&.text&.strip || ""
return if previous_text.gsub(/(\s){2,}/, '\1') != quoted_text.gsub(/(\s){2,}/, '\1') return if previous_text.gsub(/(\s){2,}/, '\1') != quoted_text.gsub(/(\s){2,}/, '\1')
quote_regexp = /\A\s*\[quote.+\[\/quote\]/im quote_regexp = %r{\A\s*\[quote.+\[/quote\]}im
quoteless_raw = @post.raw.sub(quote_regexp, "").strip quoteless_raw = @post.raw.sub(quote_regexp, "").strip
return if @post.raw.strip == quoteless_raw return if @post.raw.strip == quoteless_raw
PostRevisor.new(@post).revise!( PostRevisor.new(@post).revise!(
Discourse.system_user, Discourse.system_user,
{ { raw: quoteless_raw, edit_reason: I18n.t(:removed_direct_reply_full_quotes) },
raw: quoteless_raw,
edit_reason: I18n.t(:removed_direct_reply_full_quotes)
},
skip_validations: true, skip_validations: true,
bypass_bump: true bypass_bump: true,
) )
end end
def extract_images def extract_images
# all images with a src attribute # all images with a src attribute
@doc.css("img[src], img[#{PrettyText::BLOCKED_HOTLINKED_SRC_ATTR}]") - @doc.css("img[src], img[#{PrettyText::BLOCKED_HOTLINKED_SRC_ATTR}]") -
# minus data images # minus data images
@doc.css("img[src^='data']") - @doc.css("img[src^='data']") -
# minus emojis # minus emojis
@doc.css("img.emoji") @doc.css("img.emoji")
end end
def extract_images_for_post def extract_images_for_post
# all images with a src attribute # all images with a src attribute
@doc.css("img[src]") - @doc.css("img[src]") -
# minus emojis # minus emojis
@doc.css("img.emoji") - @doc.css("img.emoji") -
# minus images inside quotes # minus images inside quotes
@doc.css(".quote img") - @doc.css(".quote img") -
# minus onebox site icons # minus onebox site icons
@doc.css("img.site-icon") - @doc.css("img.site-icon") -
# minus onebox avatars # minus onebox avatars
@doc.css("img.onebox-avatar") - @doc.css("img.onebox-avatar") - @doc.css("img.onebox-avatar-inline") -
@doc.css("img.onebox-avatar-inline") - # minus github onebox profile images
# minus github onebox profile images @doc.css(".onebox.githubfolder img")
@doc.css(".onebox.githubfolder img")
end end
def convert_to_link!(img) def convert_to_link!(img)
w, h = img["width"].to_i, img["height"].to_i w, h = img["width"].to_i, img["height"].to_i
user_width, user_height = (w > 0 && h > 0 && [w, h]) || user_width, user_height =
get_size_from_attributes(img) || (w > 0 && h > 0 && [w, h]) || get_size_from_attributes(img) ||
get_size_from_image_sizes(img["src"], @opts[:image_sizes]) get_size_from_image_sizes(img["src"], @opts[:image_sizes])
limit_size!(img) limit_size!(img)
@ -155,7 +159,7 @@ class CookedPostProcessor
upload = Upload.get_from_url(src) upload = Upload.get_from_url(src)
original_width, original_height = nil original_width, original_height = nil
if (upload.present?) if (upload.present?)
original_width = upload.width || 0 original_width = upload.width || 0
@ -172,12 +176,17 @@ class CookedPostProcessor
img.add_class("animated") img.add_class("animated")
end end
return if original_width <= SiteSetting.max_image_width && original_height <= SiteSetting.max_image_height if original_width <= SiteSetting.max_image_width &&
original_height <= SiteSetting.max_image_height
return
end
user_width, user_height = [original_width, original_height] if user_width.to_i <= 0 && user_height.to_i <= 0 user_width, user_height = [original_width, original_height] if user_width.to_i <= 0 &&
user_height.to_i <= 0
width, height = user_width, user_height width, height = user_width, user_height
crop = SiteSetting.min_ratio_to_crop > 0 && width.to_f / height.to_f < SiteSetting.min_ratio_to_crop crop =
SiteSetting.min_ratio_to_crop > 0 && width.to_f / height.to_f < SiteSetting.min_ratio_to_crop
if crop if crop
width, height = ImageSizer.crop(width, height) width, height = ImageSizer.crop(width, height)
@ -200,7 +209,7 @@ class CookedPostProcessor
return if upload.animated? return if upload.animated?
if img.ancestors('.onebox, .onebox-body, .quote').blank? && !img.classes.include?("onebox") if img.ancestors(".onebox, .onebox-body, .quote").blank? && !img.classes.include?("onebox")
add_lightbox!(img, original_width, original_height, upload, cropped: crop) add_lightbox!(img, original_width, original_height, upload, cropped: crop)
end end
@ -211,7 +220,7 @@ class CookedPostProcessor
def each_responsive_ratio def each_responsive_ratio
SiteSetting SiteSetting
.responsive_post_image_sizes .responsive_post_image_sizes
.split('|') .split("|")
.map(&:to_f) .map(&:to_f)
.sort .sort
.each { |r| yield r if r > 1 } .each { |r| yield r if r > 1 }
@ -239,13 +248,16 @@ class CookedPostProcessor
srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x" srcset << ", #{cooked_url} #{ratio.to_s.sub(/\.0$/, "")}x"
end end
img["srcset"] = "#{UrlHelper.cook_url(img["src"], secure: @post.with_secure_uploads?)}#{srcset}" if srcset.present? img[
"srcset"
] = "#{UrlHelper.cook_url(img["src"], secure: @post.with_secure_uploads?)}#{srcset}" if srcset.present?
end end
else else
img["src"] = upload.url img["src"] = upload.url
end end
if !@disable_dominant_color && (color = upload.dominant_color(calculate_if_missing: true).presence) if !@disable_dominant_color &&
(color = upload.dominant_color(calculate_if_missing: true).presence)
img["data-dominant-color"] = color img["data-dominant-color"] = color
end end
end end
@ -261,9 +273,7 @@ class CookedPostProcessor
a = create_link_node("lightbox", src) a = create_link_node("lightbox", src)
img.add_next_sibling(a) img.add_next_sibling(a)
if upload a["data-download-href"] = Discourse.store.download_url(upload) if upload
a["data-download-href"] = Discourse.store.download_url(upload)
end
a.add_child(img) a.add_child(img)
@ -309,48 +319,55 @@ class CookedPostProcessor
@post.update_column(:image_upload_id, upload.id) # post @post.update_column(:image_upload_id, upload.id) # post
if @post.is_first_post? # topic if @post.is_first_post? # topic
@post.topic.update_column(:image_upload_id, upload.id) @post.topic.update_column(:image_upload_id, upload.id)
extra_sizes = ThemeModifierHelper.new(theme_ids: Theme.user_selectable.pluck(:id)).topic_thumbnail_sizes extra_sizes =
ThemeModifierHelper.new(theme_ids: Theme.user_selectable.pluck(:id)).topic_thumbnail_sizes
@post.topic.generate_thumbnails!(extra_sizes: extra_sizes) @post.topic.generate_thumbnails!(extra_sizes: extra_sizes)
end end
else else
@post.update_column(:image_upload_id, nil) if @post.image_upload_id @post.update_column(:image_upload_id, nil) if @post.image_upload_id
@post.topic.update_column(:image_upload_id, nil) if @post.topic.image_upload_id && @post.is_first_post? if @post.topic.image_upload_id && @post.is_first_post?
@post.topic.update_column(:image_upload_id, nil)
end
nil nil
end end
end end
def optimize_urls def optimize_urls
%w{href data-download-href}.each do |selector| %w[href data-download-href].each do |selector|
@doc.css("a[#{selector}]").each do |a| @doc.css("a[#{selector}]").each { |a| a[selector] = UrlHelper.cook_url(a[selector].to_s) }
a[selector] = UrlHelper.cook_url(a[selector].to_s)
end
end end
%w{src}.each do |selector| %w[src].each do |selector|
@doc.css("img[#{selector}]").each do |img| @doc
custom_emoji = img["class"]&.include?("emoji-custom") && Emoji.custom?(img["title"]) .css("img[#{selector}]")
img[selector] = UrlHelper.cook_url( .each do |img|
img[selector].to_s, secure: @post.with_secure_uploads? && !custom_emoji custom_emoji = img["class"]&.include?("emoji-custom") && Emoji.custom?(img["title"])
) img[selector] = UrlHelper.cook_url(
end img[selector].to_s,
secure: @post.with_secure_uploads? && !custom_emoji,
)
end
end end
end end
def remove_user_ids def remove_user_ids
@doc.css("a[href]").each do |a| @doc
uri = begin .css("a[href]")
URI(a["href"]) .each do |a|
rescue URI::Error uri =
next begin
URI(a["href"])
rescue URI::Error
next
end
next if uri.hostname != Discourse.current_hostname
query = Rack::Utils.parse_nested_query(uri.query)
next if !query.delete("u")
uri.query = query.map { |k, v| "#{k}=#{v}" }.join("&").presence
a["href"] = uri.to_s
end end
next if uri.hostname != Discourse.current_hostname
query = Rack::Utils.parse_nested_query(uri.query)
next if !query.delete("u")
uri.query = query.map { |k, v| "#{k}=#{v}" }.join("&").presence
a["href"] = uri.to_s
end
end end
def enforce_nofollow def enforce_nofollow
@ -369,13 +386,14 @@ class CookedPostProcessor
def process_hotlinked_image(img) def process_hotlinked_image(img)
@hotlinked_map ||= @post.post_hotlinked_media.preload(:upload).map { |r| [r.url, r] }.to_h @hotlinked_map ||= @post.post_hotlinked_media.preload(:upload).map { |r| [r.url, r] }.to_h
normalized_src = PostHotlinkedMedia.normalize_src(img["src"] || img[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR]) normalized_src =
PostHotlinkedMedia.normalize_src(img["src"] || img[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR])
info = @hotlinked_map[normalized_src] info = @hotlinked_map[normalized_src]
still_an_image = true still_an_image = true
if info&.too_large? if info&.too_large?
if img.ancestors('.onebox, .onebox-body').blank? if img.ancestors(".onebox, .onebox-body").blank?
add_large_image_placeholder!(img) add_large_image_placeholder!(img)
else else
img.remove img.remove
@ -383,7 +401,7 @@ class CookedPostProcessor
still_an_image = false still_an_image = false
elsif info&.download_failed? elsif info&.download_failed?
if img.ancestors('.onebox, .onebox-body').blank? if img.ancestors(".onebox, .onebox-body").blank?
add_broken_image_placeholder!(img) add_broken_image_placeholder!(img)
else else
img.remove img.remove
@ -399,28 +417,29 @@ class CookedPostProcessor
end end
def add_blocked_hotlinked_media_placeholders def add_blocked_hotlinked_media_placeholders
@doc.css([ @doc
"[#{PrettyText::BLOCKED_HOTLINKED_SRC_ATTR}]", .css(
"[#{PrettyText::BLOCKED_HOTLINKED_SRCSET_ATTR}]", [
].join(',')).each do |el| "[#{PrettyText::BLOCKED_HOTLINKED_SRC_ATTR}]",
src = el[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR] || "[#{PrettyText::BLOCKED_HOTLINKED_SRCSET_ATTR}]",
el[PrettyText::BLOCKED_HOTLINKED_SRCSET_ATTR]&.split(',')&.first&.split(' ')&.first ].join(","),
)
.each do |el|
src =
el[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR] ||
el[PrettyText::BLOCKED_HOTLINKED_SRCSET_ATTR]&.split(",")&.first&.split(" ")&.first
if el.name == "img" if el.name == "img"
add_blocked_hotlinked_image_placeholder!(el) add_blocked_hotlinked_image_placeholder!(el)
next next
end
el = el.parent if %w[video audio].include?(el.parent.name)
el = el.parent if el.parent.classes.include?("video-container")
add_blocked_hotlinked_media_placeholder!(el, src)
end end
if ["video", "audio"].include?(el.parent.name)
el = el.parent
end
if el.parent.classes.include?("video-container")
el = el.parent
end
add_blocked_hotlinked_media_placeholder!(el, src)
end
end end
def is_svg?(img) def is_svg?(img)
@ -431,6 +450,6 @@ class CookedPostProcessor
nil nil
end end
File.extname(path) == '.svg' if path File.extname(path) == ".svg" if path
end end
end end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module CookedProcessorMixin module CookedProcessorMixin
def post_process_oneboxes def post_process_oneboxes
limit = SiteSetting.max_oneboxes_per_post - @doc.css("aside.onebox, a.inline-onebox").size limit = SiteSetting.max_oneboxes_per_post - @doc.css("aside.onebox, a.inline-onebox").size
oneboxes = {} oneboxes = {}
@ -14,7 +13,7 @@ module CookedProcessorMixin
if skip_onebox if skip_onebox
if is_onebox if is_onebox
element.remove_class('onebox') element.remove_class("onebox")
else else
remove_inline_onebox_loading_class(element) remove_inline_onebox_loading_class(element)
end end
@ -26,11 +25,13 @@ module CookedProcessorMixin
map[url] = true map[url] = true
if is_onebox if is_onebox
onebox = Oneboxer.onebox(url, onebox =
invalidate_oneboxes: !!@opts[:invalidate_oneboxes], Oneboxer.onebox(
user_id: @model&.user_id, url,
category_id: @category_id invalidate_oneboxes: !!@opts[:invalidate_oneboxes],
) user_id: @model&.user_id,
category_id: @category_id,
)
@has_oneboxes = true if onebox.present? @has_oneboxes = true if onebox.present?
onebox onebox
@ -56,7 +57,7 @@ module CookedProcessorMixin
# and wrap in a div # and wrap in a div
limit_size!(img) limit_size!(img)
next if img["class"]&.include?('onebox-avatar') next if img["class"]&.include?("onebox-avatar")
parent = parent&.parent if parent&.name == "a" parent = parent&.parent if parent&.name == "a"
parent_class = parent && parent["class"] parent_class = parent && parent["class"]
@ -84,12 +85,18 @@ module CookedProcessorMixin
if width < 64 && height < 64 if width < 64 && height < 64
img["class"] = img["class"].to_s + " onebox-full-image" img["class"] = img["class"].to_s + " onebox-full-image"
else else
img.delete('width') img.delete("width")
img.delete('height') img.delete("height")
new_parent = img.add_next_sibling("<div class='aspect-image' style='--aspect-ratio:#{width}/#{height};'/>") new_parent =
img.add_next_sibling(
"<div class='aspect-image' style='--aspect-ratio:#{width}/#{height};'/>",
)
new_parent.first.add_child(img) new_parent.first.add_child(img)
end end
elsif (parent_class&.include?("instagram-images") || parent_class&.include?("tweet-images") || parent_class&.include?("scale-images")) && width > 0 && height > 0 elsif (
parent_class&.include?("instagram-images") || parent_class&.include?("tweet-images") ||
parent_class&.include?("scale-images")
) && width > 0 && height > 0
img.remove_attribute("width") img.remove_attribute("width")
img.remove_attribute("height") img.remove_attribute("height")
parent["class"] = "aspect-image-full-size" parent["class"] = "aspect-image-full-size"
@ -98,16 +105,18 @@ module CookedProcessorMixin
end end
if @omit_nofollow || !SiteSetting.add_rel_nofollow_to_user_content if @omit_nofollow || !SiteSetting.add_rel_nofollow_to_user_content
@doc.css(".onebox-body a[rel], .onebox a[rel]").each do |a| @doc
rel_values = a['rel'].split(' ').map(&:downcase) .css(".onebox-body a[rel], .onebox a[rel]")
rel_values.delete('nofollow') .each do |a|
rel_values.delete('ugc') rel_values = a["rel"].split(" ").map(&:downcase)
if rel_values.blank? rel_values.delete("nofollow")
a.remove_attribute("rel") rel_values.delete("ugc")
else if rel_values.blank?
a["rel"] = rel_values.join(' ') a.remove_attribute("rel")
else
a["rel"] = rel_values.join(" ")
end
end end
end
end end
end end
@ -116,9 +125,9 @@ module CookedProcessorMixin
# 1) the width/height attributes # 1) the width/height attributes
# 2) the dimension from the preview (image_sizes) # 2) the dimension from the preview (image_sizes)
# 3) the dimension of the original image (HTTP request) # 3) the dimension of the original image (HTTP request)
w, h = get_size_from_attributes(img) || w, h =
get_size_from_image_sizes(img["src"], @opts[:image_sizes]) || get_size_from_attributes(img) || get_size_from_image_sizes(img["src"], @opts[:image_sizes]) ||
get_size(img["src"]) get_size(img["src"])
# limit the size of the thumbnail # limit the size of the thumbnail
img["width"], img["height"] = ImageSizer.resize(w, h) img["width"], img["height"] = ImageSizer.resize(w, h)
@ -126,7 +135,7 @@ module CookedProcessorMixin
def get_size_from_attributes(img) def get_size_from_attributes(img)
w, h = img["width"].to_i, img["height"].to_i w, h = img["width"].to_i, img["height"].to_i
return [w, h] unless w <= 0 || h <= 0 return w, h unless w <= 0 || h <= 0
# if only width or height are specified attempt to scale image # if only width or height are specified attempt to scale image
if w > 0 || h > 0 if w > 0 || h > 0
w = w.to_f w = w.to_f
@ -149,9 +158,9 @@ module CookedProcessorMixin
return unless image_sizes.present? return unless image_sizes.present?
image_sizes.each do |image_size| image_sizes.each do |image_size|
url, size = image_size[0], image_size[1] url, size = image_size[0], image_size[1]
if url && src && url.include?(src) && if url && src && url.include?(src) && size && size["width"].to_i > 0 &&
size && size["width"].to_i > 0 && size["height"].to_i > 0 size["height"].to_i > 0
return [size["width"], size["height"]] return size["width"], size["height"]
end end
end end
nil nil
@ -165,7 +174,7 @@ module CookedProcessorMixin
return @size_cache[url] if @size_cache.has_key?(url) return @size_cache[url] if @size_cache.has_key?(url)
absolute_url = url absolute_url = url
absolute_url = Discourse.base_url_no_prefix + absolute_url if absolute_url =~ /^\/[^\/]/ absolute_url = Discourse.base_url_no_prefix + absolute_url if absolute_url =~ %r{^/[^/]}
return unless absolute_url return unless absolute_url
@ -186,14 +195,13 @@ module CookedProcessorMixin
else else
@size_cache[url] = FastImage.size(absolute_url) @size_cache[url] = FastImage.size(absolute_url)
end end
rescue Zlib::BufError, URI::Error, OpenSSL::SSL::SSLError rescue Zlib::BufError, URI::Error, OpenSSL::SSL::SSLError
# FastImage.size raises BufError for some gifs, leave it. # FastImage.size raises BufError for some gifs, leave it.
end end
def is_valid_image_url?(url) def is_valid_image_url?(url)
uri = URI.parse(url) uri = URI.parse(url)
%w(http https).include? uri.scheme %w[http https].include? uri.scheme
rescue URI::Error rescue URI::Error
end end
@ -217,9 +225,12 @@ module CookedProcessorMixin
"help", "help",
I18n.t( I18n.t(
"upload.placeholders.too_large_humanized", "upload.placeholders.too_large_humanized",
max_size: ActiveSupport::NumberHelper.number_to_human_size(SiteSetting.max_image_size_kb.kilobytes) max_size:
) ActiveSupport::NumberHelper.number_to_human_size(
) SiteSetting.max_image_size_kb.kilobytes,
),
),
),
) )
# Only if the image is already linked # Only if the image is already linked
@ -227,7 +238,7 @@ module CookedProcessorMixin
parent = placeholder.parent parent = placeholder.parent
parent.add_next_sibling(placeholder) parent.add_next_sibling(placeholder)
if parent.name == 'a' && parent["href"].present? if parent.name == "a" && parent["href"].present?
if url == parent["href"] if url == parent["href"]
parent.remove parent.remove
else else
@ -295,12 +306,13 @@ module CookedProcessorMixin
end end
def process_inline_onebox(element) def process_inline_onebox(element)
inline_onebox = InlineOneboxer.lookup( inline_onebox =
element.attributes["href"].value, InlineOneboxer.lookup(
invalidate: !!@opts[:invalidate_oneboxes], element.attributes["href"].value,
user_id: @model&.user_id, invalidate: !!@opts[:invalidate_oneboxes],
category_id: @category_id user_id: @model&.user_id,
) category_id: @category_id,
)
if title = inline_onebox&.dig(:title) if title = inline_onebox&.dig(:title)
element.children = CGI.escapeHTML(title) element.children = CGI.escapeHTML(title)

View File

@ -4,7 +4,7 @@ module CrawlerDetection
WAYBACK_MACHINE_URL = "archive.org" WAYBACK_MACHINE_URL = "archive.org"
def self.to_matcher(string, type: nil) def self.to_matcher(string, type: nil)
escaped = string.split('|').map { |agent| Regexp.escape(agent) }.join('|') escaped = string.split("|").map { |agent| Regexp.escape(agent) }.join("|")
if type == :real && Rails.env == "test" if type == :real && Rails.env == "test"
# we need this bypass so we properly render views # we need this bypass so we properly render views
@ -15,18 +15,33 @@ module CrawlerDetection
end end
def self.crawler?(user_agent, via_header = nil) def self.crawler?(user_agent, via_header = nil)
return true if user_agent.nil? || user_agent&.include?(WAYBACK_MACHINE_URL) || via_header&.include?(WAYBACK_MACHINE_URL) if user_agent.nil? || user_agent&.include?(WAYBACK_MACHINE_URL) ||
via_header&.include?(WAYBACK_MACHINE_URL)
return true
end
# this is done to avoid regenerating regexes # this is done to avoid regenerating regexes
@non_crawler_matchers ||= {} @non_crawler_matchers ||= {}
@matchers ||= {} @matchers ||= {}
possibly_real = (@non_crawler_matchers[SiteSetting.non_crawler_user_agents] ||= to_matcher(SiteSetting.non_crawler_user_agents, type: :real)) possibly_real =
(
@non_crawler_matchers[SiteSetting.non_crawler_user_agents] ||= to_matcher(
SiteSetting.non_crawler_user_agents,
type: :real,
)
)
if user_agent.match?(possibly_real) if user_agent.match?(possibly_real)
known_bots = (@matchers[SiteSetting.crawler_user_agents] ||= to_matcher(SiteSetting.crawler_user_agents)) known_bots =
(@matchers[SiteSetting.crawler_user_agents] ||= to_matcher(SiteSetting.crawler_user_agents))
if user_agent.match?(known_bots) if user_agent.match?(known_bots)
bypass = (@matchers[SiteSetting.crawler_check_bypass_agents] ||= to_matcher(SiteSetting.crawler_check_bypass_agents)) bypass =
(
@matchers[SiteSetting.crawler_check_bypass_agents] ||= to_matcher(
SiteSetting.crawler_check_bypass_agents,
)
)
!user_agent.match?(bypass) !user_agent.match?(bypass)
else else
false false
@ -34,30 +49,40 @@ module CrawlerDetection
else else
true true
end end
end end
def self.show_browser_update?(user_agent) def self.show_browser_update?(user_agent)
return false if SiteSetting.browser_update_user_agents.blank? return false if SiteSetting.browser_update_user_agents.blank?
@browser_update_matchers ||= {} @browser_update_matchers ||= {}
matcher = @browser_update_matchers[SiteSetting.browser_update_user_agents] ||= to_matcher(SiteSetting.browser_update_user_agents) matcher =
@browser_update_matchers[SiteSetting.browser_update_user_agents] ||= to_matcher(
SiteSetting.browser_update_user_agents,
)
user_agent.match?(matcher) user_agent.match?(matcher)
end end
# Given a user_agent that returns true from crawler?, should its request be allowed? # Given a user_agent that returns true from crawler?, should its request be allowed?
def self.allow_crawler?(user_agent) def self.allow_crawler?(user_agent)
return true if SiteSetting.allowed_crawler_user_agents.blank? && if SiteSetting.allowed_crawler_user_agents.blank? &&
SiteSetting.blocked_crawler_user_agents.blank? SiteSetting.blocked_crawler_user_agents.blank?
return true
end
@allowlisted_matchers ||= {} @allowlisted_matchers ||= {}
@blocklisted_matchers ||= {} @blocklisted_matchers ||= {}
if SiteSetting.allowed_crawler_user_agents.present? if SiteSetting.allowed_crawler_user_agents.present?
allowlisted = @allowlisted_matchers[SiteSetting.allowed_crawler_user_agents] ||= to_matcher(SiteSetting.allowed_crawler_user_agents) allowlisted =
@allowlisted_matchers[SiteSetting.allowed_crawler_user_agents] ||= to_matcher(
SiteSetting.allowed_crawler_user_agents,
)
!user_agent.nil? && user_agent.match?(allowlisted) !user_agent.nil? && user_agent.match?(allowlisted)
else else
blocklisted = @blocklisted_matchers[SiteSetting.blocked_crawler_user_agents] ||= to_matcher(SiteSetting.blocked_crawler_user_agents) blocklisted =
@blocklisted_matchers[SiteSetting.blocked_crawler_user_agents] ||= to_matcher(
SiteSetting.blocked_crawler_user_agents,
)
user_agent.nil? || !user_agent.match?(blocklisted) user_agent.nil? || !user_agent.match?(blocklisted)
end end
end end

View File

@ -2,7 +2,8 @@
# Provides a way to check a CSRF token outside of a controller # Provides a way to check a CSRF token outside of a controller
class CSRFTokenVerifier class CSRFTokenVerifier
class InvalidCSRFToken < StandardError; end class InvalidCSRFToken < StandardError
end
include ActiveSupport::Configurable include ActiveSupport::Configurable
include ActionController::RequestForgeryProtection include ActionController::RequestForgeryProtection
@ -18,9 +19,7 @@ class CSRFTokenVerifier
def call(env) def call(env)
@request = ActionDispatch::Request.new(env.dup) @request = ActionDispatch::Request.new(env.dup)
unless verified_request? raise InvalidCSRFToken unless verified_request?
raise InvalidCSRFToken
end
end end
public :form_authenticity_token public :form_authenticity_token

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module CurrentUser module CurrentUser
def self.has_auth_cookie?(env) def self.has_auth_cookie?(env)
Discourse.current_user_provider.new(env).has_auth_cookie? Discourse.current_user_provider.new(env).has_auth_cookie?
end end
@ -45,5 +44,4 @@ module CurrentUser
def current_user_provider def current_user_provider
@current_user_provider ||= Discourse.current_user_provider.new(request.env) @current_user_provider ||= Discourse.current_user_provider.new(request.env)
end end
end end

View File

@ -3,7 +3,6 @@
require "migration/base_dropper" require "migration/base_dropper"
class DbHelper class DbHelper
REMAP_SQL ||= <<~SQL REMAP_SQL ||= <<~SQL
SELECT table_name::text, column_name::text, character_maximum_length SELECT table_name::text, column_name::text, character_maximum_length
FROM information_schema.columns FROM information_schema.columns
@ -19,24 +18,33 @@ class DbHelper
WHERE trigger_name LIKE '%_readonly' WHERE trigger_name LIKE '%_readonly'
SQL SQL
TRUNCATABLE_COLUMNS ||= [ TRUNCATABLE_COLUMNS ||= ["topic_links.url"]
'topic_links.url'
]
def self.remap(from, to, anchor_left: false, anchor_right: false, excluded_tables: [], verbose: false) def self.remap(
like = "#{anchor_left ? '' : "%"}#{from}#{anchor_right ? '' : "%"}" from,
to,
anchor_left: false,
anchor_right: false,
excluded_tables: [],
verbose: false
)
like = "#{anchor_left ? "" : "%"}#{from}#{anchor_right ? "" : "%"}"
text_columns = find_text_columns(excluded_tables) text_columns = find_text_columns(excluded_tables)
text_columns.each do |table, columns| text_columns.each do |table, columns|
set = columns.map do |column| set =
replace = "REPLACE(\"#{column[:name]}\", :from, :to)" columns
replace = truncate(replace, table, column) .map do |column|
"\"#{column[:name]}\" = #{replace}" replace = "REPLACE(\"#{column[:name]}\", :from, :to)"
end.join(", ") replace = truncate(replace, table, column)
"\"#{column[:name]}\" = #{replace}"
end
.join(", ")
where = columns.map do |column| where =
"\"#{column[:name]}\" IS NOT NULL AND \"#{column[:name]}\" LIKE :like" columns
end.join(" OR ") .map { |column| "\"#{column[:name]}\" IS NOT NULL AND \"#{column[:name]}\" LIKE :like" }
.join(" OR ")
rows = DB.exec(<<~SQL, from: from, to: to, like: like) rows = DB.exec(<<~SQL, from: from, to: to, like: like)
UPDATE \"#{table}\" UPDATE \"#{table}\"
@ -50,19 +58,32 @@ class DbHelper
finish! finish!
end end
def self.regexp_replace(pattern, replacement, flags: "gi", match: "~*", excluded_tables: [], verbose: false) def self.regexp_replace(
pattern,
replacement,
flags: "gi",
match: "~*",
excluded_tables: [],
verbose: false
)
text_columns = find_text_columns(excluded_tables) text_columns = find_text_columns(excluded_tables)
text_columns.each do |table, columns| text_columns.each do |table, columns|
set = columns.map do |column| set =
replace = "REGEXP_REPLACE(\"#{column[:name]}\", :pattern, :replacement, :flags)" columns
replace = truncate(replace, table, column) .map do |column|
"\"#{column[:name]}\" = #{replace}" replace = "REGEXP_REPLACE(\"#{column[:name]}\", :pattern, :replacement, :flags)"
end.join(", ") replace = truncate(replace, table, column)
"\"#{column[:name]}\" = #{replace}"
end
.join(", ")
where = columns.map do |column| where =
"\"#{column[:name]}\" IS NOT NULL AND \"#{column[:name]}\" #{match} :pattern" columns
end.join(" OR ") .map do |column|
"\"#{column[:name]}\" IS NOT NULL AND \"#{column[:name]}\" #{match} :pattern"
end
.join(" OR ")
rows = DB.exec(<<~SQL, pattern: pattern, replacement: replacement, flags: flags, match: match) rows = DB.exec(<<~SQL, pattern: pattern, replacement: replacement, flags: flags, match: match)
UPDATE \"#{table}\" UPDATE \"#{table}\"
@ -78,23 +99,25 @@ class DbHelper
def self.find(needle, anchor_left: false, anchor_right: false, excluded_tables: []) def self.find(needle, anchor_left: false, anchor_right: false, excluded_tables: [])
found = {} found = {}
like = "#{anchor_left ? '' : "%"}#{needle}#{anchor_right ? '' : "%"}" like = "#{anchor_left ? "" : "%"}#{needle}#{anchor_right ? "" : "%"}"
DB.query(REMAP_SQL).each do |r| DB
next if excluded_tables.include?(r.table_name) .query(REMAP_SQL)
.each do |r|
next if excluded_tables.include?(r.table_name)
rows = DB.query(<<~SQL, like: like) rows = DB.query(<<~SQL, like: like)
SELECT \"#{r.column_name}\" SELECT \"#{r.column_name}\"
FROM \"#{r.table_name}\" FROM \"#{r.table_name}\"
WHERE \""#{r.column_name}"\" LIKE :like WHERE \""#{r.column_name}"\" LIKE :like
SQL SQL
if rows.size > 0 if rows.size > 0
found["#{r.table_name}.#{r.column_name}"] = rows.map do |row| found["#{r.table_name}.#{r.column_name}"] = rows.map do |row|
row.public_send(r.column_name) row.public_send(r.column_name)
end
end end
end end
end
found found
end end
@ -112,16 +135,21 @@ class DbHelper
triggers = DB.query(TRIGGERS_SQL).map(&:trigger_name).to_set triggers = DB.query(TRIGGERS_SQL).map(&:trigger_name).to_set
text_columns = Hash.new { |h, k| h[k] = [] } text_columns = Hash.new { |h, k| h[k] = [] }
DB.query(REMAP_SQL).each do |r| DB
next if excluded_tables.include?(r.table_name) || .query(REMAP_SQL)
triggers.include?(Migration::BaseDropper.readonly_trigger_name(r.table_name, r.column_name)) || .each do |r|
triggers.include?(Migration::BaseDropper.readonly_trigger_name(r.table_name)) if excluded_tables.include?(r.table_name) ||
triggers.include?(
Migration::BaseDropper.readonly_trigger_name(r.table_name, r.column_name),
) || triggers.include?(Migration::BaseDropper.readonly_trigger_name(r.table_name))
next
end
text_columns[r.table_name] << { text_columns[r.table_name] << {
name: r.column_name, name: r.column_name,
max_length: r.character_maximum_length max_length: r.character_maximum_length,
} }
end end
text_columns text_columns
end end

View File

@ -1,26 +1,22 @@
# frozen_string_literal: true # frozen_string_literal: true
module Demon; end module Demon
end
# intelligent fork based demonizer # intelligent fork based demonizer
class Demon::Base class Demon::Base
def self.demons def self.demons
@demons @demons
end end
def self.start(count = 1, verbose: false) def self.start(count = 1, verbose: false)
@demons ||= {} @demons ||= {}
count.times do |i| count.times { |i| (@demons["#{prefix}_#{i}"] ||= new(i, verbose: verbose)).start }
(@demons["#{prefix}_#{i}"] ||= new(i, verbose: verbose)).start
end
end end
def self.stop def self.stop
return unless @demons return unless @demons
@demons.values.each do |demon| @demons.values.each { |demon| demon.stop }
demon.stop
end
end end
def self.restart def self.restart
@ -32,16 +28,12 @@ class Demon::Base
end end
def self.ensure_running def self.ensure_running
@demons.values.each do |demon| @demons.values.each { |demon| demon.ensure_running }
demon.ensure_running
end
end end
def self.kill(signal) def self.kill(signal)
return unless @demons return unless @demons
@demons.values.each do |demon| @demons.values.each { |demon| demon.kill(signal) }
demon.kill(signal)
end
end end
attr_reader :pid, :parent_pid, :started, :index attr_reader :pid, :parent_pid, :started, :index
@ -83,18 +75,27 @@ class Demon::Base
if @pid if @pid
Process.kill(stop_signal, @pid) Process.kill(stop_signal, @pid)
wait_for_stop = lambda { wait_for_stop =
timeout = @stop_timeout lambda do
timeout = @stop_timeout
while alive? && timeout > 0 while alive? && timeout > 0
timeout -= (@stop_timeout / 10.0) timeout -= (@stop_timeout / 10.0)
sleep(@stop_timeout / 10.0) sleep(@stop_timeout / 10.0)
Process.waitpid(@pid, Process::WNOHANG) rescue -1 begin
Process.waitpid(@pid, Process::WNOHANG)
rescue StandardError
-1
end
end
begin
Process.waitpid(@pid, Process::WNOHANG)
rescue StandardError
-1
end
end end
Process.waitpid(@pid, Process::WNOHANG) rescue -1
}
wait_for_stop.call wait_for_stop.call
if alive? if alive?
@ -118,7 +119,12 @@ class Demon::Base
return return
end end
dead = Process.waitpid(@pid, Process::WNOHANG) rescue -1 dead =
begin
Process.waitpid(@pid, Process::WNOHANG)
rescue StandardError
-1
end
if dead if dead
STDERR.puts "Detected dead worker #{@pid}, restarting..." STDERR.puts "Detected dead worker #{@pid}, restarting..."
@pid = nil @pid = nil
@ -141,21 +147,20 @@ class Demon::Base
end end
def run def run
@pid = fork do @pid =
Process.setproctitle("discourse #{self.class.prefix}") fork do
monitor_parent Process.setproctitle("discourse #{self.class.prefix}")
establish_app monitor_parent
after_fork establish_app
end after_fork
end
write_pid_file write_pid_file
end end
def already_running? def already_running?
if File.exist? pid_file if File.exist? pid_file
pid = File.read(pid_file).to_i pid = File.read(pid_file).to_i
if Demon::Base.alive?(pid) return pid if Demon::Base.alive?(pid)
return pid
end
end end
nil nil
@ -164,24 +169,20 @@ class Demon::Base
def self.alive?(pid) def self.alive?(pid)
Process.kill(0, pid) Process.kill(0, pid)
true true
rescue rescue StandardError
false false
end end
private private
def verbose(msg) def verbose(msg)
if @verbose puts msg if @verbose
puts msg
end
end end
def write_pid_file def write_pid_file
verbose("writing pid file #{pid_file} for #{@pid}") verbose("writing pid file #{pid_file} for #{@pid}")
FileUtils.mkdir_p(@rails_root + "tmp/pids") FileUtils.mkdir_p(@rails_root + "tmp/pids")
File.open(pid_file, 'w') do |f| File.open(pid_file, "w") { |f| f.write(@pid) }
f.write(@pid)
end
end end
def delete_pid_file def delete_pid_file

View File

@ -36,15 +36,20 @@ class Demon::EmailSync < ::Demon::Base
status = nil status = nil
idle = false idle = false
while @running && group.reload.imap_mailbox_name.present? do while @running && group.reload.imap_mailbox_name.present?
ImapSyncLog.debug("Processing mailbox for group #{group.name} in db #{db}", group) ImapSyncLog.debug("Processing mailbox for group #{group.name} in db #{db}", group)
status = syncer.process( status =
idle: syncer.can_idle? && status && status[:remaining] == 0, syncer.process(
old_emails_limit: status && status[:remaining] > 0 ? 0 : nil, idle: syncer.can_idle? && status && status[:remaining] == 0,
) old_emails_limit: status && status[:remaining] > 0 ? 0 : nil,
)
if !syncer.can_idle? && status[:remaining] == 0 if !syncer.can_idle? && status[:remaining] == 0
ImapSyncLog.debug("Going to sleep for group #{group.name} in db #{db} to wait for new emails", group, db: false) ImapSyncLog.debug(
"Going to sleep for group #{group.name} in db #{db} to wait for new emails",
group,
db: false,
)
# Thread goes into sleep for a bit so it is better to return any # Thread goes into sleep for a bit so it is better to return any
# connection back to the pool. # connection back to the pool.
@ -66,11 +71,7 @@ class Demon::EmailSync < ::Demon::Base
# synchronization primitives available anyway). # synchronization primitives available anyway).
@running = false @running = false
@sync_data.each do |db, sync_data| @sync_data.each { |db, sync_data| sync_data.each { |_, data| kill_and_disconnect!(data) } }
sync_data.each do |_, data|
kill_and_disconnect!(data)
end
end
exit 0 exit 0
end end
@ -89,9 +90,9 @@ class Demon::EmailSync < ::Demon::Base
@sync_data = {} @sync_data = {}
@sync_lock = Mutex.new @sync_lock = Mutex.new
trap('INT') { kill_threads } trap("INT") { kill_threads }
trap('TERM') { kill_threads } trap("TERM") { kill_threads }
trap('HUP') { kill_threads } trap("HUP") { kill_threads }
while @running while @running
Discourse.redis.set(HEARTBEAT_KEY, Time.now.to_i, ex: HEARTBEAT_INTERVAL) Discourse.redis.set(HEARTBEAT_KEY, Time.now.to_i, ex: HEARTBEAT_INTERVAL)
@ -101,9 +102,7 @@ class Demon::EmailSync < ::Demon::Base
@sync_data.filter! do |db, sync_data| @sync_data.filter! do |db, sync_data|
next true if all_dbs.include?(db) next true if all_dbs.include?(db)
sync_data.each do |_, data| sync_data.each { |_, data| kill_and_disconnect!(data) }
kill_and_disconnect!(data)
end
false false
end end
@ -121,7 +120,10 @@ class Demon::EmailSync < ::Demon::Base
next true if groups[group_id] && data[:thread]&.alive? && !data[:syncer]&.disconnected? next true if groups[group_id] && data[:thread]&.alive? && !data[:syncer]&.disconnected?
if !groups[group_id] if !groups[group_id]
ImapSyncLog.warn("Killing thread for group because mailbox is no longer synced", group_id) ImapSyncLog.warn(
"Killing thread for group because mailbox is no longer synced",
group_id,
)
else else
ImapSyncLog.warn("Thread for group is dead", group_id) ImapSyncLog.warn("Thread for group is dead", group_id)
end end
@ -133,12 +135,13 @@ class Demon::EmailSync < ::Demon::Base
# Spawn new threads for groups that are now synchronized. # Spawn new threads for groups that are now synchronized.
groups.each do |group_id, group| groups.each do |group_id, group|
if !@sync_data[db][group_id] if !@sync_data[db][group_id]
ImapSyncLog.debug("Starting thread for group #{group.name} mailbox #{group.imap_mailbox_name}", group, db: false) ImapSyncLog.debug(
"Starting thread for group #{group.name} mailbox #{group.imap_mailbox_name}",
group,
db: false,
)
@sync_data[db][group_id] = { @sync_data[db][group_id] = { thread: start_thread(db, group), syncer: nil }
thread: start_thread(db, group),
syncer: nil
}
end end
end end
end end

View File

@ -3,7 +3,6 @@
require "demon/base" require "demon/base"
class Demon::RailsAutospec < Demon::Base class Demon::RailsAutospec < Demon::Base
def self.prefix def self.prefix
"rails-autospec" "rails-autospec"
end end
@ -17,15 +16,10 @@ class Demon::RailsAutospec < Demon::Base
def after_fork def after_fork
require "rack" require "rack"
ENV["RAILS_ENV"] = "test" ENV["RAILS_ENV"] = "test"
Rack::Server.start( Rack::Server.start(config: "config.ru", AccessLog: [], Port: ENV["TEST_SERVER_PORT"] || 60_099)
config: "config.ru",
AccessLog: [],
Port: ENV["TEST_SERVER_PORT"] || 60099,
)
rescue => e rescue => e
STDERR.puts e.message STDERR.puts e.message
STDERR.puts e.backtrace.join("\n") STDERR.puts e.backtrace.join("\n")
exit 1 exit 1
end end
end end

View File

@ -3,7 +3,6 @@
require "demon/base" require "demon/base"
class Demon::Sidekiq < ::Demon::Base class Demon::Sidekiq < ::Demon::Base
def self.prefix def self.prefix
"sidekiq" "sidekiq"
end end
@ -26,7 +25,7 @@ class Demon::Sidekiq < ::Demon::Base
Demon::Sidekiq.after_fork&.call Demon::Sidekiq.after_fork&.call
puts "Loading Sidekiq in process id #{Process.pid}" puts "Loading Sidekiq in process id #{Process.pid}"
require 'sidekiq/cli' require "sidekiq/cli"
cli = Sidekiq::CLI.instance cli = Sidekiq::CLI.instance
# Unicorn uses USR1 to indicate that log files have been rotated # Unicorn uses USR1 to indicate that log files have been rotated
@ -38,10 +37,10 @@ class Demon::Sidekiq < ::Demon::Base
options = ["-c", GlobalSetting.sidekiq_workers.to_s] options = ["-c", GlobalSetting.sidekiq_workers.to_s]
[['critical', 8], ['default', 4], ['low', 2], ['ultra_low', 1]].each do |queue_name, weight| [["critical", 8], ["default", 4], ["low", 2], ["ultra_low", 1]].each do |queue_name, weight|
custom_queue_hostname = ENV["UNICORN_SIDEKIQ_#{queue_name.upcase}_QUEUE_HOSTNAME"] custom_queue_hostname = ENV["UNICORN_SIDEKIQ_#{queue_name.upcase}_QUEUE_HOSTNAME"]
if !custom_queue_hostname || custom_queue_hostname.split(',').include?(Discourse.os_hostname) if !custom_queue_hostname || custom_queue_hostname.split(",").include?(Discourse.os_hostname)
options << "-q" options << "-q"
options << "#{queue_name},#{weight}" options << "#{queue_name},#{weight}"
end end
@ -49,7 +48,7 @@ class Demon::Sidekiq < ::Demon::Base
# Sidekiq not as high priority as web, in this environment it is forked so a web is very # Sidekiq not as high priority as web, in this environment it is forked so a web is very
# likely running # likely running
Discourse::Utils.execute_command('renice', '-n', '5', '-p', Process.pid.to_s) Discourse::Utils.execute_command("renice", "-n", "5", "-p", Process.pid.to_s)
cli.parse(options) cli.parse(options)
load Rails.root + "config/initializers/100-sidekiq.rb" load Rails.root + "config/initializers/100-sidekiq.rb"
@ -59,5 +58,4 @@ class Demon::Sidekiq < ::Demon::Base
STDERR.puts e.backtrace.join("\n") STDERR.puts e.backtrace.join("\n")
exit 1 exit 1
end end
end end

View File

@ -1,24 +1,23 @@
# frozen_string_literal: true # frozen_string_literal: true
module DirectoryHelper module DirectoryHelper
def tmp_directory(prefix) def tmp_directory(prefix)
directory_cache[prefix] ||= begin directory_cache[prefix] ||= begin
f = File.join(Rails.root, 'tmp', Time.now.strftime("#{prefix}%Y%m%d%H%M%S")) f = File.join(Rails.root, "tmp", Time.now.strftime("#{prefix}%Y%m%d%H%M%S"))
FileUtils.mkdir_p(f) unless Dir[f].present? FileUtils.mkdir_p(f) unless Dir[f].present?
f f
end end
end end
def remove_tmp_directory(prefix) def remove_tmp_directory(prefix)
tmp_directory_name = directory_cache[prefix] || '' tmp_directory_name = directory_cache[prefix] || ""
directory_cache.delete(prefix) directory_cache.delete(prefix)
FileUtils.rm_rf(tmp_directory_name) if Dir[tmp_directory_name].present? FileUtils.rm_rf(tmp_directory_name) if Dir[tmp_directory_name].present?
end end
private private
def directory_cache def directory_cache
@directory_cache ||= {} @directory_cache ||= {}
end end
end end

View File

@ -1,16 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'cache' require "cache"
require 'open3' require "open3"
require 'plugin/instance' require "plugin/instance"
require 'version' require "version"
module Discourse module Discourse
DB_POST_MIGRATE_PATH ||= "db/post_migrate" DB_POST_MIGRATE_PATH ||= "db/post_migrate"
REQUESTED_HOSTNAME ||= "REQUESTED_HOSTNAME" REQUESTED_HOSTNAME ||= "REQUESTED_HOSTNAME"
class Utils class Utils
URI_REGEXP ||= URI.regexp(%w{http https}) URI_REGEXP ||= URI.regexp(%w[http https])
# Usage: # Usage:
# Discourse::Utils.execute_command("pwd", chdir: 'mydirectory') # Discourse::Utils.execute_command("pwd", chdir: 'mydirectory')
@ -22,7 +22,9 @@ module Discourse
runner = CommandRunner.new(**args) runner = CommandRunner.new(**args)
if block_given? if block_given?
raise RuntimeError.new("Cannot pass command and block to execute_command") if command.present? if command.present?
raise RuntimeError.new("Cannot pass command and block to execute_command")
end
yield runner yield runner
else else
runner.exec(*command) runner.exec(*command)
@ -33,33 +35,32 @@ module Discourse
logs.join("\n") logs.join("\n")
end end
def self.logs_markdown(logs, user:, filename: 'log.txt') def self.logs_markdown(logs, user:, filename: "log.txt")
# Reserve 250 characters for the rest of the text # Reserve 250 characters for the rest of the text
max_logs_length = SiteSetting.max_post_length - 250 max_logs_length = SiteSetting.max_post_length - 250
pretty_logs = Discourse::Utils.pretty_logs(logs) pretty_logs = Discourse::Utils.pretty_logs(logs)
# If logs are short, try to inline them # If logs are short, try to inline them
if pretty_logs.size < max_logs_length return <<~TEXT if pretty_logs.size < max_logs_length
return <<~TEXT
```text ```text
#{pretty_logs} #{pretty_logs}
``` ```
TEXT TEXT
end
# Try to create an upload for the logs # Try to create an upload for the logs
upload = Dir.mktmpdir do |dir| upload =
File.write(File.join(dir, filename), pretty_logs) Dir.mktmpdir do |dir|
zipfile = Compression::Zip.new.compress(dir, filename) File.write(File.join(dir, filename), pretty_logs)
File.open(zipfile) do |file| zipfile = Compression::Zip.new.compress(dir, filename)
UploadCreator.new( File.open(zipfile) do |file|
file, UploadCreator.new(
File.basename(zipfile), file,
type: 'backup_logs', File.basename(zipfile),
for_export: 'true' type: "backup_logs",
).create_for(user.id) for_export: "true",
).create_for(user.id)
end
end end
end
if upload.persisted? if upload.persisted?
return UploadMarkdown.new(upload).attachment_markdown return UploadMarkdown.new(upload).attachment_markdown
@ -82,8 +83,8 @@ module Discourse
rescue Errno::ENOENT rescue Errno::ENOENT
end end
FileUtils.mkdir_p(File.join(Rails.root, 'tmp')) FileUtils.mkdir_p(File.join(Rails.root, "tmp"))
temp_destination = File.join(Rails.root, 'tmp', SecureRandom.hex) temp_destination = File.join(Rails.root, "tmp", SecureRandom.hex)
File.open(temp_destination, "w") do |fd| File.open(temp_destination, "w") do |fd|
fd.write(contents) fd.write(contents)
@ -101,9 +102,9 @@ module Discourse
rescue Errno::ENOENT, Errno::EINVAL rescue Errno::ENOENT, Errno::EINVAL
end end
FileUtils.mkdir_p(File.join(Rails.root, 'tmp')) FileUtils.mkdir_p(File.join(Rails.root, "tmp"))
temp_destination = File.join(Rails.root, 'tmp', SecureRandom.hex) temp_destination = File.join(Rails.root, "tmp", SecureRandom.hex)
execute_command('ln', '-s', source, temp_destination) execute_command("ln", "-s", source, temp_destination)
FileUtils.mv(temp_destination, destination) FileUtils.mv(temp_destination, destination)
nil nil
@ -127,13 +128,22 @@ module Discourse
end end
def exec(*command, **exec_params) def exec(*command, **exec_params)
raise RuntimeError.new("Cannot specify same parameters at block and command level") if (@init_params.keys & exec_params.keys).present? if (@init_params.keys & exec_params.keys).present?
raise RuntimeError.new("Cannot specify same parameters at block and command level")
end
execute_command(*command, **@init_params.merge(exec_params)) execute_command(*command, **@init_params.merge(exec_params))
end end
private private
def execute_command(*command, timeout: nil, failure_message: "", success_status_codes: [0], chdir: ".", unsafe_shell: false) def execute_command(
*command,
timeout: nil,
failure_message: "",
success_status_codes: [0],
chdir: ".",
unsafe_shell: false
)
env = nil env = nil
env = command.shift if command[0].is_a?(Hash) env = command.shift if command[0].is_a?(Hash)
@ -156,11 +166,11 @@ module Discourse
if !status.exited? || !success_status_codes.include?(status.exitstatus) if !status.exited? || !success_status_codes.include?(status.exitstatus)
failure_message = "#{failure_message}\n" if !failure_message.blank? failure_message = "#{failure_message}\n" if !failure_message.blank?
raise CommandError.new( raise CommandError.new(
"#{caller[0]}: #{failure_message}#{stderr}", "#{caller[0]}: #{failure_message}#{stderr}",
stdout: stdout, stdout: stdout,
stderr: stderr, stderr: stderr,
status: status status: status,
) )
end end
stdout stdout
@ -195,33 +205,32 @@ module Discourse
# mini_scheduler direct reporting # mini_scheduler direct reporting
if Hash === job if Hash === job
job_class = job["class"] job_class = job["class"]
if job_class job_exception_stats[job_class] += 1 if job_class
job_exception_stats[job_class] += 1
end
end end
# internal reporting # internal reporting
if job.class == Class && ::Jobs::Base > job job_exception_stats[job] += 1 if job.class == Class && ::Jobs::Base > job
job_exception_stats[job] += 1
end
cm = RailsMultisite::ConnectionManagement cm = RailsMultisite::ConnectionManagement
parent_logger.handle_exception(ex, { parent_logger.handle_exception(
current_db: cm.current_db, ex,
current_hostname: cm.current_hostname { current_db: cm.current_db, current_hostname: cm.current_hostname }.merge(context),
}.merge(context)) )
raise ex if Rails.env.test? raise ex if Rails.env.test?
end end
# Expected less matches than what we got in a find # Expected less matches than what we got in a find
class TooManyMatches < StandardError; end class TooManyMatches < StandardError
end
# When they try to do something they should be logged in for # When they try to do something they should be logged in for
class NotLoggedIn < StandardError; end class NotLoggedIn < StandardError
end
# When the input is somehow bad # When the input is somehow bad
class InvalidParameters < StandardError; end class InvalidParameters < StandardError
end
# When they don't have permission to do something # When they don't have permission to do something
class InvalidAccess < StandardError class InvalidAccess < StandardError
@ -249,7 +258,13 @@ module Discourse
attr_reader :original_path attr_reader :original_path
attr_reader :custom_message attr_reader :custom_message
def initialize(msg = nil, status: 404, check_permalinks: false, original_path: nil, custom_message: nil) def initialize(
msg = nil,
status: 404,
check_permalinks: false,
original_path: nil,
custom_message: nil
)
super(msg) super(msg)
@status = status @status = status
@ -260,27 +275,33 @@ module Discourse
end end
# When a setting is missing # When a setting is missing
class SiteSettingMissing < StandardError; end class SiteSettingMissing < StandardError
end
# When ImageMagick is missing # When ImageMagick is missing
class ImageMagickMissing < StandardError; end class ImageMagickMissing < StandardError
end
# When read-only mode is enabled # When read-only mode is enabled
class ReadOnly < StandardError; end class ReadOnly < StandardError
end
# Cross site request forgery # Cross site request forgery
class CSRF < StandardError; end class CSRF < StandardError
end
class Deprecation < StandardError; end class Deprecation < StandardError
end
class ScssError < StandardError; end class ScssError < StandardError
end
def self.filters def self.filters
@filters ||= [:latest, :unread, :new, :unseen, :top, :read, :posted, :bookmarks] @filters ||= %i[latest unread new unseen top read posted bookmarks]
end end
def self.anonymous_filters def self.anonymous_filters
@anonymous_filters ||= [:latest, :top, :categories] @anonymous_filters ||= %i[latest top categories]
end end
def self.top_menu_items def self.top_menu_items
@ -288,7 +309,7 @@ module Discourse
end end
def self.anonymous_top_menu_items def self.anonymous_top_menu_items
@anonymous_top_menu_items ||= Discourse.anonymous_filters + [:categories, :top] @anonymous_top_menu_items ||= Discourse.anonymous_filters + %i[categories top]
end end
PIXEL_RATIOS ||= [1, 1.5, 2, 3] PIXEL_RATIOS ||= [1, 1.5, 2, 3]
@ -297,26 +318,28 @@ module Discourse
# TODO: should cache these when we get a notification system for site settings # TODO: should cache these when we get a notification system for site settings
set = Set.new set = Set.new
SiteSetting.avatar_sizes.split("|").map(&:to_i).each do |size| SiteSetting
PIXEL_RATIOS.each do |pixel_ratio| .avatar_sizes
set << (size * pixel_ratio).to_i .split("|")
end .map(&:to_i)
end .each { |size| PIXEL_RATIOS.each { |pixel_ratio| set << (size * pixel_ratio).to_i } }
set set
end end
def self.activate_plugins! def self.activate_plugins!
@plugins = [] @plugins = []
Plugin::Instance.find_all("#{Rails.root}/plugins").each do |p| Plugin::Instance
v = p.metadata.required_version || Discourse::VERSION::STRING .find_all("#{Rails.root}/plugins")
if Discourse.has_needed_version?(Discourse::VERSION::STRING, v) .each do |p|
p.activate! v = p.metadata.required_version || Discourse::VERSION::STRING
@plugins << p if Discourse.has_needed_version?(Discourse::VERSION::STRING, v)
else p.activate!
STDERR.puts "Could not activate #{p.metadata.name}, discourse does not meet required version (#{v})" @plugins << p
else
STDERR.puts "Could not activate #{p.metadata.name}, discourse does not meet required version (#{v})"
end
end end
end
DiscourseEvent.trigger(:after_plugin_activation) DiscourseEvent.trigger(:after_plugin_activation)
end end
@ -360,9 +383,7 @@ module Discourse
def self.apply_asset_filters(plugins, type, request) def self.apply_asset_filters(plugins, type, request)
filter_opts = asset_filter_options(type, request) filter_opts = asset_filter_options(type, request)
plugins.select do |plugin| plugins.select { |plugin| plugin.asset_filters.all? { |b| b.call(type, request, filter_opts) } }
plugin.asset_filters.all? { |b| b.call(type, request, filter_opts) }
end
end end
def self.asset_filter_options(type, request) def self.asset_filter_options(type, request)
@ -385,20 +406,24 @@ module Discourse
targets << :desktop if args[:desktop_view] targets << :desktop if args[:desktop_view]
targets.each do |target| targets.each do |target|
assets += plugins.find_all do |plugin| assets +=
plugin.css_asset_exists?(target) plugins
end.map do |plugin| .find_all { |plugin| plugin.css_asset_exists?(target) }
target.nil? ? plugin.directory_name : "#{plugin.directory_name}_#{target}" .map do |plugin|
end target.nil? ? plugin.directory_name : "#{plugin.directory_name}_#{target}"
end
end end
assets assets
end end
def self.find_plugin_js_assets(args) def self.find_plugin_js_assets(args)
plugins = self.find_plugins(args).select do |plugin| plugins =
plugin.js_asset_exists? || plugin.extra_js_asset_exists? || plugin.admin_js_asset_exists? self
end .find_plugins(args)
.select do |plugin|
plugin.js_asset_exists? || plugin.extra_js_asset_exists? || plugin.admin_js_asset_exists?
end
plugins = apply_asset_filters(plugins, :js, args[:request]) plugins = apply_asset_filters(plugins, :js, args[:request])
@ -413,25 +438,33 @@ module Discourse
end end
def self.assets_digest def self.assets_digest
@assets_digest ||= begin @assets_digest ||=
digest = Digest::MD5.hexdigest(ActionView::Base.assets_manifest.assets.values.sort.join) begin
digest = Digest::MD5.hexdigest(ActionView::Base.assets_manifest.assets.values.sort.join)
channel = "/global/asset-version" channel = "/global/asset-version"
message = MessageBus.last_message(channel) message = MessageBus.last_message(channel)
unless message && message.data == digest MessageBus.publish channel, digest unless message && message.data == digest
MessageBus.publish channel, digest digest
end end
digest
end
end end
BUILTIN_AUTH ||= [ BUILTIN_AUTH ||= [
Auth::AuthProvider.new(authenticator: Auth::FacebookAuthenticator.new, frame_width: 580, frame_height: 400, icon: "fab-facebook"), Auth::AuthProvider.new(
Auth::AuthProvider.new(authenticator: Auth::GoogleOAuth2Authenticator.new, frame_width: 850, frame_height: 500), # Custom icon implemented in client authenticator: Auth::FacebookAuthenticator.new,
frame_width: 580,
frame_height: 400,
icon: "fab-facebook",
),
Auth::AuthProvider.new(
authenticator: Auth::GoogleOAuth2Authenticator.new,
frame_width: 850,
frame_height: 500,
), # Custom icon implemented in client
Auth::AuthProvider.new(authenticator: Auth::GithubAuthenticator.new, icon: "fab-github"), Auth::AuthProvider.new(authenticator: Auth::GithubAuthenticator.new, icon: "fab-github"),
Auth::AuthProvider.new(authenticator: Auth::TwitterAuthenticator.new, icon: "fab-twitter"), Auth::AuthProvider.new(authenticator: Auth::TwitterAuthenticator.new, icon: "fab-twitter"),
Auth::AuthProvider.new(authenticator: Auth::DiscordAuthenticator.new, icon: "fab-discord") Auth::AuthProvider.new(authenticator: Auth::DiscordAuthenticator.new, icon: "fab-discord"),
] ]
def self.auth_providers def self.auth_providers
@ -439,7 +472,7 @@ module Discourse
end end
def self.enabled_auth_providers def self.enabled_auth_providers
auth_providers.select { |provider| provider.authenticator.enabled? } auth_providers.select { |provider| provider.authenticator.enabled? }
end end
def self.authenticators def self.authenticators
@ -449,17 +482,18 @@ module Discourse
end end
def self.enabled_authenticators def self.enabled_authenticators
authenticators.select { |authenticator| authenticator.enabled? } authenticators.select { |authenticator| authenticator.enabled? }
end end
def self.cache def self.cache
@cache ||= begin @cache ||=
if GlobalSetting.skip_redis? begin
ActiveSupport::Cache::MemoryStore.new if GlobalSetting.skip_redis?
else ActiveSupport::Cache::MemoryStore.new
Cache.new else
Cache.new
end
end end
end
end end
# hostname of the server, operating system level # hostname of the server, operating system level
@ -467,15 +501,15 @@ module Discourse
def self.os_hostname def self.os_hostname
@os_hostname ||= @os_hostname ||=
begin begin
require 'socket' require "socket"
Socket.gethostname Socket.gethostname
rescue => e rescue => e
warn_exception(e, message: 'Socket.gethostname is not working') warn_exception(e, message: "Socket.gethostname is not working")
begin begin
`hostname`.strip `hostname`.strip
rescue => e rescue => e
warn_exception(e, message: 'hostname command is not working') warn_exception(e, message: "hostname command is not working")
'unknown_host' "unknown_host"
end end
end end
end end
@ -501,12 +535,12 @@ module Discourse
def self.current_hostname_with_port def self.current_hostname_with_port
default_port = SiteSetting.force_https? ? 443 : 80 default_port = SiteSetting.force_https? ? 443 : 80
result = +"#{current_hostname}" result = +"#{current_hostname}"
result << ":#{SiteSetting.port}" if SiteSetting.port.to_i > 0 && SiteSetting.port.to_i != default_port if SiteSetting.port.to_i > 0 && SiteSetting.port.to_i != default_port
result << ":#{SiteSetting.port}"
if Rails.env.development? && SiteSetting.port.blank?
result << ":#{ENV["UNICORN_PORT"] || 3000}"
end end
result << ":#{ENV["UNICORN_PORT"] || 3000}" if Rails.env.development? && SiteSetting.port.blank?
result result
end end
@ -520,16 +554,18 @@ module Discourse
def self.route_for(uri) def self.route_for(uri)
unless uri.is_a?(URI) unless uri.is_a?(URI)
uri = begin uri =
URI(uri) begin
rescue ArgumentError, URI::Error URI(uri)
end rescue ArgumentError, URI::Error
end
end end
return unless uri return unless uri
path = +(uri.path || "") path = +(uri.path || "")
if !uri.host || (uri.host == Discourse.current_hostname && path.start_with?(Discourse.base_path)) if !uri.host ||
(uri.host == Discourse.current_hostname && path.start_with?(Discourse.base_path))
path.slice!(Discourse.base_path) path.slice!(Discourse.base_path)
return Rails.application.routes.recognize_path(path) return Rails.application.routes.recognize_path(path)
end end
@ -543,21 +579,21 @@ module Discourse
alias_method :base_url_no_path, :base_url_no_prefix alias_method :base_url_no_path, :base_url_no_prefix
end end
READONLY_MODE_KEY_TTL ||= 60 READONLY_MODE_KEY_TTL ||= 60
READONLY_MODE_KEY ||= 'readonly_mode' READONLY_MODE_KEY ||= "readonly_mode"
PG_READONLY_MODE_KEY ||= 'readonly_mode:postgres' PG_READONLY_MODE_KEY ||= "readonly_mode:postgres"
PG_READONLY_MODE_KEY_TTL ||= 300 PG_READONLY_MODE_KEY_TTL ||= 300
USER_READONLY_MODE_KEY ||= 'readonly_mode:user' USER_READONLY_MODE_KEY ||= "readonly_mode:user"
PG_FORCE_READONLY_MODE_KEY ||= 'readonly_mode:postgres_force' PG_FORCE_READONLY_MODE_KEY ||= "readonly_mode:postgres_force"
# Psuedo readonly mode, where staff can still write # Psuedo readonly mode, where staff can still write
STAFF_WRITES_ONLY_MODE_KEY ||= 'readonly_mode:staff_writes_only' STAFF_WRITES_ONLY_MODE_KEY ||= "readonly_mode:staff_writes_only"
READONLY_KEYS ||= [ READONLY_KEYS ||= [
READONLY_MODE_KEY, READONLY_MODE_KEY,
PG_READONLY_MODE_KEY, PG_READONLY_MODE_KEY,
USER_READONLY_MODE_KEY, USER_READONLY_MODE_KEY,
PG_FORCE_READONLY_MODE_KEY PG_FORCE_READONLY_MODE_KEY,
] ]
def self.enable_readonly_mode(key = READONLY_MODE_KEY) def self.enable_readonly_mode(key = READONLY_MODE_KEY)
@ -565,7 +601,9 @@ module Discourse
Sidekiq.pause!("pg_failover") if !Sidekiq.paused? Sidekiq.pause!("pg_failover") if !Sidekiq.paused?
end end
if [USER_READONLY_MODE_KEY, PG_FORCE_READONLY_MODE_KEY, STAFF_WRITES_ONLY_MODE_KEY].include?(key) if [USER_READONLY_MODE_KEY, PG_FORCE_READONLY_MODE_KEY, STAFF_WRITES_ONLY_MODE_KEY].include?(
key,
)
Discourse.redis.set(key, 1) Discourse.redis.set(key, 1)
else else
ttl = ttl =
@ -595,15 +633,13 @@ module Discourse
unless @threads[key]&.alive? unless @threads[key]&.alive?
@threads[key] = Thread.new do @threads[key] = Thread.new do
while @dbs.size > 0 do while @dbs.size > 0
sleep ttl / 2 sleep ttl / 2
@mutex.synchronize do @mutex.synchronize do
@dbs.each do |db| @dbs.each do |db|
RailsMultisite::ConnectionManagement.with_connection(db) do RailsMultisite::ConnectionManagement.with_connection(db) do
if !Discourse.redis.expire(key, ttl) @dbs.delete(db) if !Discourse.redis.expire(key, ttl)
@dbs.delete(db)
end
end end
end end
end end
@ -653,7 +689,7 @@ module Discourse
# Shared between processes # Shared between processes
def self.postgres_last_read_only def self.postgres_last_read_only
@postgres_last_read_only ||= DistributedCache.new('postgres_last_read_only', namespace: false) @postgres_last_read_only ||= DistributedCache.new("postgres_last_read_only", namespace: false)
end end
# Per-process # Per-process
@ -698,39 +734,43 @@ module Discourse
# This is better than `MessageBus.publish "/file-change", ["refresh"]` because # This is better than `MessageBus.publish "/file-change", ["refresh"]` because
# it spreads the refreshes out over a time period # it spreads the refreshes out over a time period
if user_ids if user_ids
MessageBus.publish("/refresh_client", 'clobber', user_ids: user_ids) MessageBus.publish("/refresh_client", "clobber", user_ids: user_ids)
else else
MessageBus.publish('/global/asset-version', 'clobber') MessageBus.publish("/global/asset-version", "clobber")
end end
end end
def self.git_version def self.git_version
@git_version ||= begin @git_version ||=
git_cmd = 'git rev-parse HEAD' begin
self.try_git(git_cmd, Discourse::VERSION::STRING) git_cmd = "git rev-parse HEAD"
end self.try_git(git_cmd, Discourse::VERSION::STRING)
end
end end
def self.git_branch def self.git_branch
@git_branch ||= begin @git_branch ||=
git_cmd = 'git rev-parse --abbrev-ref HEAD' begin
self.try_git(git_cmd, 'unknown') git_cmd = "git rev-parse --abbrev-ref HEAD"
end self.try_git(git_cmd, "unknown")
end
end end
def self.full_version def self.full_version
@full_version ||= begin @full_version ||=
git_cmd = 'git describe --dirty --match "v[0-9]*" 2> /dev/null' begin
self.try_git(git_cmd, 'unknown') git_cmd = 'git describe --dirty --match "v[0-9]*" 2> /dev/null'
end self.try_git(git_cmd, "unknown")
end
end end
def self.last_commit_date def self.last_commit_date
@last_commit_date ||= begin @last_commit_date ||=
git_cmd = 'git log -1 --format="%ct"' begin
seconds = self.try_git(git_cmd, nil) git_cmd = 'git log -1 --format="%ct"'
seconds.nil? ? nil : DateTime.strptime(seconds, '%s') seconds = self.try_git(git_cmd, nil)
end seconds.nil? ? nil : DateTime.strptime(seconds, "%s")
end
end end
def self.try_git(git_cmd, default_value) def self.try_git(git_cmd, default_value)
@ -738,20 +778,21 @@ module Discourse
begin begin
version_value = `#{git_cmd}`.strip version_value = `#{git_cmd}`.strip
rescue rescue StandardError
version_value = default_value version_value = default_value
end end
if version_value.empty? version_value = default_value if version_value.empty?
version_value = default_value
end
version_value version_value
end end
# Either returns the site_contact_username user or the first admin. # Either returns the site_contact_username user or the first admin.
def self.site_contact_user def self.site_contact_user
user = User.find_by(username_lower: SiteSetting.site_contact_username.downcase) if SiteSetting.site_contact_username.present? user =
User.find_by(
username_lower: SiteSetting.site_contact_username.downcase,
) if SiteSetting.site_contact_username.present?
user ||= (system_user || User.admins.real.order(:id).first) user ||= (system_user || User.admins.real.order(:id).first)
end end
@ -765,10 +806,10 @@ module Discourse
def self.store def self.store
if SiteSetting.Upload.enable_s3_uploads if SiteSetting.Upload.enable_s3_uploads
@s3_store_loaded ||= require 'file_store/s3_store' @s3_store_loaded ||= require "file_store/s3_store"
FileStore::S3Store.new FileStore::S3Store.new
else else
@local_store_loaded ||= require 'file_store/local_store' @local_store_loaded ||= require "file_store/local_store"
FileStore::LocalStore.new FileStore::LocalStore.new
end end
end end
@ -805,15 +846,15 @@ module Discourse
Discourse.cache.reconnect Discourse.cache.reconnect
Logster.store.redis.reconnect Logster.store.redis.reconnect
# shuts down all connections in the pool # shuts down all connections in the pool
Sidekiq.redis_pool.shutdown { |conn| conn.disconnect! } Sidekiq.redis_pool.shutdown { |conn| conn.disconnect! }
# re-establish # re-establish
Sidekiq.redis = sidekiq_redis_config Sidekiq.redis = sidekiq_redis_config
# in case v8 was initialized we want to make sure it is nil # in case v8 was initialized we want to make sure it is nil
PrettyText.reset_context PrettyText.reset_context
DiscourseJsProcessor::Transpiler.reset_context if defined? DiscourseJsProcessor::Transpiler DiscourseJsProcessor::Transpiler.reset_context if defined?(DiscourseJsProcessor::Transpiler)
JsLocaleHelper.reset_context if defined? JsLocaleHelper JsLocaleHelper.reset_context if defined?(JsLocaleHelper)
# warm up v8 after fork, that way we do not fork a v8 context # warm up v8 after fork, that way we do not fork a v8 context
# it may cause issues if bg threads in a v8 isolate randomly stop # it may cause issues if bg threads in a v8 isolate randomly stop
@ -831,7 +872,7 @@ module Discourse
# you can use Discourse.warn when you want to report custom environment # you can use Discourse.warn when you want to report custom environment
# with the error, this helps with grouping # with the error, this helps with grouping
def self.warn(message, env = nil) def self.warn(message, env = nil)
append = env ? (+" ") << env.map { |k, v|"#{k}: #{v}" }.join(" ") : "" append = env ? (+" ") << env.map { |k, v| "#{k}: #{v}" }.join(" ") : ""
if !(Logster::Logger === Rails.logger) if !(Logster::Logger === Rails.logger)
Rails.logger.warn("#{message}#{append}") Rails.logger.warn("#{message}#{append}")
@ -839,9 +880,7 @@ module Discourse
end end
loggers = [Rails.logger] loggers = [Rails.logger]
if Rails.logger.chained loggers.concat(Rails.logger.chained) if Rails.logger.chained
loggers.concat(Rails.logger.chained)
end
logster_env = env logster_env = env
@ -849,9 +888,7 @@ module Discourse
logster_env = Logster::Message.populate_from_env(old_env) logster_env = Logster::Message.populate_from_env(old_env)
# a bit awkward by try to keep the new params # a bit awkward by try to keep the new params
env.each do |k, v| env.each { |k, v| logster_env[k] = v }
logster_env[k] = v
end
end end
loggers.each do |logger| loggers.each do |logger|
@ -860,12 +897,7 @@ module Discourse
next next
end end
logger.store.report( logger.store.report(::Logger::Severity::WARN, "discourse", message, env: logster_env)
::Logger::Severity::WARN,
"discourse",
message,
env: logster_env
)
end end
if old_env if old_env
@ -881,7 +913,6 @@ module Discourse
# report a warning maintaining backtrack for logster # report a warning maintaining backtrack for logster
def self.warn_exception(e, message: "", env: nil) def self.warn_exception(e, message: "", env: nil)
if Rails.logger.respond_to? :add_with_opts if Rails.logger.respond_to? :add_with_opts
env ||= {} env ||= {}
env[:current_db] ||= RailsMultisite::ConnectionManagement.current_db env[:current_db] ||= RailsMultisite::ConnectionManagement.current_db
@ -891,13 +922,13 @@ module Discourse
"#{message} : #{e.class.name} : #{e}", "#{message} : #{e.class.name} : #{e}",
"discourse-exception", "discourse-exception",
backtrace: e.backtrace.join("\n"), backtrace: e.backtrace.join("\n"),
env: env env: env,
) )
else else
# no logster ... fallback # no logster ... fallback
Rails.logger.warn("#{message} #{e}\n#{e.backtrace.join("\n")}") Rails.logger.warn("#{message} #{e}\n#{e.backtrace.join("\n")}")
end end
rescue rescue StandardError
STDERR.puts "Failed to report exception #{e} #{message}" STDERR.puts "Failed to report exception #{e} #{message}"
end end
@ -909,17 +940,11 @@ module Discourse
warning << "\nAt #{location}" warning << "\nAt #{location}"
warning = warning.join(" ") warning = warning.join(" ")
if raise_error raise Deprecation.new(warning) if raise_error
raise Deprecation.new(warning)
end
if Rails.env == "development" STDERR.puts(warning) if Rails.env == "development"
STDERR.puts(warning)
end
if output_in_test && Rails.env == "test" STDERR.puts(warning) if output_in_test && Rails.env == "test"
STDERR.puts(warning)
end
digest = Digest::MD5.hexdigest(warning) digest = Digest::MD5.hexdigest(warning)
redis_key = "deprecate-notice-#{digest}" redis_key = "deprecate-notice-#{digest}"
@ -935,7 +960,7 @@ module Discourse
warning warning
end end
SIDEKIQ_NAMESPACE ||= 'sidekiq' SIDEKIQ_NAMESPACE ||= "sidekiq"
def self.sidekiq_redis_config def self.sidekiq_redis_config
conf = GlobalSetting.redis_config.dup conf = GlobalSetting.redis_config.dup
@ -951,7 +976,8 @@ module Discourse
def self.reset_active_record_cache_if_needed(e) def self.reset_active_record_cache_if_needed(e)
last_cache_reset = Discourse.last_ar_cache_reset last_cache_reset = Discourse.last_ar_cache_reset
if e && e.message =~ /UndefinedColumn/ && (last_cache_reset.nil? || last_cache_reset < 30.seconds.ago) if e && e.message =~ /UndefinedColumn/ &&
(last_cache_reset.nil? || last_cache_reset < 30.seconds.ago)
Rails.logger.warn "Clearing Active Record cache, this can happen if schema changed while site is running or in a multisite various databases are running different schemas. Consider running rake multisite:migrate." Rails.logger.warn "Clearing Active Record cache, this can happen if schema changed while site is running or in a multisite various databases are running different schemas. Consider running rake multisite:migrate."
Discourse.last_ar_cache_reset = Time.zone.now Discourse.last_ar_cache_reset = Time.zone.now
Discourse.reset_active_record_cache Discourse.reset_active_record_cache
@ -961,7 +987,11 @@ module Discourse
def self.reset_active_record_cache def self.reset_active_record_cache
ActiveRecord::Base.connection.query_cache.clear ActiveRecord::Base.connection.query_cache.clear
(ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table| (ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
table.classify.constantize.reset_column_information rescue nil begin
table.classify.constantize.reset_column_information
rescue StandardError
nil
end
end end
nil nil
end end
@ -971,7 +1001,7 @@ module Discourse
end end
def self.skip_post_deployment_migrations? def self.skip_post_deployment_migrations?
['1', 'true'].include?(ENV["SKIP_POST_DEPLOYMENT_MIGRATIONS"]&.to_s) %w[1 true].include?(ENV["SKIP_POST_DEPLOYMENT_MIGRATIONS"]&.to_s)
end end
# this is used to preload as much stuff as possible prior to forking # this is used to preload as much stuff as possible prior to forking
@ -985,7 +1015,11 @@ module Discourse
# load up all models and schema # load up all models and schema
(ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table| (ActiveRecord::Base.connection.tables - %w[schema_migrations versions]).each do |table|
table.classify.constantize.first rescue nil begin
table.classify.constantize.first
rescue StandardError
nil
end
end end
# ensure we have a full schema cache in case we missed something above # ensure we have a full schema cache in case we missed something above
@ -1024,29 +1058,27 @@ module Discourse
end end
[ [
Thread.new { Thread.new do
# router warm up # router warm up
Rails.application.routes.recognize_path('abc') rescue nil begin
}, Rails.application.routes.recognize_path("abc")
Thread.new { rescue StandardError
nil
end
end,
Thread.new do
# preload discourse version # preload discourse version
Discourse.git_version Discourse.git_version
Discourse.git_branch Discourse.git_branch
Discourse.full_version Discourse.full_version
}, end,
Thread.new { Thread.new do
require 'actionview_precompiler' require "actionview_precompiler"
ActionviewPrecompiler.precompile ActionviewPrecompiler.precompile
}, end,
Thread.new { Thread.new { LetterAvatar.image_magick_version },
LetterAvatar.image_magick_version Thread.new { SvgSprite.core_svgs },
}, Thread.new { EmberCli.script_chunks },
Thread.new {
SvgSprite.core_svgs
},
Thread.new {
EmberCli.script_chunks
}
].each(&:join) ].each(&:join)
ensure ensure
@preloaded_rails = true @preloaded_rails = true
@ -1055,10 +1087,10 @@ module Discourse
mattr_accessor :redis mattr_accessor :redis
def self.is_parallel_test? def self.is_parallel_test?
ENV['RAILS_ENV'] == "test" && ENV['TEST_ENV_NUMBER'] ENV["RAILS_ENV"] == "test" && ENV["TEST_ENV_NUMBER"]
end end
CDN_REQUEST_METHODS ||= ["GET", "HEAD", "OPTIONS"] CDN_REQUEST_METHODS ||= %w[GET HEAD OPTIONS]
def self.is_cdn_request?(env, request_method) def self.is_cdn_request?(env, request_method)
return unless CDN_REQUEST_METHODS.include?(request_method) return unless CDN_REQUEST_METHODS.include?(request_method)
@ -1071,8 +1103,8 @@ module Discourse
end end
def self.apply_cdn_headers(headers) def self.apply_cdn_headers(headers)
headers['Access-Control-Allow-Origin'] = '*' headers["Access-Control-Allow-Origin"] = "*"
headers['Access-Control-Allow-Methods'] = CDN_REQUEST_METHODS.join(", ") headers["Access-Control-Allow-Methods"] = CDN_REQUEST_METHODS.join(", ")
headers headers
end end
@ -1091,8 +1123,12 @@ module Discourse
end end
def self.anonymous_locale(request) def self.anonymous_locale(request)
locale = HttpLanguageParser.parse(request.cookies["locale"]) if SiteSetting.set_locale_from_cookie locale =
locale ||= HttpLanguageParser.parse(request.env["HTTP_ACCEPT_LANGUAGE"]) if SiteSetting.set_locale_from_accept_language_header HttpLanguageParser.parse(request.cookies["locale"]) if SiteSetting.set_locale_from_cookie
locale ||=
HttpLanguageParser.parse(
request.env["HTTP_ACCEPT_LANGUAGE"],
) if SiteSetting.set_locale_from_accept_language_header
locale locale
end end
end end

View File

@ -1,12 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class DiscourseConnectBase class DiscourseConnectBase
class ParseError < RuntimeError
end
class ParseError < RuntimeError; end ACCESSORS = %i[
ACCESSORS = %i{
add_groups add_groups
admin moderator admin
moderator
avatar_force_update avatar_force_update
avatar_url avatar_url
bio bio
@ -31,11 +32,11 @@ class DiscourseConnectBase
title title
username username
website website
} ]
FIXNUMS = [] FIXNUMS = []
BOOLS = %i{ BOOLS = %i[
admin admin
avatar_force_update avatar_force_update
confirmed_2fa confirmed_2fa
@ -46,7 +47,7 @@ class DiscourseConnectBase
require_2fa require_2fa
require_activation require_activation
suppress_welcome_message suppress_welcome_message
} ]
def self.nonce_expiry_time def self.nonce_expiry_time
@nonce_expiry_time ||= 10.minutes @nonce_expiry_time ||= 10.minutes
@ -80,9 +81,11 @@ class DiscourseConnectBase
decoded_hash = Rack::Utils.parse_query(decoded) decoded_hash = Rack::Utils.parse_query(decoded)
if sso.sign(parsed["sso"]) != parsed["sig"] if sso.sign(parsed["sso"]) != parsed["sig"]
diags = "\n\nsso: #{parsed["sso"]}\n\nsig: #{parsed["sig"]}\n\nexpected sig: #{sso.sign(parsed["sso"])}" diags =
if parsed["sso"] =~ /[^a-zA-Z0-9=\r\n\/+]/m "\n\nsso: #{parsed["sso"]}\n\nsig: #{parsed["sig"]}\n\nexpected sig: #{sso.sign(parsed["sso"])}"
raise ParseError, "The SSO field should be Base64 encoded, using only A-Z, a-z, 0-9, +, /, and = characters. Your input contains characters we don't understand as Base64, see http://en.wikipedia.org/wiki/Base64 #{diags}" if parsed["sso"] =~ %r{[^a-zA-Z0-9=\r\n/+]}m
raise ParseError,
"The SSO field should be Base64 encoded, using only A-Z, a-z, 0-9, +, /, and = characters. Your input contains characters we don't understand as Base64, see http://en.wikipedia.org/wiki/Base64 #{diags}"
else else
raise ParseError, "Bad signature for payload #{diags}" raise ParseError, "Bad signature for payload #{diags}"
end end
@ -91,9 +94,7 @@ class DiscourseConnectBase
ACCESSORS.each do |k| ACCESSORS.each do |k|
val = decoded_hash[k.to_s] val = decoded_hash[k.to_s]
val = val.to_i if FIXNUMS.include? k val = val.to_i if FIXNUMS.include? k
if BOOLS.include? k val = %w[true false].include?(val) ? val == "true" : nil if BOOLS.include? k
val = ["true", "false"].include?(val) ? val == "true" : nil
end
sso.public_send("#{k}=", val) sso.public_send("#{k}=", val)
end end
@ -137,12 +138,12 @@ class DiscourseConnectBase
def to_url(base_url = nil) def to_url(base_url = nil)
base = "#{base_url || sso_url}" base = "#{base_url || sso_url}"
"#{base}#{base.include?('?') ? '&' : '?'}#{payload}" "#{base}#{base.include?("?") ? "&" : "?"}#{payload}"
end end
def payload(secret = nil) def payload(secret = nil)
payload = Base64.strict_encode64(unsigned_payload) payload = Base64.strict_encode64(unsigned_payload)
"sso=#{CGI::escape(payload)}&sig=#{sign(payload, secret)}" "sso=#{CGI.escape(payload)}&sig=#{sign(payload, secret)}"
end end
def unsigned_payload def unsigned_payload
@ -157,9 +158,7 @@ class DiscourseConnectBase
payload[k] = val payload[k] = val
end end
@custom_fields&.each do |k, v| @custom_fields&.each { |k, v| payload["custom.#{k}"] = v.to_s }
payload["custom.#{k}"] = v.to_s
end
payload payload
end end

View File

@ -1,8 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class DiscourseConnectProvider < DiscourseConnectBase class DiscourseConnectProvider < DiscourseConnectBase
class BlankSecret < RuntimeError; end class BlankSecret < RuntimeError
class BlankReturnUrl < RuntimeError; end end
class BlankReturnUrl < RuntimeError
end
def self.parse(payload, sso_secret = nil, **init_kwargs) def self.parse(payload, sso_secret = nil, **init_kwargs)
parsed_payload = Rack::Utils.parse_query(payload) parsed_payload = Rack::Utils.parse_query(payload)
@ -15,11 +17,16 @@ class DiscourseConnectProvider < DiscourseConnectBase
if sso_secret.blank? if sso_secret.blank?
begin begin
host = URI.parse(return_sso_url).host host = URI.parse(return_sso_url).host
Rails.logger.warn("SSO failed; website #{host} is not in the `discourse_connect_provider_secrets` site settings") Rails.logger.warn(
"SSO failed; website #{host} is not in the `discourse_connect_provider_secrets` site settings",
)
rescue StandardError => e rescue StandardError => e
# going for StandardError cause URI::Error may not be enough, eg it parses to something not # going for StandardError cause URI::Error may not be enough, eg it parses to something not
# responding to host # responding to host
Discourse.warn_exception(e, message: "SSO failed; invalid or missing return_sso_url in SSO payload") Discourse.warn_exception(
e,
message: "SSO failed; invalid or missing return_sso_url in SSO payload",
)
end end
raise BlankSecret raise BlankSecret
@ -31,7 +38,7 @@ class DiscourseConnectProvider < DiscourseConnectBase
def self.lookup_return_sso_url(parsed_payload) def self.lookup_return_sso_url(parsed_payload)
decoded = Base64.decode64(parsed_payload["sso"]) decoded = Base64.decode64(parsed_payload["sso"])
decoded_hash = Rack::Utils.parse_query(decoded) decoded_hash = Rack::Utils.parse_query(decoded)
decoded_hash['return_sso_url'] decoded_hash["return_sso_url"]
end end
def self.lookup_sso_secret(return_sso_url, parsed_payload) def self.lookup_sso_secret(return_sso_url, parsed_payload)
@ -39,21 +46,23 @@ class DiscourseConnectProvider < DiscourseConnectBase
return_url_host = URI.parse(return_sso_url).host return_url_host = URI.parse(return_sso_url).host
provider_secrets = SiteSetting provider_secrets =
.discourse_connect_provider_secrets SiteSetting
.split("\n") .discourse_connect_provider_secrets
.map { |row| row.split("|", 2) } .split("\n")
.sort_by { |k, _| k } .map { |row| row.split("|", 2) }
.reverse .sort_by { |k, _| k }
.reverse
first_domain_match = nil first_domain_match = nil
pair = provider_secrets.find do |domain, configured_secret| pair =
if WildcardDomainChecker.check_domain(domain, return_url_host) provider_secrets.find do |domain, configured_secret|
first_domain_match ||= configured_secret if WildcardDomainChecker.check_domain(domain, return_url_host)
sign(parsed_payload["sso"], configured_secret) == parsed_payload["sig"] first_domain_match ||= configured_secret
sign(parsed_payload["sso"], configured_secret) == parsed_payload["sig"]
end
end end
end
# falls back to a secret which will fail to validate in DiscourseConnectBase # falls back to a secret which will fail to validate in DiscourseConnectBase
# this ensures error flow is correct # this ensures error flow is correct

View File

@ -1,12 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'discourse_dev/record' require "discourse_dev/record"
require 'rails' require "rails"
require 'faker' require "faker"
module DiscourseDev module DiscourseDev
class Category < Record class Category < Record
def initialize def initialize
super(::Category, DiscourseDev.config.category[:count]) super(::Category, DiscourseDev.config.category[:count])
@parent_category_ids = ::Category.where(parent_category_id: nil).pluck(:id) @parent_category_ids = ::Category.where(parent_category_id: nil).pluck(:id)
@ -29,7 +28,7 @@ module DiscourseDev
description: Faker::Lorem.paragraph, description: Faker::Lorem.paragraph,
user_id: ::Discourse::SYSTEM_USER_ID, user_id: ::Discourse::SYSTEM_USER_ID,
color: Faker::Color.hex_color.last(6), color: Faker::Color.hex_color.last(6),
parent_category_id: parent_category_id parent_category_id: parent_category_id,
} }
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails' require "rails"
require 'highline/import' require "highline/import"
module DiscourseDev module DiscourseDev
class Config class Config
@ -63,10 +63,11 @@ module DiscourseDev
if settings.present? if settings.present?
email = settings[:email] || "new_user@example.com" email = settings[:email] || "new_user@example.com"
new_user = ::User.create!( new_user =
email: email, ::User.create!(
username: settings[:username] || UserNameSuggester.suggest(email) email: email,
) username: settings[:username] || UserNameSuggester.suggest(email),
)
new_user.email_tokens.update_all confirmed: true new_user.email_tokens.update_all confirmed: true
new_user.activate new_user.activate
end end
@ -88,15 +89,14 @@ module DiscourseDev
def create_admin_user_from_settings(settings) def create_admin_user_from_settings(settings)
email = settings[:email] email = settings[:email]
admin = ::User.with_email(email).first_or_create!( admin =
email: email, ::User.with_email(email).first_or_create!(
username: settings[:username] || UserNameSuggester.suggest(email), email: email,
password: settings[:password] username: settings[:username] || UserNameSuggester.suggest(email),
) password: settings[:password],
)
admin.grant_admin! admin.grant_admin!
if admin.trust_level < 1 admin.change_trust_level!(1) if admin.trust_level < 1
admin.change_trust_level!(1)
end
admin.email_tokens.update_all confirmed: true admin.email_tokens.update_all confirmed: true
admin.activate admin.activate
end end
@ -107,10 +107,7 @@ module DiscourseDev
password = ask("Password (optional, press ENTER to skip): ") password = ask("Password (optional, press ENTER to skip): ")
username = UserNameSuggester.suggest(email) username = UserNameSuggester.suggest(email)
admin = ::User.new( admin = ::User.new(email: email, username: username)
email: email,
username: username
)
if password.present? if password.present?
admin.password = password admin.password = password
@ -122,7 +119,7 @@ module DiscourseDev
saved = admin.save saved = admin.save
if saved if saved
File.open(file_path, 'a') do | file| File.open(file_path, "a") do |file|
file.puts("admin:") file.puts("admin:")
file.puts(" username: #{admin.username}") file.puts(" username: #{admin.username}")
file.puts(" email: #{admin.email}") file.puts(" email: #{admin.email}")
@ -137,9 +134,7 @@ module DiscourseDev
admin.save admin.save
admin.grant_admin! admin.grant_admin!
if admin.trust_level < 1 admin.change_trust_level!(1) if admin.trust_level < 1
admin.change_trust_level!(1)
end
admin.email_tokens.update_all confirmed: true admin.email_tokens.update_all confirmed: true
admin.activate admin.activate

View File

@ -1,12 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'discourse_dev/record' require "discourse_dev/record"
require 'rails' require "rails"
require 'faker' require "faker"
module DiscourseDev module DiscourseDev
class Group < Record class Group < Record
def initialize def initialize
super(::Group, DiscourseDev.config.group[:count]) super(::Group, DiscourseDev.config.group[:count])
end end

View File

@ -1,11 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'discourse_dev/record' require "discourse_dev/record"
require 'faker' require "faker"
module DiscourseDev module DiscourseDev
class Post < Record class Post < Record
attr_reader :topic attr_reader :topic
def initialize(topic, count) def initialize(topic, count)
@ -28,7 +27,7 @@ module DiscourseDev
raw: Faker::DiscourseMarkdown.sandwich(sentences: 5), raw: Faker::DiscourseMarkdown.sandwich(sentences: 5),
created_at: Faker::Time.between(from: topic.last_posted_at, to: DateTime.now), created_at: Faker::Time.between(from: topic.last_posted_at, to: DateTime.now),
skip_validations: true, skip_validations: true,
skip_guardian: true skip_guardian: true,
} }
end end
@ -44,13 +43,20 @@ module DiscourseDev
def generate_likes(post) def generate_likes(post)
user_ids = [post.user_id] user_ids = [post.user_id]
Faker::Number.between(from: 0, to: @max_likes_count).times do Faker::Number
user = self.user .between(from: 0, to: @max_likes_count)
next if user_ids.include?(user.id) .times do
user = self.user
next if user_ids.include?(user.id)
PostActionCreator.new(user, post, PostActionType.types[:like], created_at: Faker::Time.between(from: post.created_at, to: DateTime.now)).perform PostActionCreator.new(
user_ids << user.id user,
end post,
PostActionType.types[:like],
created_at: Faker::Time.between(from: post.created_at, to: DateTime.now),
).perform
user_ids << user.id
end
end end
def user def user
@ -90,13 +96,14 @@ module DiscourseDev
count.times do |i| count.times do |i|
begin begin
user = User.random user = User.random
reply = Faker::DiscourseMarkdown.with_user(user.id) do reply =
{ Faker::DiscourseMarkdown.with_user(user.id) do
topic_id: topic.id, {
raw: Faker::DiscourseMarkdown.sandwich(sentences: 5), topic_id: topic.id,
skip_validations: true raw: Faker::DiscourseMarkdown.sandwich(sentences: 5),
} skip_validations: true,
end }
end
PostCreator.new(user, reply).create! PostCreator.new(user, reply).create!
rescue ActiveRecord::RecordNotSaved => e rescue ActiveRecord::RecordNotSaved => e
puts e puts e
@ -109,6 +116,5 @@ module DiscourseDev
def self.random def self.random
super(::Post) super(::Post)
end end
end end
end end

View File

@ -1,11 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'discourse_dev/record' require "discourse_dev/record"
require 'faker' require "faker"
module DiscourseDev module DiscourseDev
class PostRevision < Record class PostRevision < Record
def initialize def initialize
super(::PostRevision, DiscourseDev.config.post_revisions[:count]) super(::PostRevision, DiscourseDev.config.post_revisions[:count])
end end

View File

@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'discourse_dev' require "discourse_dev"
require 'rails' require "rails"
require 'faker' require "faker"
module DiscourseDev module DiscourseDev
class Record class Record
@ -12,11 +12,12 @@ module DiscourseDev
attr_reader :model, :type attr_reader :model, :type
def initialize(model, count = DEFAULT_COUNT) def initialize(model, count = DEFAULT_COUNT)
@@initialized ||= begin @@initialized ||=
Faker::Discourse.unique.clear begin
RateLimiter.disable Faker::Discourse.unique.clear
true RateLimiter.disable
end true
end
@model = model @model = model
@type = model.to_s.downcase.to_sym @type = model.to_s.downcase.to_sym
@ -40,11 +41,9 @@ module DiscourseDev
if current_count >= @count if current_count >= @count
puts "Already have #{current_count} #{type} records" puts "Already have #{current_count} #{type} records"
Rake.application.top_level_tasks.each do |task_name| Rake.application.top_level_tasks.each { |task_name| Rake::Task[task_name].reenable }
Rake::Task[task_name].reenable
end
Rake::Task['dev:repopulate'].invoke Rake::Task["dev:repopulate"].invoke
return return
elsif current_count > 0 elsif current_count > 0
@count -= current_count @count -= current_count
@ -74,7 +73,9 @@ module DiscourseDev
end end
def self.random(model, use_existing_records: true) def self.random(model, use_existing_records: true)
model.joins(:_custom_fields).where("#{:type}_custom_fields.name = '#{AUTO_POPULATED}'") if !use_existing_records && model.new.respond_to?(:custom_fields) if !use_existing_records && model.new.respond_to?(:custom_fields)
model.joins(:_custom_fields).where("#{:type}_custom_fields.name = '#{AUTO_POPULATED}'")
end
count = model.count count = model.count
raise "#{:type} records are not yet populated" if count == 0 raise "#{:type} records are not yet populated" if count == 0

View File

@ -1,12 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'discourse_dev/record' require "discourse_dev/record"
require 'rails' require "rails"
require 'faker' require "faker"
module DiscourseDev module DiscourseDev
class Tag < Record class Tag < Record
def initialize def initialize
super(::Tag, DiscourseDev.config.tag[:count]) super(::Tag, DiscourseDev.config.tag[:count])
end end
@ -24,9 +23,7 @@ module DiscourseDev
end end
def data def data
{ { name: Faker::Discourse.unique.tag }
name: Faker::Discourse.unique.tag,
}
end end
end end
end end

View File

@ -1,11 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'discourse_dev/record' require "discourse_dev/record"
require 'faker' require "faker"
module DiscourseDev module DiscourseDev
class Topic < Record class Topic < Record
def initialize(private_messages: false, recipient: nil, ignore_current_count: false) def initialize(private_messages: false, recipient: nil, ignore_current_count: false)
@settings = DiscourseDev.config.topic @settings = DiscourseDev.config.topic
@private_messages = private_messages @private_messages = private_messages
@ -33,15 +32,9 @@ module DiscourseDev
end end
if @category if @category
merge_attributes = { merge_attributes = { category: @category.id, tags: tags }
category: @category.id,
tags: tags
}
else else
merge_attributes = { merge_attributes = { archetype: "private_message", target_usernames: [@recipient] }
archetype: "private_message",
target_usernames: [@recipient]
}
end end
{ {
@ -51,9 +44,11 @@ module DiscourseDev
topic_opts: { topic_opts: {
import_mode: true, import_mode: true,
views: Faker::Number.between(from: 1, to: max_views), views: Faker::Number.between(from: 1, to: max_views),
custom_fields: { dev_sample: true } custom_fields: {
dev_sample: true,
},
}, },
skip_validations: true skip_validations: true,
}.merge(merge_attributes) }.merge(merge_attributes)
end end
@ -61,7 +56,10 @@ module DiscourseDev
if current_count < I18n.t("faker.discourse.topics").count if current_count < I18n.t("faker.discourse.topics").count
Faker::Discourse.unique.topic Faker::Discourse.unique.topic
else else
Faker::Lorem.unique.sentence(word_count: 5, supplemental: true, random_words_to_add: 4).chomp(".") Faker::Lorem
.unique
.sentence(word_count: 5, supplemental: true, random_words_to_add: 4)
.chomp(".")
end end
end end
@ -70,9 +68,9 @@ module DiscourseDev
@tags = [] @tags = []
Faker::Number.between(from: @settings.dig(:tags, :min), to: @settings.dig(:tags, :max)).times do Faker::Number
@tags << Faker::Discourse.tag .between(from: @settings.dig(:tags, :min), to: @settings.dig(:tags, :max))
end .times { @tags << Faker::Discourse.tag }
@tags.uniq @tags.uniq
end end
@ -92,7 +90,11 @@ module DiscourseDev
if override = @settings.dig(:replies, :overrides).find { |o| o[:title] == topic_data[:title] } if override = @settings.dig(:replies, :overrides).find { |o| o[:title] == topic_data[:title] }
reply_count = override[:count] reply_count = override[:count]
else else
reply_count = Faker::Number.between(from: @settings.dig(:replies, :min), to: @settings.dig(:replies, :max)) reply_count =
Faker::Number.between(
from: @settings.dig(:replies, :min),
to: @settings.dig(:replies, :max),
)
end end
topic = post.topic topic = post.topic
@ -123,9 +125,7 @@ module DiscourseDev
end end
def delete_unwanted_sidekiq_jobs def delete_unwanted_sidekiq_jobs
Sidekiq::ScheduledSet.new.each do |job| Sidekiq::ScheduledSet.new.each { |job| job.delete if job.item["class"] == "Jobs::UserEmail" }
job.delete if job.item["class"] == "Jobs::UserEmail"
end
end end
end end
end end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class DiscourseDiff class DiscourseDiff
MAX_DIFFERENCE = 200 MAX_DIFFERENCE = 200
def initialize(before, after) def initialize(before, after)
@ -9,8 +8,8 @@ class DiscourseDiff
@after = after @after = after
before_html = tokenize_html_blocks(@before) before_html = tokenize_html_blocks(@before)
after_html = tokenize_html_blocks(@after) after_html = tokenize_html_blocks(@after)
before_markdown = tokenize_line(CGI::escapeHTML(@before)) before_markdown = tokenize_line(CGI.escapeHTML(@before))
after_markdown = tokenize_line(CGI::escapeHTML(@after)) after_markdown = tokenize_line(CGI.escapeHTML(@after))
@block_by_block_diff = ONPDiff.new(before_html, after_html).paragraph_diff @block_by_block_diff = ONPDiff.new(before_html, after_html).paragraph_diff
@line_by_line_diff = ONPDiff.new(before_markdown, after_markdown).short_diff @line_by_line_diff = ONPDiff.new(before_markdown, after_markdown).short_diff
@ -21,7 +20,8 @@ class DiscourseDiff
inline = [] inline = []
while i < @block_by_block_diff.size while i < @block_by_block_diff.size
op_code = @block_by_block_diff[i][1] op_code = @block_by_block_diff[i][1]
if op_code == :common then inline << @block_by_block_diff[i][0] if op_code == :common
inline << @block_by_block_diff[i][0]
else else
if op_code == :delete if op_code == :delete
opposite_op_code = :add opposite_op_code = :add
@ -36,7 +36,11 @@ class DiscourseDiff
end end
if i + 1 < @block_by_block_diff.size && @block_by_block_diff[i + 1][1] == opposite_op_code if i + 1 < @block_by_block_diff.size && @block_by_block_diff[i + 1][1] == opposite_op_code
diff = ONPDiff.new(tokenize_html(@block_by_block_diff[first][0]), tokenize_html(@block_by_block_diff[second][0])).diff diff =
ONPDiff.new(
tokenize_html(@block_by_block_diff[first][0]),
tokenize_html(@block_by_block_diff[second][0]),
).diff
inline << generate_inline_html(diff) inline << generate_inline_html(diff)
i += 1 i += 1
else else
@ -73,7 +77,11 @@ class DiscourseDiff
end end
if i + 1 < @block_by_block_diff.size && @block_by_block_diff[i + 1][1] == opposite_op_code if i + 1 < @block_by_block_diff.size && @block_by_block_diff[i + 1][1] == opposite_op_code
diff = ONPDiff.new(tokenize_html(@block_by_block_diff[first][0]), tokenize_html(@block_by_block_diff[second][0])).diff diff =
ONPDiff.new(
tokenize_html(@block_by_block_diff[first][0]),
tokenize_html(@block_by_block_diff[second][0]),
).diff
deleted, inserted = generate_side_by_side_html(diff) deleted, inserted = generate_side_by_side_html(diff)
left << deleted left << deleted
right << inserted right << inserted
@ -109,9 +117,13 @@ class DiscourseDiff
end end
if i + 1 < @line_by_line_diff.size && @line_by_line_diff[i + 1][1] == opposite_op_code if i + 1 < @line_by_line_diff.size && @line_by_line_diff[i + 1][1] == opposite_op_code
before_tokens, after_tokens = tokenize_markdown(@line_by_line_diff[first][0]), tokenize_markdown(@line_by_line_diff[second][0]) before_tokens, after_tokens =
tokenize_markdown(@line_by_line_diff[first][0]),
tokenize_markdown(@line_by_line_diff[second][0])
if (before_tokens.size - after_tokens.size).abs > MAX_DIFFERENCE if (before_tokens.size - after_tokens.size).abs > MAX_DIFFERENCE
before_tokens, after_tokens = tokenize_line(@line_by_line_diff[first][0]), tokenize_line(@line_by_line_diff[second][0]) before_tokens, after_tokens =
tokenize_line(@line_by_line_diff[first][0]),
tokenize_line(@line_by_line_diff[second][0])
end end
diff = ONPDiff.new(before_tokens, after_tokens).short_diff diff = ONPDiff.new(before_tokens, after_tokens).short_diff
deleted, inserted = generate_side_by_side_markdown(diff) deleted, inserted = generate_side_by_side_markdown(diff)
@ -178,7 +190,7 @@ class DiscourseDiff
def add_class_or_wrap_in_tags(html_or_text, klass) def add_class_or_wrap_in_tags(html_or_text, klass)
result = html_or_text.dup result = html_or_text.dup
index_of_next_chevron = result.index(">") index_of_next_chevron = result.index(">")
if result.size > 0 && result[0] == '<' && index_of_next_chevron if result.size > 0 && result[0] == "<" && index_of_next_chevron
index_of_class = result.index("class=") index_of_class = result.index("class=")
if index_of_class.nil? || index_of_class > index_of_next_chevron if index_of_class.nil? || index_of_class > index_of_next_chevron
# we do not have a class for the current tag # we do not have a class for the current tag
@ -202,9 +214,12 @@ class DiscourseDiff
inline = [] inline = []
diff.each do |d| diff.each do |d|
case d[1] case d[1]
when :common then inline << d[0] when :common
when :delete then inline << add_class_or_wrap_in_tags(d[0], "del") inline << d[0]
when :add then inline << add_class_or_wrap_in_tags(d[0], "ins") when :delete
inline << add_class_or_wrap_in_tags(d[0], "del")
when :add
inline << add_class_or_wrap_in_tags(d[0], "ins")
end end
end end
inline inline
@ -217,8 +232,10 @@ class DiscourseDiff
when :common when :common
deleted << d[0] deleted << d[0]
inserted << d[0] inserted << d[0]
when :delete then deleted << add_class_or_wrap_in_tags(d[0], "del") when :delete
when :add then inserted << add_class_or_wrap_in_tags(d[0], "ins") deleted << add_class_or_wrap_in_tags(d[0], "del")
when :add
inserted << add_class_or_wrap_in_tags(d[0], "ins")
end end
end end
[deleted, inserted] [deleted, inserted]
@ -231,15 +248,16 @@ class DiscourseDiff
when :common when :common
deleted << d[0] deleted << d[0]
inserted << d[0] inserted << d[0]
when :delete then deleted << "<del>#{d[0]}</del>" when :delete
when :add then inserted << "<ins>#{d[0]}</ins>" deleted << "<del>#{d[0]}</del>"
when :add
inserted << "<ins>#{d[0]}</ins>"
end end
end end
[deleted, inserted] [deleted, inserted]
end end
class HtmlTokenizer < Nokogiri::XML::SAX::Document class HtmlTokenizer < Nokogiri::XML::SAX::Document
attr_accessor :tokens attr_accessor :tokens
def initialize def initialize
@ -253,23 +271,21 @@ class DiscourseDiff
me.tokens me.tokens
end end
USELESS_TAGS = %w{html body} USELESS_TAGS = %w[html body]
def start_element(name, attributes = []) def start_element(name, attributes = [])
return if USELESS_TAGS.include?(name) return if USELESS_TAGS.include?(name)
attrs = attributes.map { |a| " #{a[0]}=\"#{CGI::escapeHTML(a[1])}\"" }.join attrs = attributes.map { |a| " #{a[0]}=\"#{CGI.escapeHTML(a[1])}\"" }.join
@tokens << "<#{name}#{attrs}>" @tokens << "<#{name}#{attrs}>"
end end
AUTOCLOSING_TAGS = %w{area base br col embed hr img input meta} AUTOCLOSING_TAGS = %w[area base br col embed hr img input meta]
def end_element(name) def end_element(name)
return if USELESS_TAGS.include?(name) || AUTOCLOSING_TAGS.include?(name) return if USELESS_TAGS.include?(name) || AUTOCLOSING_TAGS.include?(name)
@tokens << "</#{name}>" @tokens << "</#{name}>"
end end
def characters(string) def characters(string)
@tokens.concat string.scan(/\W|\w+[ \t]*/).map { |x| CGI::escapeHTML(x) } @tokens.concat string.scan(/\W|\w+[ \t]*/).map { |x| CGI.escapeHTML(x) }
end end
end end
end end

View File

@ -3,21 +3,23 @@
# This is meant to be used by plugins to trigger and listen to events # This is meant to be used by plugins to trigger and listen to events
# So we can execute code when things happen. # So we can execute code when things happen.
class DiscourseEvent class DiscourseEvent
# Defaults to a hash where default values are empty sets. # Defaults to a hash where default values are empty sets.
def self.events def self.events
@events ||= Hash.new { |hash, key| hash[key] = Set.new } @events ||= Hash.new { |hash, key| hash[key] = Set.new }
end end
def self.trigger(event_name, *args, **kwargs) def self.trigger(event_name, *args, **kwargs)
events[event_name].each do |event| events[event_name].each { |event| event.call(*args, **kwargs) }
event.call(*args, **kwargs)
end
end end
def self.on(event_name, &block) def self.on(event_name, &block)
if event_name == :site_setting_saved if event_name == :site_setting_saved
Discourse.deprecate("The :site_setting_saved event is deprecated. Please use :site_setting_changed instead", since: "2.3.0beta8", drop_from: "2.4", raise_error: true) Discourse.deprecate(
"The :site_setting_saved event is deprecated. Please use :site_setting_changed instead",
since: "2.3.0beta8",
drop_from: "2.4",
raise_error: true,
)
end end
events[event_name] << block events[event_name] << block
end end

View File

@ -1,16 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
module DiscourseHub module DiscourseHub
STATS_FETCHED_AT_KEY = "stats_fetched_at" STATS_FETCHED_AT_KEY = "stats_fetched_at"
def self.version_check_payload def self.version_check_payload
default_payload = { installed_version: Discourse::VERSION::STRING }.merge!(Discourse.git_branch == "unknown" ? {} : { branch: Discourse.git_branch }) default_payload = { installed_version: Discourse::VERSION::STRING }.merge!(
Discourse.git_branch == "unknown" ? {} : { branch: Discourse.git_branch },
)
default_payload.merge!(get_payload) default_payload.merge!(get_payload)
end end
def self.discourse_version_check def self.discourse_version_check
get('/version_check', version_check_payload) get("/version_check", version_check_payload)
end end
def self.stats_fetched_at=(time_with_zone) def self.stats_fetched_at=(time_with_zone)
@ -18,7 +19,11 @@ module DiscourseHub
end end
def self.get_payload def self.get_payload
SiteSetting.share_anonymized_statistics && stats_fetched_at < 7.days.ago ? About.fetch_cached_stats.symbolize_keys : {} if SiteSetting.share_anonymized_statistics && stats_fetched_at < 7.days.ago
About.fetch_cached_stats.symbolize_keys
else
{}
end
end end
def self.get(rel_url, params = {}) def self.get(rel_url, params = {})
@ -40,27 +45,39 @@ module DiscourseHub
def self.singular_action(action, rel_url, params = {}) def self.singular_action(action, rel_url, params = {})
connect_opts = connect_opts(params) connect_opts = connect_opts(params)
JSON.parse(Excon.public_send(action, JSON.parse(
"#{hub_base_url}#{rel_url}", Excon.public_send(
{ action,
headers: { 'Referer' => referer, 'Accept' => accepts.join(', ') }, "#{hub_base_url}#{rel_url}",
query: params, {
omit_default_port: true headers: {
}.merge(connect_opts) "Referer" => referer,
).body) "Accept" => accepts.join(", "),
},
query: params,
omit_default_port: true,
}.merge(connect_opts),
).body,
)
end end
def self.collection_action(action, rel_url, params = {}) def self.collection_action(action, rel_url, params = {})
connect_opts = connect_opts(params) connect_opts = connect_opts(params)
response = Excon.public_send(action, response =
"#{hub_base_url}#{rel_url}", Excon.public_send(
{ action,
body: JSON[params], "#{hub_base_url}#{rel_url}",
headers: { 'Referer' => referer, 'Accept' => accepts.join(', '), "Content-Type" => "application/json" }, {
omit_default_port: true body: JSON[params],
}.merge(connect_opts) headers: {
) "Referer" => referer,
"Accept" => accepts.join(", "),
"Content-Type" => "application/json",
},
omit_default_port: true,
}.merge(connect_opts),
)
if (status = response.status) != 200 if (status = response.status) != 200
Rails.logger.warn(response_status_log_message(rel_url, status)) Rails.logger.warn(response_status_log_message(rel_url, status))
@ -87,14 +104,14 @@ module DiscourseHub
def self.hub_base_url def self.hub_base_url
if Rails.env.production? if Rails.env.production?
ENV['HUB_BASE_URL'] || 'https://api.discourse.org/api' ENV["HUB_BASE_URL"] || "https://api.discourse.org/api"
else else
ENV['HUB_BASE_URL'] || 'http://local.hub:3000/api' ENV["HUB_BASE_URL"] || "http://local.hub:3000/api"
end end
end end
def self.accepts def self.accepts
['application/json', 'application/vnd.discoursehub.v1'] %w[application/json application/vnd.discoursehub.v1]
end end
def self.referer def self.referer
@ -105,5 +122,4 @@ module DiscourseHub
t = Discourse.redis.get(STATS_FETCHED_AT_KEY) t = Discourse.redis.get(STATS_FETCHED_AT_KEY)
t ? Time.zone.at(t.to_i) : 1.year.ago t ? Time.zone.at(t.to_i) : 1.year.ago
end end
end end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'maxminddb' require "maxminddb"
require 'resolv' require "resolv"
class DiscourseIpInfo class DiscourseIpInfo
include Singleton include Singleton
@ -11,13 +11,13 @@ class DiscourseIpInfo
end end
def open_db(path) def open_db(path)
@loc_mmdb = mmdb_load(File.join(path, 'GeoLite2-City.mmdb')) @loc_mmdb = mmdb_load(File.join(path, "GeoLite2-City.mmdb"))
@asn_mmdb = mmdb_load(File.join(path, 'GeoLite2-ASN.mmdb')) @asn_mmdb = mmdb_load(File.join(path, "GeoLite2-ASN.mmdb"))
@cache = LruRedux::ThreadSafeCache.new(2000) @cache = LruRedux::ThreadSafeCache.new(2000)
end end
def self.path def self.path
@path ||= File.join(Rails.root, 'vendor', 'data') @path ||= File.join(Rails.root, "vendor", "data")
end end
def self.mmdb_path(name) def self.mmdb_path(name)
@ -25,7 +25,6 @@ class DiscourseIpInfo
end end
def self.mmdb_download(name) def self.mmdb_download(name)
if GlobalSetting.maxmind_license_key.blank? if GlobalSetting.maxmind_license_key.blank?
STDERR.puts "MaxMind IP database updates require a license" STDERR.puts "MaxMind IP database updates require a license"
STDERR.puts "Please set DISCOURSE_MAXMIND_LICENSE_KEY to one you generated at https://www.maxmind.com" STDERR.puts "Please set DISCOURSE_MAXMIND_LICENSE_KEY to one you generated at https://www.maxmind.com"
@ -34,41 +33,29 @@ class DiscourseIpInfo
FileUtils.mkdir_p(path) FileUtils.mkdir_p(path)
url = "https://download.maxmind.com/app/geoip_download?license_key=#{GlobalSetting.maxmind_license_key}&edition_id=#{name}&suffix=tar.gz" url =
"https://download.maxmind.com/app/geoip_download?license_key=#{GlobalSetting.maxmind_license_key}&edition_id=#{name}&suffix=tar.gz"
gz_file = FileHelper.download( gz_file =
url, FileHelper.download(
max_file_size: 100.megabytes, url,
tmp_file_name: "#{name}.gz", max_file_size: 100.megabytes,
validate_uri: false, tmp_file_name: "#{name}.gz",
follow_redirect: false validate_uri: false,
) follow_redirect: false,
)
filename = File.basename(gz_file.path) filename = File.basename(gz_file.path)
dir = "#{Dir.tmpdir}/#{SecureRandom.hex}" dir = "#{Dir.tmpdir}/#{SecureRandom.hex}"
Discourse::Utils.execute_command( Discourse::Utils.execute_command("mkdir", "-p", dir)
"mkdir", "-p", dir
)
Discourse::Utils.execute_command( Discourse::Utils.execute_command("cp", gz_file.path, "#{dir}/#{filename}")
"cp",
gz_file.path,
"#{dir}/#{filename}"
)
Discourse::Utils.execute_command( Discourse::Utils.execute_command("tar", "-xzvf", "#{dir}/#{filename}", chdir: dir)
"tar",
"-xzvf",
"#{dir}/#{filename}",
chdir: dir
)
Dir["#{dir}/**/*.mmdb"].each do |f|
FileUtils.mv(f, mmdb_path(name))
end
Dir["#{dir}/**/*.mmdb"].each { |f| FileUtils.mv(f, mmdb_path(name)) }
ensure ensure
FileUtils.rm_r(dir, force: true) if dir FileUtils.rm_r(dir, force: true) if dir
gz_file&.close! gz_file&.close!
@ -96,7 +83,8 @@ class DiscourseIpInfo
if result&.found? if result&.found?
ret[:country] = result.country.name(locale) || result.country.name ret[:country] = result.country.name(locale) || result.country.name
ret[:country_code] = result.country.iso_code ret[:country_code] = result.country.iso_code
ret[:region] = result.subdivisions.most_specific.name(locale) || result.subdivisions.most_specific.name ret[:region] = result.subdivisions.most_specific.name(locale) ||
result.subdivisions.most_specific.name
ret[:city] = result.city.name(locale) || result.city.name ret[:city] = result.city.name(locale) || result.city.name
ret[:latitude] = result.location.latitude ret[:latitude] = result.location.latitude
ret[:longitude] = result.location.longitude ret[:longitude] = result.location.longitude
@ -104,13 +92,18 @@ class DiscourseIpInfo
# used by plugins or API to locate users more accurately # used by plugins or API to locate users more accurately
ret[:geoname_ids] = [ ret[:geoname_ids] = [
result.continent.geoname_id, result.country.geoname_id, result.city.geoname_id, result.continent.geoname_id,
*result.subdivisions.map(&:geoname_id) result.country.geoname_id,
result.city.geoname_id,
*result.subdivisions.map(&:geoname_id),
] ]
ret[:geoname_ids].compact! ret[:geoname_ids].compact!
end end
rescue => e rescue => e
Discourse.warn_exception(e, message: "IP #{ip} could not be looked up in MaxMind GeoLite2-City database.") Discourse.warn_exception(
e,
message: "IP #{ip} could not be looked up in MaxMind GeoLite2-City database.",
)
end end
end end
@ -123,7 +116,10 @@ class DiscourseIpInfo
ret[:organization] = result["autonomous_system_organization"] ret[:organization] = result["autonomous_system_organization"]
end end
rescue => e rescue => e
Discourse.warn_exception(e, message: "IP #{ip} could not be looked up in MaxMind GeoLite2-ASN database.") Discourse.warn_exception(
e,
message: "IP #{ip} could not be looked up in MaxMind GeoLite2-ASN database.",
)
end end
end end
@ -142,10 +138,13 @@ class DiscourseIpInfo
def get(ip, locale: :en, resolve_hostname: false) def get(ip, locale: :en, resolve_hostname: false)
ip = ip.to_s ip = ip.to_s
locale = locale.to_s.sub('_', '-') locale = locale.to_s.sub("_", "-")
@cache["#{ip}-#{locale}-#{resolve_hostname}"] ||= @cache["#{ip}-#{locale}-#{resolve_hostname}"] ||= lookup(
lookup(ip, locale: locale, resolve_hostname: resolve_hostname) ip,
locale: locale,
resolve_hostname: resolve_hostname,
)
end end
def self.open_db(path) def self.open_db(path)

View File

@ -1,27 +1,28 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'execjs' require "execjs"
require 'mini_racer' require "mini_racer"
class DiscourseJsProcessor class DiscourseJsProcessor
class TranspileError < StandardError; end class TranspileError < StandardError
end
DISCOURSE_COMMON_BABEL_PLUGINS = [ DISCOURSE_COMMON_BABEL_PLUGINS = [
'proposal-optional-chaining', "proposal-optional-chaining",
['proposal-decorators', { legacy: true } ], ["proposal-decorators", { legacy: true }],
'transform-template-literals', "transform-template-literals",
'proposal-class-properties', "proposal-class-properties",
'proposal-class-static-block', "proposal-class-static-block",
'proposal-private-property-in-object', "proposal-private-property-in-object",
'proposal-private-methods', "proposal-private-methods",
'proposal-numeric-separator', "proposal-numeric-separator",
'proposal-logical-assignment-operators', "proposal-logical-assignment-operators",
'proposal-nullish-coalescing-operator', "proposal-nullish-coalescing-operator",
'proposal-json-strings', "proposal-json-strings",
'proposal-optional-catch-binding', "proposal-optional-catch-binding",
'transform-parameters', "transform-parameters",
'proposal-async-generator-functions', "proposal-async-generator-functions",
'proposal-object-rest-spread', "proposal-object-rest-spread",
'proposal-export-namespace-from', "proposal-export-namespace-from",
] ]
def self.plugin_transpile_paths def self.plugin_transpile_paths
@ -33,22 +34,22 @@ class DiscourseJsProcessor
end end
def self.call(input) def self.call(input)
root_path = input[:load_path] || '' root_path = input[:load_path] || ""
logical_path = (input[:filename] || '').sub(root_path, '').gsub(/\.(js|es6).*$/, '').sub(/^\//, '') logical_path =
(input[:filename] || "").sub(root_path, "").gsub(/\.(js|es6).*$/, "").sub(%r{^/}, "")
data = input[:data] data = input[:data]
if should_transpile?(input[:filename]) data = transpile(data, root_path, logical_path) if should_transpile?(input[:filename])
data = transpile(data, root_path, logical_path)
end
# add sourceURL until we can do proper source maps # add sourceURL until we can do proper source maps
if !Rails.env.production? && !ember_cli?(input[:filename]) if !Rails.env.production? && !ember_cli?(input[:filename])
plugin_name = root_path[/\/plugins\/([\w-]+)\/assets/, 1] plugin_name = root_path[%r{/plugins/([\w-]+)/assets}, 1]
source_url = if plugin_name source_url =
"plugins/#{plugin_name}/assets/javascripts/#{logical_path}" if plugin_name
else "plugins/#{plugin_name}/assets/javascripts/#{logical_path}"
logical_path else
end logical_path
end
data = "eval(#{data.inspect} + \"\\n//# sourceURL=#{source_url}\");\n" data = "eval(#{data.inspect} + \"\\n//# sourceURL=#{source_url}\");\n"
end end
@ -62,7 +63,7 @@ class DiscourseJsProcessor
end end
def self.should_transpile?(filename) def self.should_transpile?(filename)
filename ||= '' filename ||= ""
# skip ember cli # skip ember cli
return false if ember_cli?(filename) return false if ember_cli?(filename)
@ -73,7 +74,7 @@ class DiscourseJsProcessor
# For .js check the path... # For .js check the path...
return false unless filename.end_with?(".js") || filename.end_with?(".js.erb") return false unless filename.end_with?(".js") || filename.end_with?(".js.erb")
relative_path = filename.sub(Rails.root.to_s, '').sub(/^\/*/, '') relative_path = filename.sub(Rails.root.to_s, "").sub(%r{^/*}, "")
js_root = "app/assets/javascripts" js_root = "app/assets/javascripts"
test_root = "test/javascripts" test_root = "test/javascripts"
@ -81,26 +82,27 @@ class DiscourseJsProcessor
return false if relative_path.start_with?("#{js_root}/locales/") return false if relative_path.start_with?("#{js_root}/locales/")
return false if relative_path.start_with?("#{js_root}/plugins/") return false if relative_path.start_with?("#{js_root}/plugins/")
return true if %w( if %w[
start-discourse start-discourse
onpopstate-handler onpopstate-handler
google-tag-manager google-tag-manager
google-universal-analytics-v3 google-universal-analytics-v3
google-universal-analytics-v4 google-universal-analytics-v4
activate-account activate-account
auto-redirect auto-redirect
embed-application embed-application
app-boot app-boot
).any? { |f| relative_path == "#{js_root}/#{f}.js" } ].any? { |f| relative_path == "#{js_root}/#{f}.js" }
return true
end
return true if plugin_transpile_paths.any? { |prefix| relative_path.start_with?(prefix) } return true if plugin_transpile_paths.any? { |prefix| relative_path.start_with?(prefix) }
!!(relative_path =~ /^#{js_root}\/[^\/]+\// || !!(relative_path =~ %r{^#{js_root}/[^/]+/} || relative_path =~ %r{^#{test_root}/[^/]+/})
relative_path =~ /^#{test_root}\/[^\/]+\//)
end end
def self.skip_module?(data) def self.skip_module?(data)
!!(data.present? && data =~ /^\/\/ discourse-skip-module$/) !!(data.present? && data =~ %r{^// discourse-skip-module$})
end end
class Transpiler class Transpiler
@ -113,19 +115,17 @@ class DiscourseJsProcessor
def self.load_file_in_context(ctx, path, wrap_in_module: nil) def self.load_file_in_context(ctx, path, wrap_in_module: nil)
contents = File.read("#{Rails.root}/app/assets/javascripts/#{path}") contents = File.read("#{Rails.root}/app/assets/javascripts/#{path}")
if wrap_in_module contents = <<~JS if wrap_in_module
contents = <<~JS
define(#{wrap_in_module.to_json}, ["exports", "require", "module"], function(exports, require, module){ define(#{wrap_in_module.to_json}, ["exports", "require", "module"], function(exports, require, module){
#{contents} #{contents}
}); });
JS JS
end
ctx.eval(contents, filename: path) ctx.eval(contents, filename: path)
end end
def self.create_new_context def self.create_new_context
# timeout any eval that takes longer than 15 seconds # timeout any eval that takes longer than 15 seconds
ctx = MiniRacer::Context.new(timeout: 15000, ensure_gc_after_idle: 2000) ctx = MiniRacer::Context.new(timeout: 15_000, ensure_gc_after_idle: 2000)
# General shims # General shims
ctx.attach("rails.logger.info", proc { |err| Rails.logger.info(err.to_s) }) ctx.attach("rails.logger.info", proc { |err| Rails.logger.info(err.to_s) })
@ -158,10 +158,26 @@ class DiscourseJsProcessor
# Template Compiler # Template Compiler
load_file_in_context(ctx, "node_modules/ember-source/dist/ember-template-compiler.js") load_file_in_context(ctx, "node_modules/ember-source/dist/ember-template-compiler.js")
load_file_in_context(ctx, "node_modules/babel-plugin-ember-template-compilation/src/plugin.js", wrap_in_module: "babel-plugin-ember-template-compilation/index") load_file_in_context(
load_file_in_context(ctx, "node_modules/babel-plugin-ember-template-compilation/src/expression-parser.js", wrap_in_module: "babel-plugin-ember-template-compilation/expression-parser") ctx,
load_file_in_context(ctx, "node_modules/babel-import-util/src/index.js", wrap_in_module: "babel-import-util") "node_modules/babel-plugin-ember-template-compilation/src/plugin.js",
load_file_in_context(ctx, "node_modules/ember-cli-htmlbars/lib/colocated-babel-plugin.js", wrap_in_module: "colocated-babel-plugin") wrap_in_module: "babel-plugin-ember-template-compilation/index",
)
load_file_in_context(
ctx,
"node_modules/babel-plugin-ember-template-compilation/src/expression-parser.js",
wrap_in_module: "babel-plugin-ember-template-compilation/expression-parser",
)
load_file_in_context(
ctx,
"node_modules/babel-import-util/src/index.js",
wrap_in_module: "babel-import-util",
)
load_file_in_context(
ctx,
"node_modules/ember-cli-htmlbars/lib/colocated-babel-plugin.js",
wrap_in_module: "colocated-babel-plugin",
)
# Widget HBS compiler # Widget HBS compiler
widget_hbs_compiler_source = File.read("#{Rails.root}/lib/javascripts/widget-hbs-compiler.js") widget_hbs_compiler_source = File.read("#{Rails.root}/lib/javascripts/widget-hbs-compiler.js")
@ -170,32 +186,44 @@ class DiscourseJsProcessor
#{widget_hbs_compiler_source} #{widget_hbs_compiler_source}
}); });
JS JS
widget_hbs_compiler_transpiled = ctx.call("rawBabelTransform", widget_hbs_compiler_source, { widget_hbs_compiler_transpiled =
ast: false, ctx.call(
moduleId: 'widget-hbs-compiler', "rawBabelTransform",
plugins: DISCOURSE_COMMON_BABEL_PLUGINS widget_hbs_compiler_source,
}) { ast: false, moduleId: "widget-hbs-compiler", plugins: DISCOURSE_COMMON_BABEL_PLUGINS },
)
ctx.eval(widget_hbs_compiler_transpiled, filename: "widget-hbs-compiler.js") ctx.eval(widget_hbs_compiler_transpiled, filename: "widget-hbs-compiler.js")
# Raw HBS compiler # Raw HBS compiler
load_file_in_context(ctx, "node_modules/handlebars/dist/handlebars.js", wrap_in_module: "handlebars") load_file_in_context(
ctx,
raw_hbs_transpiled = ctx.call( "node_modules/handlebars/dist/handlebars.js",
"rawBabelTransform", wrap_in_module: "handlebars",
File.read("#{Rails.root}/app/assets/javascripts/discourse-common/addon/lib/raw-handlebars.js"),
{
ast: false,
moduleId: "raw-handlebars",
plugins: [
['transform-modules-amd', { noInterop: true }],
*DISCOURSE_COMMON_BABEL_PLUGINS
]
}
) )
raw_hbs_transpiled =
ctx.call(
"rawBabelTransform",
File.read(
"#{Rails.root}/app/assets/javascripts/discourse-common/addon/lib/raw-handlebars.js",
),
{
ast: false,
moduleId: "raw-handlebars",
plugins: [
["transform-modules-amd", { noInterop: true }],
*DISCOURSE_COMMON_BABEL_PLUGINS,
],
},
)
ctx.eval(raw_hbs_transpiled, filename: "raw-handlebars.js") ctx.eval(raw_hbs_transpiled, filename: "raw-handlebars.js")
# Theme template AST transformation plugins # Theme template AST transformation plugins
load_file_in_context(ctx, "discourse-js-processor.js", wrap_in_module: "discourse-js-processor") load_file_in_context(
ctx,
"discourse-js-processor.js",
wrap_in_module: "discourse-js-processor",
)
# Make interfaces available via `v8.call` # Make interfaces available via `v8.call`
ctx.eval <<~JS ctx.eval <<~JS
@ -262,10 +290,10 @@ class DiscourseJsProcessor
{ {
skip_module: @skip_module, skip_module: @skip_module,
moduleId: module_name(root_path, logical_path), moduleId: module_name(root_path, logical_path),
filename: logical_path || 'unknown', filename: logical_path || "unknown",
themeId: theme_id, themeId: theme_id,
commonPlugins: DISCOURSE_COMMON_BABEL_PLUGINS commonPlugins: DISCOURSE_COMMON_BABEL_PLUGINS,
} },
) )
end end
@ -274,15 +302,16 @@ class DiscourseJsProcessor
root_base = File.basename(Rails.root) root_base = File.basename(Rails.root)
# If the resource is a plugin, use the plugin name as a prefix # If the resource is a plugin, use the plugin name as a prefix
if root_path =~ /(.*\/#{root_base}\/plugins\/[^\/]+)\// if root_path =~ %r{(.*/#{root_base}/plugins/[^/]+)/}
plugin_path = "#{Regexp.last_match[1]}/plugin.rb" plugin_path = "#{Regexp.last_match[1]}/plugin.rb"
plugin = Discourse.plugins.find { |p| p.path == plugin_path } plugin = Discourse.plugins.find { |p| p.path == plugin_path }
path = "discourse/plugins/#{plugin.name}/#{logical_path.sub(/javascripts\//, '')}" if plugin path =
"discourse/plugins/#{plugin.name}/#{logical_path.sub(%r{javascripts/}, "")}" if plugin
end end
# We need to strip the app subdirectory to replicate how ember-cli works. # We need to strip the app subdirectory to replicate how ember-cli works.
path || logical_path&.gsub('app/', '')&.gsub('addon/', '')&.gsub('admin/addon', 'admin') path || logical_path&.gsub("app/", "")&.gsub("addon/", "")&.gsub("admin/addon", "admin")
end end
def compile_raw_template(source, theme_id: nil) def compile_raw_template(source, theme_id: nil)

View File

@ -1,27 +1,28 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'logstash-logger' require "logstash-logger"
class DiscourseLogstashLogger class DiscourseLogstashLogger
def self.logger(uri:, type:) def self.logger(uri:, type:)
# See Discourse.os_hostname # See Discourse.os_hostname
hostname = begin hostname =
require 'socket' begin
Socket.gethostname require "socket"
rescue => e Socket.gethostname
`hostname`.chomp rescue => e
end `hostname`.chomp
end
LogStashLogger.new( LogStashLogger.new(
uri: uri, uri: uri,
sync: true, sync: true,
customize_event: ->(event) { customize_event: ->(event) do
event['hostname'] = hostname event["hostname"] = hostname
event['severity_name'] = event['severity'] event["severity_name"] = event["severity"]
event['severity'] = Object.const_get("Logger::Severity::#{event['severity']}") event["severity"] = Object.const_get("Logger::Severity::#{event["severity"]}")
event['type'] = type event["type"] = type
event['pid'] = Process.pid event["pid"] = Process.pid
}, end,
) )
end end
end end

View File

@ -4,7 +4,6 @@
# A class that handles interaction between a plugin and the Discourse App. # A class that handles interaction between a plugin and the Discourse App.
# #
class DiscoursePluginRegistry class DiscoursePluginRegistry
# Plugins often need to be able to register additional handlers, data, or # Plugins often need to be able to register additional handlers, data, or
# classes that will be used by core classes. This should be used if you # classes that will be used by core classes. This should be used if you
# need to control which type the registry is, and if it doesn't need to # need to control which type the registry is, and if it doesn't need to
@ -24,9 +23,7 @@ class DiscoursePluginRegistry
instance_variable_set(:"@#{register_name}", type.new) instance_variable_set(:"@#{register_name}", type.new)
end end
define_method(register_name) do define_method(register_name) { self.class.public_send(register_name) }
self.class.public_send(register_name)
end
end end
# Plugins often need to add values to a list, and we need to filter those # Plugins often need to add values to a list, and we need to filter those
@ -45,10 +42,7 @@ class DiscoursePluginRegistry
define_singleton_method(register_name) do define_singleton_method(register_name) do
unfiltered = public_send(:"_raw_#{register_name}") unfiltered = public_send(:"_raw_#{register_name}")
unfiltered unfiltered.filter { |v| v[:plugin].enabled? }.map { |v| v[:value] }.uniq
.filter { |v| v[:plugin].enabled? }
.map { |v| v[:value] }
.uniq
end end
define_singleton_method("register_#{register_name.to_s.singularize}") do |value, plugin| define_singleton_method("register_#{register_name.to_s.singularize}") do |value, plugin|
@ -158,9 +152,7 @@ class DiscoursePluginRegistry
next if each_options[:admin] next if each_options[:admin]
end end
Dir.glob("#{root}/**/*.#{ext}") do |f| Dir.glob("#{root}/**/*.#{ext}") { |f| yield f }
yield f
end
end end
end end
@ -227,7 +219,7 @@ class DiscoursePluginRegistry
def self.seed_paths def self.seed_paths
result = SeedFu.fixture_paths.dup result = SeedFu.fixture_paths.dup
unless Rails.env.test? && ENV['LOAD_PLUGINS'] != "1" unless Rails.env.test? && ENV["LOAD_PLUGINS"] != "1"
seed_path_builders.each { |b| result += b.call } seed_path_builders.each { |b| result += b.call }
end end
result.uniq result.uniq
@ -239,7 +231,7 @@ class DiscoursePluginRegistry
VENDORED_CORE_PRETTY_TEXT_MAP = { VENDORED_CORE_PRETTY_TEXT_MAP = {
"moment.js" => "vendor/assets/javascripts/moment.js", "moment.js" => "vendor/assets/javascripts/moment.js",
"moment-timezone.js" => "vendor/assets/javascripts/moment-timezone-with-data.js" "moment-timezone.js" => "vendor/assets/javascripts/moment-timezone-with-data.js",
} }
def self.core_asset_for_name(name) def self.core_asset_for_name(name)
asset = VENDORED_CORE_PRETTY_TEXT_MAP[name] asset = VENDORED_CORE_PRETTY_TEXT_MAP[name]
@ -248,16 +240,12 @@ class DiscoursePluginRegistry
end end
def self.reset! def self.reset!
@@register_names.each do |name| @@register_names.each { |name| instance_variable_set(:"@#{name}", nil) }
instance_variable_set(:"@#{name}", nil)
end
end end
def self.reset_register!(register_name) def self.reset_register!(register_name)
found_register = @@register_names.detect { |name| name == register_name } found_register = @@register_names.detect { |name| name == register_name }
if found_register instance_variable_set(:"@#{found_register}", nil) if found_register
instance_variable_set(:"@#{found_register}", nil)
end
end end
end end

View File

@ -46,15 +46,103 @@ class DiscourseRedis
end end
# Proxy key methods through, but prefix the keys with the namespace # Proxy key methods through, but prefix the keys with the namespace
[:append, :blpop, :brpop, :brpoplpush, :decr, :decrby, :expire, :expireat, :get, :getbit, :getrange, :getset, %i[
:hdel, :hexists, :hget, :hgetall, :hincrby, :hincrbyfloat, :hkeys, :hlen, :hmget, :hmset, :hset, :hsetnx, :hvals, :incr, append
:incrby, :incrbyfloat, :lindex, :linsert, :llen, :lpop, :lpush, :lpushx, :lrange, :lrem, :lset, :ltrim, blpop
:mapped_hmset, :mapped_hmget, :mapped_mget, :mapped_mset, :mapped_msetnx, :move, :mset, brpop
:msetnx, :persist, :pexpire, :pexpireat, :psetex, :pttl, :rename, :renamenx, :rpop, :rpoplpush, :rpush, :rpushx, :sadd, :sadd?, :scard, brpoplpush
:sdiff, :set, :setbit, :setex, :setnx, :setrange, :sinter, :sismember, :smembers, :sort, :spop, :srandmember, :srem, :srem?, :strlen, decr
:sunion, :ttl, :type, :watch, :zadd, :zcard, :zcount, :zincrby, :zrange, :zrangebyscore, :zrank, :zrem, :zremrangebyrank, decrby
:zremrangebyscore, :zrevrange, :zrevrangebyscore, :zrevrank, :zrangebyscore, expire
:dump, :restore].each do |m| expireat
get
getbit
getrange
getset
hdel
hexists
hget
hgetall
hincrby
hincrbyfloat
hkeys
hlen
hmget
hmset
hset
hsetnx
hvals
incr
incrby
incrbyfloat
lindex
linsert
llen
lpop
lpush
lpushx
lrange
lrem
lset
ltrim
mapped_hmset
mapped_hmget
mapped_mget
mapped_mset
mapped_msetnx
move
mset
msetnx
persist
pexpire
pexpireat
psetex
pttl
rename
renamenx
rpop
rpoplpush
rpush
rpushx
sadd
sadd?
scard
sdiff
set
setbit
setex
setnx
setrange
sinter
sismember
smembers
sort
spop
srandmember
srem
srem?
strlen
sunion
ttl
type
watch
zadd
zcard
zcount
zincrby
zrange
zrangebyscore
zrank
zrem
zremrangebyrank
zremrangebyscore
zrevrange
zrevrangebyscore
zrevrank
zrangebyscore
dump
restore
].each do |m|
define_method m do |*args, **kwargs| define_method m do |*args, **kwargs|
args[0] = "#{namespace}:#{args[0]}" if @namespace args[0] = "#{namespace}:#{args[0]}" if @namespace
DiscourseRedis.ignore_readonly { @redis.public_send(m, *args, **kwargs) } DiscourseRedis.ignore_readonly { @redis.public_send(m, *args, **kwargs) }
@ -72,7 +160,7 @@ class DiscourseRedis
end end
def mget(*args) def mget(*args)
args.map! { |a| "#{namespace}:#{a}" } if @namespace args.map! { |a| "#{namespace}:#{a}" } if @namespace
DiscourseRedis.ignore_readonly { @redis.mget(*args) } DiscourseRedis.ignore_readonly { @redis.mget(*args) }
end end
@ -86,14 +174,13 @@ class DiscourseRedis
def scan_each(options = {}, &block) def scan_each(options = {}, &block)
DiscourseRedis.ignore_readonly do DiscourseRedis.ignore_readonly do
match = options[:match].presence || '*' match = options[:match].presence || "*"
options[:match] = options[:match] = if @namespace
if @namespace "#{namespace}:#{match}"
"#{namespace}:#{match}" else
else match
match end
end
if block if block
@redis.scan_each(**options) do |key| @redis.scan_each(**options) do |key|
@ -101,17 +188,19 @@ class DiscourseRedis
block.call(key) block.call(key)
end end
else else
@redis.scan_each(**options).map do |key| @redis
key = remove_namespace(key) if @namespace .scan_each(**options)
key .map do |key|
end key = remove_namespace(key) if @namespace
key
end
end end
end end
end end
def keys(pattern = nil) def keys(pattern = nil)
DiscourseRedis.ignore_readonly do DiscourseRedis.ignore_readonly do
pattern = pattern || '*' pattern = pattern || "*"
pattern = "#{namespace}:#{pattern}" if @namespace pattern = "#{namespace}:#{pattern}" if @namespace
keys = @redis.keys(pattern) keys = @redis.keys(pattern)
@ -125,9 +214,7 @@ class DiscourseRedis
end end
def delete_prefixed(prefix) def delete_prefixed(prefix)
DiscourseRedis.ignore_readonly do DiscourseRedis.ignore_readonly { keys("#{prefix}*").each { |k| Discourse.redis.del(k) } }
keys("#{prefix}*").each { |k| Discourse.redis.del(k) }
end
end end
def reconnect def reconnect

View File

@ -6,7 +6,8 @@
class DiscourseSourcemappingUrlProcessor < Sprockets::Rails::SourcemappingUrlProcessor class DiscourseSourcemappingUrlProcessor < Sprockets::Rails::SourcemappingUrlProcessor
def self.sourcemap_asset_path(sourcemap_logical_path, context:) def self.sourcemap_asset_path(sourcemap_logical_path, context:)
result = super(sourcemap_logical_path, context: context) result = super(sourcemap_logical_path, context: context)
if (File.basename(sourcemap_logical_path) === sourcemap_logical_path) || sourcemap_logical_path.start_with?("plugins/") if (File.basename(sourcemap_logical_path) === sourcemap_logical_path) ||
sourcemap_logical_path.start_with?("plugins/")
# If the original sourcemap reference is relative, keep it relative # If the original sourcemap reference is relative, keep it relative
result = File.basename(result) result = File.basename(result)
end end

View File

@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module DiscourseTagging module DiscourseTagging
TAGS_FIELD_NAME ||= "tags" TAGS_FIELD_NAME ||= "tags"
TAGS_FILTER_REGEXP ||= /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<> TAGS_FILTER_REGEXP ||= /[\/\?#\[\]@!\$&'\(\)\*\+,;=\.%\\`^\s|\{\}"<>]+/ # /?#[]@!$&'()*+,;=.%\`^|{}"<>
TAGS_STAFF_CACHE_KEY ||= "staff_tag_names" TAGS_STAFF_CACHE_KEY ||= "staff_tag_names"
@ -22,9 +21,11 @@ module DiscourseTagging
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || [] tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, guardian) || []
if !tag_names.empty? if !tag_names.empty?
Tag.where_name(tag_names).joins(:target_tag).includes(:target_tag).each do |tag| Tag
tag_names[tag_names.index(tag.name)] = tag.target_tag.name .where_name(tag_names)
end .joins(:target_tag)
.includes(:target_tag)
.each { |tag| tag_names[tag_names.index(tag.name)] = tag.target_tag.name }
end end
# tags currently on the topic # tags currently on the topic
@ -45,9 +46,7 @@ module DiscourseTagging
# If this user has explicit permission to use certain tags, # If this user has explicit permission to use certain tags,
# we need to ensure those tags are removed from the list of # we need to ensure those tags are removed from the list of
# restricted tags # restricted tags
if permitted_tags.present? readonly_tags = readonly_tags - permitted_tags if permitted_tags.present?
readonly_tags = readonly_tags - permitted_tags
end
# visible, but not usable, tags this user is trying to use # visible, but not usable, tags this user is trying to use
disallowed_tags = new_tag_names & readonly_tags disallowed_tags = new_tag_names & readonly_tags
@ -55,13 +54,19 @@ module DiscourseTagging
disallowed_tags += new_tag_names & hidden_tags disallowed_tags += new_tag_names & hidden_tags
if disallowed_tags.present? if disallowed_tags.present?
topic.errors.add(:base, I18n.t("tags.restricted_tag_disallowed", tag: disallowed_tags.join(" "))) topic.errors.add(
:base,
I18n.t("tags.restricted_tag_disallowed", tag: disallowed_tags.join(" ")),
)
return false return false
end end
removed_readonly_tags = removed_tag_names & readonly_tags removed_readonly_tags = removed_tag_names & readonly_tags
if removed_readonly_tags.present? if removed_readonly_tags.present?
topic.errors.add(:base, I18n.t("tags.restricted_tag_remove_disallowed", tag: removed_readonly_tags.join(" "))) topic.errors.add(
:base,
I18n.t("tags.restricted_tag_remove_disallowed", tag: removed_readonly_tags.join(" ")),
)
return false return false
end end
@ -73,50 +78,61 @@ module DiscourseTagging
if tag_names.present? if tag_names.present?
# guardian is explicitly nil cause we don't want to strip all # guardian is explicitly nil cause we don't want to strip all
# staff tags that already passed validation # staff tags that already passed validation
tags = filter_allowed_tags( tags =
nil, # guardian filter_allowed_tags(
for_topic: true, nil, # guardian
category: category, for_topic: true,
selected_tags: tag_names, category: category,
only_tag_names: tag_names selected_tags: tag_names,
) only_tag_names: tag_names,
)
# keep existent tags that current user cannot use # keep existent tags that current user cannot use
tags += Tag.where(name: old_tag_names & tag_names) tags += Tag.where(name: old_tag_names & tag_names)
tags = Tag.where(id: tags.map(&:id)).all.to_a if tags.size > 0 tags = Tag.where(id: tags.map(&:id)).all.to_a if tags.size > 0
if tags.size < tag_names.size && (category.nil? || category.allow_global_tags || (category.tags.count == 0 && category.tag_groups.count == 0)) if tags.size < tag_names.size &&
(
category.nil? || category.allow_global_tags ||
(category.tags.count == 0 && category.tag_groups.count == 0)
)
tag_names.each do |name| tag_names.each do |name|
unless Tag.where_name(name).exists? tags << Tag.create(name: name) unless Tag.where_name(name).exists?
tags << Tag.create(name: name)
end
end end
end end
# add missing mandatory parent tags # add missing mandatory parent tags
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
parent_tags_map = DB.query(" parent_tags_map =
DB
.query(
"
SELECT tgm.tag_id, tg.parent_tag_id SELECT tgm.tag_id, tg.parent_tag_id
FROM tag_groups tg FROM tag_groups tg
INNER JOIN tag_group_memberships tgm INNER JOIN tag_group_memberships tgm
ON tgm.tag_group_id = tg.id ON tgm.tag_group_id = tg.id
WHERE tg.parent_tag_id IS NOT NULL WHERE tg.parent_tag_id IS NOT NULL
AND tgm.tag_id IN (?) AND tgm.tag_id IN (?)
", tag_ids).inject({}) do |h, v| ",
h[v.tag_id] ||= [] tag_ids,
h[v.tag_id] << v.parent_tag_id )
h .inject({}) do |h, v|
end h[v.tag_id] ||= []
h[v.tag_id] << v.parent_tag_id
h
end
missing_parent_tag_ids = parent_tags_map.map do |_, parent_tag_ids| missing_parent_tag_ids =
(tag_ids & parent_tag_ids).size == 0 ? parent_tag_ids.first : nil parent_tags_map
end.compact.uniq .map do |_, parent_tag_ids|
(tag_ids & parent_tag_ids).size == 0 ? parent_tag_ids.first : nil
end
.compact
.uniq
unless missing_parent_tag_ids.empty? tags = tags + Tag.where(id: missing_parent_tag_ids).all unless missing_parent_tag_ids.empty?
tags = tags + Tag.where(id: missing_parent_tag_ids).all
end
return false unless validate_min_required_tags_for_category(guardian, topic, category, tags) return false unless validate_min_required_tags_for_category(guardian, topic, category, tags)
return false unless validate_required_tags_from_group(guardian, topic, category, tags) return false unless validate_required_tags_from_group(guardian, topic, category, tags)
@ -137,7 +153,9 @@ module DiscourseTagging
DiscourseEvent.trigger( DiscourseEvent.trigger(
:topic_tags_changed, :topic_tags_changed,
topic, old_tag_names: old_tag_names, new_tag_names: topic.tags.map(&:name) topic,
old_tag_names: old_tag_names,
new_tag_names: topic.tags.map(&:name),
) )
return true return true
@ -146,12 +164,12 @@ module DiscourseTagging
end end
def self.validate_min_required_tags_for_category(guardian, model, category, tags = []) def self.validate_min_required_tags_for_category(guardian, model, category, tags = [])
if !guardian.is_staff? && if !guardian.is_staff? && category && category.minimum_required_tags > 0 &&
category && tags.length < category.minimum_required_tags
category.minimum_required_tags > 0 && model.errors.add(
tags.length < category.minimum_required_tags :base,
I18n.t("tags.minimum_required_tags", count: category.minimum_required_tags),
model.errors.add(:base, I18n.t("tags.minimum_required_tags", count: category.minimum_required_tags)) )
false false
else else
true true
@ -164,17 +182,17 @@ module DiscourseTagging
success = true success = true
category.category_required_tag_groups.each do |crtg| category.category_required_tag_groups.each do |crtg|
if tags.length < crtg.min_count || if tags.length < crtg.min_count ||
crtg.tag_group.tags.where("tags.id in (?)", tags.map(&:id)).count < crtg.min_count crtg.tag_group.tags.where("tags.id in (?)", tags.map(&:id)).count < crtg.min_count
success = false success = false
model.errors.add(:base, model.errors.add(
:base,
I18n.t( I18n.t(
"tags.required_tags_from_group", "tags.required_tags_from_group",
count: crtg.min_count, count: crtg.min_count,
tag_group_name: crtg.tag_group.name, tag_group_name: crtg.tag_group.name,
tags: crtg.tag_group.tags.order(:id).pluck(:name).join(", ") tags: crtg.tag_group.tags.order(:id).pluck(:name).join(", "),
) ),
) )
end end
end end
@ -189,24 +207,28 @@ module DiscourseTagging
tags_restricted_to_categories = Hash.new { |h, k| h[k] = Set.new } tags_restricted_to_categories = Hash.new { |h, k| h[k] = Set.new }
query = Tag.where(name: tags) query = Tag.where(name: tags)
query.joins(tag_groups: :categories).pluck(:name, 'categories.id').each do |(tag, cat_id)| query
tags_restricted_to_categories[tag] << cat_id .joins(tag_groups: :categories)
end .pluck(:name, "categories.id")
query.joins(:categories).pluck(:name, 'categories.id').each do |(tag, cat_id)| .each { |(tag, cat_id)| tags_restricted_to_categories[tag] << cat_id }
tags_restricted_to_categories[tag] << cat_id query
end .joins(:categories)
.pluck(:name, "categories.id")
.each { |(tag, cat_id)| tags_restricted_to_categories[tag] << cat_id }
unallowed_tags = tags_restricted_to_categories.keys.select do |tag| unallowed_tags =
!tags_restricted_to_categories[tag].include?(category.id) tags_restricted_to_categories.keys.select do |tag|
end !tags_restricted_to_categories[tag].include?(category.id)
end
if unallowed_tags.present? if unallowed_tags.present?
msg = I18n.t( msg =
"tags.forbidden.restricted_tags_cannot_be_used_in_category", I18n.t(
count: unallowed_tags.size, "tags.forbidden.restricted_tags_cannot_be_used_in_category",
tags: unallowed_tags.sort.join(", "), count: unallowed_tags.size,
category: category.name tags: unallowed_tags.sort.join(", "),
) category: category.name,
)
model.errors.add(:base, msg) model.errors.add(:base, msg)
return false return false
end end
@ -214,12 +236,13 @@ module DiscourseTagging
if !category.allow_global_tags && category.has_restricted_tags? if !category.allow_global_tags && category.has_restricted_tags?
unrestricted_tags = tags - tags_restricted_to_categories.keys unrestricted_tags = tags - tags_restricted_to_categories.keys
if unrestricted_tags.present? if unrestricted_tags.present?
msg = I18n.t( msg =
"tags.forbidden.category_does_not_allow_tags", I18n.t(
count: unrestricted_tags.size, "tags.forbidden.category_does_not_allow_tags",
tags: unrestricted_tags.sort.join(", "), count: unrestricted_tags.size,
category: category.name tags: unrestricted_tags.sort.join(", "),
) category: category.name,
)
model.errors.add(:base, msg) model.errors.add(:base, msg)
return false return false
end end
@ -280,7 +303,8 @@ module DiscourseTagging
def self.filter_allowed_tags(guardian, opts = {}) def self.filter_allowed_tags(guardian, opts = {})
selected_tag_ids = opts[:selected_tags] ? Tag.where_name(opts[:selected_tags]).pluck(:id) : [] selected_tag_ids = opts[:selected_tags] ? Tag.where_name(opts[:selected_tags]).pluck(:id) : []
category = opts[:category] category = opts[:category]
category_has_restricted_tags = category ? (category.tags.count > 0 || category.tag_groups.count > 0) : false category_has_restricted_tags =
category ? (category.tags.count > 0 || category.tag_groups.count > 0) : false
# If guardian is nil, it means the caller doesn't want tags to be filtered # If guardian is nil, it means the caller doesn't want tags to be filtered
# based on guardian rules. Use the same rules as for staff users. # based on guardian rules. Use the same rules as for staff users.
@ -288,9 +312,7 @@ module DiscourseTagging
builder_params = {} builder_params = {}
unless selected_tag_ids.empty? builder_params[:selected_tag_ids] = selected_tag_ids unless selected_tag_ids.empty?
builder_params[:selected_tag_ids] = selected_tag_ids
end
sql = +"WITH #{TAG_GROUP_RESTRICTIONS_SQL}, #{CATEGORY_RESTRICTIONS_SQL}" sql = +"WITH #{TAG_GROUP_RESTRICTIONS_SQL}, #{CATEGORY_RESTRICTIONS_SQL}"
if (opts[:for_input] || opts[:for_topic]) && filter_for_non_staff if (opts[:for_input] || opts[:for_topic]) && filter_for_non_staff
@ -301,13 +323,14 @@ module DiscourseTagging
outer_join = category.nil? || category.allow_global_tags || !category_has_restricted_tags outer_join = category.nil? || category.allow_global_tags || !category_has_restricted_tags
distinct_clause = if opts[:order_popularity] distinct_clause =
"DISTINCT ON (topic_count, name)" if opts[:order_popularity]
elsif opts[:order_search_results] && opts[:term].present? "DISTINCT ON (topic_count, name)"
"DISTINCT ON (lower(name) = lower(:cleaned_term), topic_count, name)" elsif opts[:order_search_results] && opts[:term].present?
else "DISTINCT ON (lower(name) = lower(:cleaned_term), topic_count, name)"
"" else
end ""
end
sql << <<~SQL sql << <<~SQL
SELECT #{distinct_clause} t.id, t.name, t.topic_count, t.pm_topic_count, t.description, SELECT #{distinct_clause} t.id, t.name, t.topic_count, t.pm_topic_count, t.description,
@ -336,16 +359,20 @@ module DiscourseTagging
# parent tag requirements # parent tag requirements
if opts[:for_input] if opts[:for_input]
builder.where( builder.where(
builder_params[:selected_tag_ids] ? (
"tgm_id IS NULL OR parent_tag_id IS NULL OR parent_tag_id IN (:selected_tag_ids)" : if builder_params[:selected_tag_ids]
"tgm_id IS NULL OR parent_tag_id IS NULL" "tgm_id IS NULL OR parent_tag_id IS NULL OR parent_tag_id IN (:selected_tag_ids)"
else
"tgm_id IS NULL OR parent_tag_id IS NULL"
end
),
) )
end end
if category && category_has_restricted_tags if category && category_has_restricted_tags
builder.where( builder.where(
category.allow_global_tags ? "category_id = ? OR category_id IS NULL" : "category_id = ?", category.allow_global_tags ? "category_id = ? OR category_id IS NULL" : "category_id = ?",
category.id category.id,
) )
elsif category || opts[:for_input] || opts[:for_topic] elsif category || opts[:for_input] || opts[:for_topic]
# tags not restricted to any categories # tags not restricted to any categories
@ -354,7 +381,9 @@ module DiscourseTagging
if filter_for_non_staff && (opts[:for_input] || opts[:for_topic]) if filter_for_non_staff && (opts[:for_input] || opts[:for_topic])
# exclude staff-only tag groups # exclude staff-only tag groups
builder.where("tag_group_id IS NULL OR tag_group_id IN (SELECT tag_group_id FROM permitted_tag_groups)") builder.where(
"tag_group_id IS NULL OR tag_group_id IN (SELECT tag_group_id FROM permitted_tag_groups)",
)
end end
term = opts[:term] term = opts[:term]
@ -380,7 +409,8 @@ module DiscourseTagging
# - and no search term has been included # - and no search term has been included
required_tag_ids = nil required_tag_ids = nil
required_category_tag_group = nil required_category_tag_group = nil
if opts[:for_input] && category&.category_required_tag_groups.present? && (filter_for_non_staff || term.blank?) if opts[:for_input] && category&.category_required_tag_groups.present? &&
(filter_for_non_staff || term.blank?)
category.category_required_tag_groups.each do |crtg| category.category_required_tag_groups.each do |crtg|
group_tags = crtg.tag_group.tags.pluck(:id) group_tags = crtg.tag_group.tags.pluck(:id)
next if (group_tags & selected_tag_ids).size >= crtg.min_count next if (group_tags & selected_tag_ids).size >= crtg.min_count
@ -426,22 +456,18 @@ module DiscourseTagging
if !one_tag_per_group_ids.empty? if !one_tag_per_group_ids.empty?
builder.where( builder.where(
"tag_group_id IS NULL OR tag_group_id NOT IN (?) OR id IN (:selected_tag_ids)", "tag_group_id IS NULL OR tag_group_id NOT IN (?) OR id IN (:selected_tag_ids)",
one_tag_per_group_ids one_tag_per_group_ids,
) )
end end
end end
if opts[:exclude_synonyms] builder.where("target_tag_id IS NULL") if opts[:exclude_synonyms]
builder.where("target_tag_id IS NULL")
end
if opts[:exclude_has_synonyms] if opts[:exclude_has_synonyms]
builder.where("id NOT IN (SELECT target_tag_id FROM tags WHERE target_tag_id IS NOT NULL)") builder.where("id NOT IN (SELECT target_tag_id FROM tags WHERE target_tag_id IS NOT NULL)")
end end
if opts[:excluded_tag_names]&.any? builder.where("name NOT IN (?)", opts[:excluded_tag_names]) if opts[:excluded_tag_names]&.any?
builder.where("name NOT IN (?)", opts[:excluded_tag_names])
end
if opts[:limit] if opts[:limit]
if required_tag_ids && term.blank? if required_tag_ids && term.blank?
@ -465,7 +491,7 @@ module DiscourseTagging
if required_category_tag_group if required_category_tag_group
context[:required_tag_group] = { context[:required_tag_group] = {
name: required_category_tag_group.tag_group.name, name: required_category_tag_group.tag_group.name,
min_count: required_category_tag_group.min_count min_count: required_category_tag_group.min_count,
} }
end end
[result, context] [result, context]
@ -480,21 +506,15 @@ module DiscourseTagging
else else
# Visible tags either have no permissions or have allowable permissions # Visible tags either have no permissions or have allowable permissions
Tag Tag
.where.not( .where.not(id: TagGroupMembership.joins(tag_group: :tag_group_permissions).select(:tag_id))
id:
TagGroupMembership
.joins(tag_group: :tag_group_permissions)
.select(:tag_id)
)
.or( .or(
Tag Tag.where(
.where( id:
id: TagGroupPermission
TagGroupPermission .joins(tag_group: :tag_group_memberships)
.joins(tag_group: :tag_group_memberships) .where(group_id: permitted_group_ids_query(guardian))
.where(group_id: permitted_group_ids_query(guardian)) .select("tag_group_memberships.tag_id"),
.select('tag_group_memberships.tag_id'), ),
)
) )
end end
end end
@ -509,21 +529,18 @@ module DiscourseTagging
def self.permitted_group_ids_query(guardian = nil) def self.permitted_group_ids_query(guardian = nil)
if guardian&.authenticated? if guardian&.authenticated?
Group Group.from(
.from( Group.sanitize_sql(
Group.sanitize_sql( [
["(SELECT ? AS id UNION #{guardian.user.groups.select(:id).to_sql}) as groups", Group::AUTO_GROUPS[:everyone]] "(SELECT ? AS id UNION #{guardian.user.groups.select(:id).to_sql}) as groups",
) Group::AUTO_GROUPS[:everyone],
) ],
.select(:id) ),
).select(:id)
else else
Group Group.from(
.from( Group.sanitize_sql(["(SELECT ? AS id) AS groups", Group::AUTO_GROUPS[:everyone]]),
Group.sanitize_sql( ).select(:id)
["(SELECT ? AS id) AS groups", Group::AUTO_GROUPS[:everyone]]
)
)
.select(:id)
end end
end end
@ -535,9 +552,11 @@ module DiscourseTagging
def self.readonly_tag_names(guardian = nil) def self.readonly_tag_names(guardian = nil)
return [] if guardian&.is_staff? return [] if guardian&.is_staff?
query = Tag.joins(tag_groups: :tag_group_permissions) query =
.where('tag_group_permissions.permission_type = ?', Tag.joins(tag_groups: :tag_group_permissions).where(
TagGroupPermission.permission_types[:readonly]) "tag_group_permissions.permission_type = ?",
TagGroupPermission.permission_types[:readonly],
)
query.pluck(:name) query.pluck(:name)
end end
@ -545,14 +564,12 @@ module DiscourseTagging
# explicit permissions to use these tags # explicit permissions to use these tags
def self.permitted_tag_names(guardian = nil) def self.permitted_tag_names(guardian = nil)
query = query =
Tag Tag.joins(tag_groups: :tag_group_permissions).where(
.joins(tag_groups: :tag_group_permissions) tag_group_permissions: {
.where( group_id: permitted_group_ids(guardian),
tag_group_permissions: { permission_type: TagGroupPermission.permission_types[:full],
group_id: permitted_group_ids(guardian), },
permission_type: TagGroupPermission.permission_types[:full], )
},
)
query.pluck(:name).uniq query.pluck(:name).uniq
end end
@ -586,15 +603,14 @@ module DiscourseTagging
tag = tag.dup tag = tag.dup
tag.downcase! if SiteSetting.force_lowercase_tags tag.downcase! if SiteSetting.force_lowercase_tags
tag.strip! tag.strip!
tag.gsub!(/[[:space:]]+/, '-') tag.gsub!(/[[:space:]]+/, "-")
tag.gsub!(/[^[:word:][:punct:]]+/, '') tag.gsub!(/[^[:word:][:punct:]]+/, "")
tag.squeeze!('-') tag.squeeze!("-")
tag.gsub!(TAGS_FILTER_REGEXP, '') tag.gsub!(TAGS_FILTER_REGEXP, "")
tag[0...SiteSetting.max_tag_length] tag[0...SiteSetting.max_tag_length]
end end
def self.tags_for_saving(tags_arg, guardian, opts = {}) def self.tags_for_saving(tags_arg, guardian, opts = {})
return [] unless guardian.can_tag_topics? && tags_arg.present? return [] unless guardian.can_tag_topics? && tags_arg.present?
tag_names = Tag.where_name(tags_arg).pluck(:name) tag_names = Tag.where_name(tags_arg).pluck(:name)
@ -609,21 +625,23 @@ module DiscourseTagging
end end
def self.add_or_create_tags_by_name(taggable, tag_names_arg, opts = {}) def self.add_or_create_tags_by_name(taggable, tag_names_arg, opts = {})
tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user), opts) || [] tag_names =
DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user), opts) ||
[]
if taggable.tags.pluck(:name).sort != tag_names.sort if taggable.tags.pluck(:name).sort != tag_names.sort
taggable.tags = Tag.where_name(tag_names).all taggable.tags = Tag.where_name(tag_names).all
new_tag_names = taggable.tags.size < tag_names.size ? tag_names - taggable.tags.map(&:name) : [] new_tag_names =
taggable.tags.size < tag_names.size ? tag_names - taggable.tags.map(&:name) : []
taggable.tags << Tag.where(target_tag_id: taggable.tags.map(&:id)).all taggable.tags << Tag.where(target_tag_id: taggable.tags.map(&:id)).all
new_tag_names.each do |name| new_tag_names.each { |name| taggable.tags << Tag.create(name: name) }
taggable.tags << Tag.create(name: name)
end
end end
end end
# Returns true if all were added successfully, or an Array of the # Returns true if all were added successfully, or an Array of the
# tags that failed to be added, with errors on each Tag. # tags that failed to be added, with errors on each Tag.
def self.add_or_create_synonyms_by_name(target_tag, synonym_names) def self.add_or_create_synonyms_by_name(target_tag, synonym_names)
tag_names = DiscourseTagging.tags_for_saving(synonym_names, Guardian.new(Discourse.system_user)) || [] tag_names =
DiscourseTagging.tags_for_saving(synonym_names, Guardian.new(Discourse.system_user)) || []
tag_names -= [target_tag.name] tag_names -= [target_tag.name]
existing = Tag.where_name(tag_names).all existing = Tag.where_name(tag_names).all
target_tag.synonyms << existing target_tag.synonyms << existing
@ -642,6 +660,6 @@ module DiscourseTagging
def self.muted_tags(user) def self.muted_tags(user)
return [] unless user return [] unless user
TagUser.lookup(user, :muted).joins(:tag).pluck('tags.name') TagUser.lookup(user, :muted).joins(:tag).pluck("tags.name")
end end
end end

View File

@ -1,13 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
module DiscourseUpdates module DiscourseUpdates
class << self class << self
def check_version def check_version
attrs = { attrs = {
installed_version: Discourse::VERSION::STRING, installed_version: Discourse::VERSION::STRING,
installed_sha: (Discourse.git_version == 'unknown' ? nil : Discourse.git_version), installed_sha: (Discourse.git_version == "unknown" ? nil : Discourse.git_version),
installed_describe: Discourse.full_version, installed_describe: Discourse.full_version,
git_branch: Discourse.git_branch, git_branch: Discourse.git_branch,
updated_at: updated_at, updated_at: updated_at,
@ -17,7 +15,7 @@ module DiscourseUpdates
attrs.merge!( attrs.merge!(
latest_version: latest_version, latest_version: latest_version,
critical_updates: critical_updates_available?, critical_updates: critical_updates_available?,
missing_versions_count: missing_versions_count missing_versions_count: missing_versions_count,
) )
end end
@ -25,19 +23,24 @@ module DiscourseUpdates
# replace -commit_count with +commit_count # replace -commit_count with +commit_count
if version_info.installed_describe =~ /-(\d+)-/ if version_info.installed_describe =~ /-(\d+)-/
version_info.installed_describe = version_info.installed_describe.gsub(/-(\d+)-.*/, " +#{$1}") version_info.installed_describe =
version_info.installed_describe.gsub(/-(\d+)-.*/, " +#{$1}")
end end
if SiteSetting.version_checks? if SiteSetting.version_checks?
is_stale_data = is_stale_data =
(version_info.missing_versions_count == 0 && version_info.latest_version != version_info.installed_version) || (
(version_info.missing_versions_count != 0 && version_info.latest_version == version_info.installed_version) version_info.missing_versions_count == 0 &&
version_info.latest_version != version_info.installed_version
) ||
(
version_info.missing_versions_count != 0 &&
version_info.latest_version == version_info.installed_version
)
# Handle cases when version check data is old so we report something that makes sense # Handle cases when version check data is old so we report something that makes sense
if version_info.updated_at.nil? || # never performed a version check if version_info.updated_at.nil? || last_installed_version != Discourse::VERSION::STRING || # never performed a version check # upgraded since the last version check
last_installed_version != Discourse::VERSION::STRING || # upgraded since the last version check is_stale_data
is_stale_data
Jobs.enqueue(:version_check, all_sites: true) Jobs.enqueue(:version_check, all_sites: true)
version_info.version_check_pending = true version_info.version_check_pending = true
@ -48,9 +51,8 @@ module DiscourseUpdates
end end
version_info.stale_data = version_info.stale_data =
version_info.version_check_pending || version_info.version_check_pending || (updated_at && updated_at < 48.hours.ago) ||
(updated_at && updated_at < 48.hours.ago) || is_stale_data
is_stale_data
end end
version_info version_info
@ -82,7 +84,7 @@ module DiscourseUpdates
end end
def critical_updates_available? def critical_updates_available?
(Discourse.redis.get(critical_updates_available_key) || false) == 'true' (Discourse.redis.get(critical_updates_available_key) || false) == "true"
end end
def critical_updates_available=(arg) def critical_updates_available=(arg)
@ -110,7 +112,7 @@ module DiscourseUpdates
# store the list in redis # store the list in redis
version_keys = [] version_keys = []
versions[0, 5].each do |v| versions[0, 5].each do |v|
key = "#{missing_versions_key_prefix}:#{v['version']}" key = "#{missing_versions_key_prefix}:#{v["version"]}"
Discourse.redis.mapped_hmset key, v Discourse.redis.mapped_hmset key, v
version_keys << key version_keys << key
end end
@ -140,11 +142,21 @@ module DiscourseUpdates
end end
def new_features def new_features
entries = JSON.parse(Discourse.redis.get(new_features_key)) rescue nil entries =
begin
JSON.parse(Discourse.redis.get(new_features_key))
rescue StandardError
nil
end
return nil if entries.nil? return nil if entries.nil?
entries.select! do |item| entries.select! do |item|
item["discourse_version"].nil? || Discourse.has_needed_version?(current_version, item["discourse_version"]) rescue nil begin
item["discourse_version"].nil? ||
Discourse.has_needed_version?(current_version, item["discourse_version"])
rescue StandardError
nil
end
end end
entries.sort_by { |item| Time.zone.parse(item["created_at"]).to_i }.reverse entries.sort_by { |item| Time.zone.parse(item["created_at"]).to_i }.reverse
@ -170,7 +182,12 @@ module DiscourseUpdates
end end
def mark_new_features_as_seen(user_id) def mark_new_features_as_seen(user_id)
entries = JSON.parse(Discourse.redis.get(new_features_key)) rescue nil entries =
begin
JSON.parse(Discourse.redis.get(new_features_key))
rescue StandardError
nil
end
return nil if entries.nil? return nil if entries.nil?
last_seen = entries.max_by { |x| x["created_at"] } last_seen = entries.max_by { |x| x["created_at"] }
Discourse.redis.set(new_features_last_seen_key(user_id), last_seen["created_at"]) Discourse.redis.set(new_features_last_seen_key(user_id), last_seen["created_at"])
@ -204,39 +221,39 @@ module DiscourseUpdates
private private
def last_installed_version_key def last_installed_version_key
'last_installed_version' "last_installed_version"
end end
def latest_version_key def latest_version_key
'discourse_latest_version' "discourse_latest_version"
end end
def critical_updates_available_key def critical_updates_available_key
'critical_updates_available' "critical_updates_available"
end end
def missing_versions_count_key def missing_versions_count_key
'missing_versions_count' "missing_versions_count"
end end
def updated_at_key def updated_at_key
'last_version_check_at' "last_version_check_at"
end end
def missing_versions_list_key def missing_versions_list_key
'missing_versions' "missing_versions"
end end
def missing_versions_key_prefix def missing_versions_key_prefix
'missing_version' "missing_version"
end end
def new_features_endpoint def new_features_endpoint
'https://meta.discourse.org/new-features.json' "https://meta.discourse.org/new-features.json"
end end
def new_features_key def new_features_key
'new_features' "new_features"
end end
def new_features_last_seen_key(user_id) def new_features_last_seen_key(user_id)

View File

@ -18,13 +18,13 @@ class DiskSpace
end end
def self.free(path) def self.free(path)
output = Discourse::Utils.execute_command('df', '-Pk', path) output = Discourse::Utils.execute_command("df", "-Pk", path)
size_line = output.split("\n")[1] size_line = output.split("\n")[1]
size_line.split(/\s+/)[3].to_i * 1024 size_line.split(/\s+/)[3].to_i * 1024
end end
def self.percent_free(path) def self.percent_free(path)
output = Discourse::Utils.execute_command('df', '-P', path) output = Discourse::Utils.execute_command("df", "-P", path)
size_line = output.split("\n")[1] size_line = output.split("\n")[1]
size_line.split(/\s+/)[4].to_i size_line.split(/\s+/)[4].to_i
end end

View File

@ -1,23 +1,16 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'message_bus/distributed_cache' require "message_bus/distributed_cache"
class DistributedCache < MessageBus::DistributedCache class DistributedCache < MessageBus::DistributedCache
def initialize(key, manager: nil, namespace: true) def initialize(key, manager: nil, namespace: true)
super( super(key, manager: manager, namespace: namespace, app_version: Discourse.git_version)
key,
manager: manager,
namespace: namespace,
app_version: Discourse.git_version
)
end end
# Defer setting of the key in the cache for performance critical path to avoid # Defer setting of the key in the cache for performance critical path to avoid
# waiting on MessageBus to publish the message which involves writing to Redis. # waiting on MessageBus to publish the message which involves writing to Redis.
def defer_set(k, v) def defer_set(k, v)
Scheduler::Defer.later("#{@key}_set") do Scheduler::Defer.later("#{@key}_set") { self[k] = v }
self[k] = v
end
end end
def defer_get_set(k, &block) def defer_get_set(k, &block)

View File

@ -31,11 +31,7 @@ class DistributedMutex
LUA LUA
def self.synchronize(key, redis: nil, validity: DEFAULT_VALIDITY, &blk) def self.synchronize(key, redis: nil, validity: DEFAULT_VALIDITY, &blk)
self.new( self.new(key, redis: redis, validity: validity).synchronize(&blk)
key,
redis: redis,
validity: validity
).synchronize(&blk)
end end
def initialize(key, redis: nil, validity: DEFAULT_VALIDITY) def initialize(key, redis: nil, validity: DEFAULT_VALIDITY)
@ -58,7 +54,9 @@ class DistributedMutex
ensure ensure
current_time = redis.time[0] current_time = redis.time[0]
if current_time > expire_time if current_time > expire_time
warn("held for too long, expected max: #{@validity} secs, took an extra #{current_time - expire_time} secs") warn(
"held for too long, expected max: #{@validity} secs, took an extra #{current_time - expire_time} secs",
)
end end
unlocked = UNLOCK_SCRIPT.eval(redis, [prefixed_key], [expire_time.to_s]) unlocked = UNLOCK_SCRIPT.eval(redis, [prefixed_key], [expire_time.to_s])

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rate_limiter' require "rate_limiter"
class EditRateLimiter < RateLimiter class EditRateLimiter < RateLimiter
def initialize(user) def initialize(user)
limit = SiteSetting.max_edits_per_day limit = SiteSetting.max_edits_per_day

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'mail' require "mail"
module Email module Email
# See https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xhtml#smtp-enhanced-status-codes-1 # See https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xhtml#smtp-enhanced-status-codes-1
@ -21,19 +21,19 @@ module Email
def self.obfuscate(email) def self.obfuscate(email)
return email if !Email.is_valid?(email) return email if !Email.is_valid?(email)
first, _, last = email.rpartition('@') first, _, last = email.rpartition("@")
# Obfuscate each last part, except tld # Obfuscate each last part, except tld
last = last.split('.') last = last.split(".")
tld = last.pop tld = last.pop
last.map! { |part| obfuscate_part(part) } last.map! { |part| obfuscate_part(part) }
last << tld last << tld
"#{obfuscate_part(first)}@#{last.join('.')}" "#{obfuscate_part(first)}@#{last.join(".")}"
end end
def self.cleanup_alias(name) def self.cleanup_alias(name)
name ? name.gsub(/[:<>,"]/, '') : name name ? name.gsub(/[:<>,"]/, "") : name
end end
def self.extract_parts(raw) def self.extract_parts(raw)

View File

@ -2,12 +2,7 @@
module Email module Email
class AuthenticationResults class AuthenticationResults
VERDICT = Enum.new( VERDICT = Enum.new(:gray, :pass, :fail, start: 0)
:gray,
:pass,
:fail,
start: 0,
)
def initialize(headers) def initialize(headers)
@authserv_id = SiteSetting.email_in_authserv_id @authserv_id = SiteSetting.email_in_authserv_id
@ -16,11 +11,10 @@ module Email
end end
def results def results
@results ||= Array(@headers).map do |header| @results ||=
parse_header(header.to_s) Array(@headers)
end.filter do |result| .map { |header| parse_header(header.to_s) }
@authserv_id.blank? || @authserv_id == result[:authserv_id] .filter { |result| @authserv_id.blank? || @authserv_id == result[:authserv_id] }
end
end end
def action def action
@ -55,7 +49,8 @@ module Email
end end
end end
end end
verdict = VERDICT[:gray] if SiteSetting.email_in_authserv_id.blank? && verdict == VERDICT[:pass] verdict = VERDICT[:gray] if SiteSetting.email_in_authserv_id.blank? &&
verdict == VERDICT[:pass]
verdict verdict
end end
@ -67,10 +62,11 @@ module Email
authres_version = /\d+#{cfws}?/ authres_version = /\d+#{cfws}?/
no_result = /#{cfws}?;#{cfws}?none/ no_result = /#{cfws}?;#{cfws}?none/
keyword = /([a-zA-Z0-9-]*[a-zA-Z0-9])/ keyword = /([a-zA-Z0-9-]*[a-zA-Z0-9])/
authres_payload = /\A#{cfws}?#{authserv_id}(?:#{cfws}#{authres_version})?(?:#{no_result}|([\S\s]*))/ authres_payload =
/\A#{cfws}?#{authserv_id}(?:#{cfws}#{authres_version})?(?:#{no_result}|([\S\s]*))/
method_version = authres_version method_version = authres_version
method = /#{keyword}\s*(?:#{cfws}?\/#{cfws}?#{method_version})?/ method = %r{#{keyword}\s*(?:#{cfws}?/#{cfws}?#{method_version})?}
result = keyword result = keyword
methodspec = /#{cfws}?#{method}#{cfws}?=#{cfws}?#{result}/ methodspec = /#{cfws}?#{method}#{cfws}?=#{cfws}?#{result}/
reasonspec = /reason#{cfws}?=#{cfws}?#{value}/ reasonspec = /reason#{cfws}?=#{cfws}?#{value}/
@ -87,27 +83,21 @@ module Email
if resinfo_val if resinfo_val
resinfo_scan = resinfo_val.scan(resinfo) resinfo_scan = resinfo_val.scan(resinfo)
parsed_resinfo = resinfo_scan.map do |x| parsed_resinfo =
{ resinfo_scan.map do |x|
method: x[2], {
result: x[8], method: x[2],
reason: x[12] || x[13], result: x[8],
props: x[-1].scan(propspec).map do |y| reason: x[12] || x[13],
{ props:
ptype: y[0], x[-1]
property: y[4], .scan(propspec)
pvalue: y[8] || y[9] .map { |y| { ptype: y[0], property: y[4], pvalue: y[8] || y[9] } },
} }
end end
}
end
end end
{ { authserv_id: parsed_authserv_id, resinfo: parsed_resinfo }
authserv_id: parsed_authserv_id,
resinfo: parsed_resinfo
}
end end
end end
end end

View File

@ -5,11 +5,11 @@ module Email
def build_email(*builder_args) def build_email(*builder_args)
builder = Email::MessageBuilder.new(*builder_args) builder = Email::MessageBuilder.new(*builder_args)
headers(builder.header_args) if builder.header_args.present? headers(builder.header_args) if builder.header_args.present?
mail(builder.build_args).tap { |message| mail(builder.build_args).tap do |message|
if message && h = builder.html_part if message && h = builder.html_part
message.html_part = h message.html_part = h
end end
} end
end end
end end
end end

Some files were not shown because too many files have changed in this diff Show More