mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
Initial release of Discourse
This commit is contained in:
0
app/models/.gitkeep
Normal file
0
app/models/.gitkeep
Normal file
86
app/models/category.rb
Normal file
86
app/models/category.rb
Normal file
@@ -0,0 +1,86 @@
|
||||
class Category < ActiveRecord::Base
|
||||
|
||||
belongs_to :topic
|
||||
belongs_to :user
|
||||
|
||||
has_many :topics
|
||||
has_many :category_featured_topics
|
||||
has_many :featured_topics, through: :category_featured_topics, source: :topic
|
||||
|
||||
has_many :category_featured_users
|
||||
has_many :featured_users, through: :category_featured_users, source: :user
|
||||
|
||||
validates_presence_of :name
|
||||
validates_uniqueness_of :name
|
||||
validate :uncategorized_validator
|
||||
|
||||
after_save :invalidate_site_cache
|
||||
after_destroy :invalidate_site_cache
|
||||
|
||||
|
||||
def uncategorized_validator
|
||||
return errors.add(:name, I18n.t(:is_reserved)) if name == SiteSetting.uncategorized_name
|
||||
return errors.add(:slug, I18n.t(:is_reserved)) if slug == SiteSetting.uncategorized_name
|
||||
end
|
||||
|
||||
def self.popular
|
||||
order('topic_count desc')
|
||||
end
|
||||
|
||||
def self.update_stats
|
||||
exec_sql "UPDATE categories
|
||||
SET topics_week = (SELECT COUNT(*)
|
||||
FROM topics as ft
|
||||
WHERE ft.category_id = categories.id
|
||||
AND ft.created_at > (CURRENT_TIMESTAMP - INTERVAL '1 WEEK')
|
||||
AND ft.visible),
|
||||
topics_month = (SELECT COUNT(*)
|
||||
FROM topics as ft
|
||||
WHERE ft.category_id = categories.id
|
||||
AND ft.created_at > (CURRENT_TIMESTAMP - INTERVAL '1 MONTH')
|
||||
AND ft.visible),
|
||||
topics_year = (SELECT COUNT(*)
|
||||
FROM topics as ft
|
||||
WHERE ft.category_id = categories.id
|
||||
AND ft.created_at > (CURRENT_TIMESTAMP - INTERVAL '1 YEAR')
|
||||
AND ft.visible)"
|
||||
end
|
||||
|
||||
# Use the first paragraph of the topic's first post as the excerpt
|
||||
def excerpt
|
||||
if topic.present?
|
||||
first_post = topic.posts.order(:post_number).first
|
||||
body = first_post.cooked
|
||||
matches = body.scan(/\<p\>(.*)\<\/p\>/)
|
||||
if matches and matches[0] and matches[0][0]
|
||||
return matches[0][0]
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def topic_url
|
||||
topic.try(:relative_url)
|
||||
end
|
||||
|
||||
before_save do
|
||||
self.slug = Slug.for(self.name)
|
||||
end
|
||||
|
||||
after_create do
|
||||
topic = Topic.create!(title: I18n.t("category.topic_prefix", category: name), user: user, visible: false)
|
||||
topic.posts.create!(raw: SiteSetting.category_post_template, user: user)
|
||||
update_column(:topic_id, topic.id)
|
||||
topic.update_column(:category_id, self.id)
|
||||
end
|
||||
|
||||
# We cache the categories in the site json, so we need to invalidate it when they change
|
||||
def invalidate_site_cache
|
||||
Site.invalidate_cache
|
||||
end
|
||||
|
||||
before_destroy do
|
||||
topic.destroy
|
||||
end
|
||||
|
||||
end
|
||||
40
app/models/category_featured_topic.rb
Normal file
40
app/models/category_featured_topic.rb
Normal file
@@ -0,0 +1,40 @@
|
||||
class CategoryFeaturedTopic < ActiveRecord::Base
|
||||
belongs_to :category
|
||||
belongs_to :topic
|
||||
|
||||
# Populates the category featured topics
|
||||
def self.feature_topics
|
||||
|
||||
transaction do
|
||||
Category.all.each do |c|
|
||||
feature_topics_for(c)
|
||||
CategoryFeaturedUser.feature_users_in(c)
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def self.feature_topics_for(c)
|
||||
return unless c.present?
|
||||
|
||||
CategoryFeaturedTopic.transaction do
|
||||
exec_sql "DELETE FROM category_featured_topics WHERE category_id = :category_id", category_id: c.id
|
||||
exec_sql "INSERT INTO category_featured_topics (category_id, topic_id, created_at, updated_at)
|
||||
SELECT :category_id,
|
||||
ft.id,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM topics AS ft
|
||||
WHERE ft.category_id = :category_id
|
||||
AND ft.visible
|
||||
AND ft.deleted_at IS NULL
|
||||
AND ft.archetype <> '#{Archetype.private_message}'
|
||||
ORDER BY ft.bumped_at DESC
|
||||
LIMIT :featured_limit",
|
||||
category_id: c.id,
|
||||
featured_limit: SiteSetting.category_featured_topics
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
31
app/models/category_featured_user.rb
Normal file
31
app/models/category_featured_user.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class CategoryFeaturedUser < ActiveRecord::Base
|
||||
belongs_to :category
|
||||
belongs_to :user
|
||||
|
||||
def self.max_featured_users
|
||||
5
|
||||
end
|
||||
|
||||
def self.feature_users_in(category)
|
||||
# Figure out major posters in the category
|
||||
user_counts = exec_sql "
|
||||
SELECT p.user_id,
|
||||
COUNT(*) AS category_posts
|
||||
FROM posts AS p
|
||||
INNER JOIN topics AS ft ON ft.id = p.topic_id
|
||||
WHERE ft.category_id = :category_id
|
||||
GROUP BY p.user_id
|
||||
ORDER BY category_posts DESC
|
||||
LIMIT :max_featured_users
|
||||
", category_id: category.id, max_featured_users: max_featured_users
|
||||
|
||||
transaction do
|
||||
CategoryFeaturedUser.delete_all ['category_id = ?', category.id]
|
||||
user_counts.each do |uc|
|
||||
create(category_id: category.id, user_id: uc['user_id'])
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
69
app/models/category_list.rb
Normal file
69
app/models/category_list.rb
Normal file
@@ -0,0 +1,69 @@
|
||||
class CategoryList
|
||||
include ActiveModel::Serialization
|
||||
|
||||
attr_accessor :categories, :topic_users, :uncategorized
|
||||
|
||||
def initialize(current_user)
|
||||
@categories = Category
|
||||
.includes(:featured_topics => [:category])
|
||||
.includes(:featured_users)
|
||||
.order('topics_week desc, topics_month desc, topics_year desc')
|
||||
.to_a
|
||||
|
||||
# Support for uncategorized topics
|
||||
uncategorized_topics = Topic
|
||||
.listable_topics
|
||||
.where(category_id: nil)
|
||||
.topic_list_order
|
||||
.limit(SiteSetting.category_featured_topics)
|
||||
if uncategorized_topics.present?
|
||||
|
||||
totals = Topic.exec_sql("SELECT SUM(CASE WHEN created_at >= (CURRENT_TIMESTAMP - INTERVAL '1 WEEK') THEN 1 ELSE 0 END) as topics_week,
|
||||
SUM(CASE WHEN created_at >= (CURRENT_TIMESTAMP - INTERVAL '1 MONTH') THEN 1 ELSE 0 END) as topics_month,
|
||||
SUM(CASE WHEN created_at >= (CURRENT_TIMESTAMP - INTERVAL '1 YEAR') THEN 1 ELSE 0 END) as topics_year,
|
||||
COUNT(*) AS topic_count
|
||||
FROM topics
|
||||
WHERE topics.visible
|
||||
AND topics.deleted_at IS NULL
|
||||
AND topics.category_id IS NULL
|
||||
AND topics.archetype <> '#{Archetype.private_message}'").first
|
||||
|
||||
|
||||
uncategorized = Category.new({name: SiteSetting.uncategorized_name,
|
||||
slug: SiteSetting.uncategorized_name,
|
||||
featured_topics: uncategorized_topics}.merge(totals))
|
||||
|
||||
# Find the appropriate place to insert it:
|
||||
insert_at = nil
|
||||
@categories.each_with_index do |c, idx|
|
||||
if totals['topics_week'].to_i > (c.topics_week || 0)
|
||||
insert_at = idx
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
@categories.insert(insert_at || @categories.size, uncategorized)
|
||||
end
|
||||
|
||||
# Remove categories with no featured topics
|
||||
@categories.delete_if {|c| c.featured_topics.blank? }
|
||||
|
||||
# Get forum topic user records if appropriate
|
||||
if current_user.present?
|
||||
topics = []
|
||||
@categories.each {|c| topics << c.featured_topics}
|
||||
topics << @uncategorized
|
||||
|
||||
topics.flatten! if topics.present?
|
||||
topics.compact! if topics.present?
|
||||
|
||||
|
||||
topic_lookup = TopicUser.lookup_for(current_user, topics)
|
||||
|
||||
# Attach some data for serialization to each topic
|
||||
topics.each {|ft| ft.user_data = topic_lookup[ft.id]}
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
45
app/models/draft.rb
Normal file
45
app/models/draft.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
class Draft < ActiveRecord::Base
|
||||
|
||||
NEW_TOPIC = 'new_topic'
|
||||
NEW_PRIVATE_MESSAGE = 'new_private_message'
|
||||
EXISTING_TOPIC = 'topic_'
|
||||
|
||||
def self.set(user, key, sequence, data)
|
||||
d = find_draft(user,key)
|
||||
if d
|
||||
return if d.sequence > sequence
|
||||
d.data = data
|
||||
d.sequence = sequence
|
||||
else
|
||||
d = Draft.new(user_id: user.id, draft_key: key, data: data, sequence: sequence)
|
||||
end
|
||||
d.save!
|
||||
end
|
||||
|
||||
def self.get(user, key, sequence)
|
||||
d = find_draft(user,key)
|
||||
if d && d.sequence == sequence
|
||||
d.data
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def self.clear(user, key, sequence)
|
||||
d = find_draft(user,key)
|
||||
if d && d.sequence <= sequence
|
||||
d.destroy
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def self.find_draft(user,key)
|
||||
user_id = user
|
||||
user_id = user.id if User === user
|
||||
Draft.where(user_id: user_id, draft_key: key).first
|
||||
end
|
||||
|
||||
end
|
||||
29
app/models/draft_sequence.rb
Normal file
29
app/models/draft_sequence.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
class DraftSequence < ActiveRecord::Base
|
||||
def self.next!(user,key)
|
||||
user_id = user
|
||||
user_id = user.id unless user.class == Fixnum
|
||||
h = {user_id: user_id, draft_key: key}
|
||||
c = DraftSequence.where(h).first
|
||||
c ||= DraftSequence.new(h)
|
||||
c.sequence ||= 0
|
||||
c.sequence += 1
|
||||
c.save
|
||||
c.sequence
|
||||
end
|
||||
|
||||
def self.current(user, key)
|
||||
return nil unless user
|
||||
|
||||
user_id = user
|
||||
user_id = user.id unless user.class == Fixnum
|
||||
|
||||
# perf critical path
|
||||
r = exec_sql('select sequence from draft_sequences where user_id = ? and draft_key = ?', user_id, key).values
|
||||
|
||||
if r.length == 0
|
||||
0
|
||||
else
|
||||
r[0][0].to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
12
app/models/email_log.rb
Normal file
12
app/models/email_log.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class EmailLog < ActiveRecord::Base
|
||||
|
||||
belongs_to :user
|
||||
validates_presence_of :email_type
|
||||
validates_presence_of :to_address
|
||||
|
||||
after_create do
|
||||
# Update last_emailed_at if the user_id is present
|
||||
User.update_all("last_emailed_at = CURRENT_TIMESTAMP", ["id = ?", user_id]) if user_id.present?
|
||||
end
|
||||
|
||||
end
|
||||
55
app/models/email_token.rb
Normal file
55
app/models/email_token.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
class EmailToken < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
|
||||
validates_presence_of :token
|
||||
validates_presence_of :user_id
|
||||
validates_presence_of :email
|
||||
|
||||
before_validation(:on => :create) do
|
||||
self.token = EmailToken.generate_token
|
||||
end
|
||||
|
||||
after_create do
|
||||
# Expire the previous tokens
|
||||
EmailToken.update_all 'expired = true', ['user_id = ? and id != ?', self.user_id, self.id]
|
||||
end
|
||||
|
||||
def self.token_length
|
||||
16
|
||||
end
|
||||
|
||||
def self.valid_after
|
||||
1.week.ago
|
||||
end
|
||||
|
||||
def self.generate_token
|
||||
SecureRandom.hex(EmailToken.token_length)
|
||||
end
|
||||
|
||||
def self.confirm(token)
|
||||
return unless token.present?
|
||||
return unless token.length/2 == EmailToken.token_length
|
||||
|
||||
email_token = EmailToken.where("token = ? AND expired = FALSE and created_at >= ?", token, EmailToken.valid_after).includes(:user).first
|
||||
return if email_token.blank?
|
||||
|
||||
user = email_token.user
|
||||
User.transaction do
|
||||
row_count = EmailToken.update_all 'confirmed = true', ['id = ? AND confirmed = false', email_token.id]
|
||||
if row_count == 1
|
||||
|
||||
# If we are activating the user, send the welcome message
|
||||
user.send_welcome_message = !user.active?
|
||||
|
||||
user.active = true
|
||||
user.email = email_token.email
|
||||
user.save!
|
||||
end
|
||||
end
|
||||
user
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
# If the user's email is already taken, just return nil (failure)
|
||||
nil
|
||||
end
|
||||
|
||||
end
|
||||
111
app/models/error_log.rb
Normal file
111
app/models/error_log.rb
Normal file
@@ -0,0 +1,111 @@
|
||||
# TODO:
|
||||
# a mechanism to iterate through errors in reverse
|
||||
# async logging should queue, if dupe stack traces are found in batch error should be merged into prev one
|
||||
|
||||
|
||||
class ErrorLog
|
||||
|
||||
@lock = Mutex.new
|
||||
|
||||
def self.filename
|
||||
"#{Rails.root}/log/#{Rails.env}_errors.log"
|
||||
end
|
||||
|
||||
def self.clear!(guid)
|
||||
raise "not implemented"
|
||||
end
|
||||
|
||||
def self.clear_all!()
|
||||
File.delete(ErrorLog.filename) if File.exists?(ErrorLog.filename)
|
||||
end
|
||||
|
||||
def self.report_async!(exception, controller, request, user)
|
||||
Thread.new do
|
||||
self.report!(exception, controller, request, user)
|
||||
end
|
||||
end
|
||||
|
||||
def self.report!(exception, controller, request, user)
|
||||
add_row!(
|
||||
:date => DateTime.now,
|
||||
:guid => SecureRandom.uuid,
|
||||
:user_id => user && user.id,
|
||||
:request => filter_sensitive_post_data_parameters(controller, request.parameters).inspect,
|
||||
:action => controller.action_name,
|
||||
:controller => controller.controller_name,
|
||||
:backtrace => sanitize_backtrace(exception.backtrace).join("\n"),
|
||||
:message => exception.message,
|
||||
:url => "#{request.protocol}#{request.env["HTTP_X_FORWARDED_HOST"] || request.env["HTTP_HOST"]}#{request.fullpath}",
|
||||
:exception_class => exception.class.to_s
|
||||
)
|
||||
end
|
||||
|
||||
def self.add_row!(hash)
|
||||
data = hash.to_xml(skip_instruct: true)
|
||||
# use background thread to write the log cause it may block if it gets backed up
|
||||
@lock.synchronize do
|
||||
File.open(self.filename, "a") do |f|
|
||||
f.flock(File::LOCK_EX)
|
||||
f.write(data)
|
||||
f.close
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def self.each(&blk)
|
||||
skip(0,&blk)
|
||||
end
|
||||
|
||||
def self.skip(skip=0)
|
||||
data = nil
|
||||
pos = 0
|
||||
return [] unless File.exists?(self.filename)
|
||||
|
||||
loop do
|
||||
lines = ""
|
||||
File.open(self.filename, "r") do |f|
|
||||
f.flock(File::LOCK_SH)
|
||||
f.pos = pos
|
||||
while !f.eof?
|
||||
line = f.readline
|
||||
lines << line
|
||||
break if line.starts_with? "</hash>"
|
||||
end
|
||||
pos = f.pos
|
||||
end
|
||||
if lines != "" && skip == 0
|
||||
h = {}
|
||||
e = Nokogiri.parse(lines).children[0]
|
||||
e.children.each do |inner|
|
||||
h[inner.name] = inner.text
|
||||
end
|
||||
yield h
|
||||
end
|
||||
skip-=1 if skip > 0
|
||||
break if lines == ""
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.sanitize_backtrace(trace)
|
||||
re = Regexp.new(/^#{Regexp.escape(Rails.root.to_s)}/)
|
||||
trace.map { |line| Pathname.new(line.gsub(re, "[RAILS_ROOT]")).cleanpath.to_s }
|
||||
end
|
||||
|
||||
def self.exclude_raw_post_parameters?(controller)
|
||||
controller && controller.respond_to?(:filter_parameters)
|
||||
end
|
||||
|
||||
def self.filter_sensitive_post_data_parameters(controller, parameters)
|
||||
exclude_raw_post_parameters?(controller) ? controller.__send__(:filter_parameters, parameters) : parameters
|
||||
end
|
||||
|
||||
def self.filter_sensitive_post_data_from_env(env_key, env_value, controller)
|
||||
return env_value unless exclude_raw_post_parameters?
|
||||
return PARAM_FILTER_REPLACEMENT if (env_key =~ /RAW_POST_DATA/i)
|
||||
return controller.__send__(:filter_parameters, {env_key => env_value}).values[0]
|
||||
end
|
||||
|
||||
end
|
||||
4
app/models/facebook_user_info.rb
Normal file
4
app/models/facebook_user_info.rb
Normal file
@@ -0,0 +1,4 @@
|
||||
class FacebookUserInfo < ActiveRecord::Base
|
||||
attr_accessible :email, :facebook_user_id, :first_name, :gender, :last_name, :name, :user_id, :username, :link
|
||||
belongs_to :user
|
||||
end
|
||||
47
app/models/incoming_link.rb
Normal file
47
app/models/incoming_link.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class IncomingLink < ActiveRecord::Base
|
||||
belongs_to :topic
|
||||
|
||||
validates :domain, :length => { :in => 1..100 }
|
||||
validates :referer, :length => { :in => 3..1000 }
|
||||
validates_presence_of :url
|
||||
|
||||
# Extract the domain
|
||||
before_validation do
|
||||
|
||||
# Referer (remote URL)
|
||||
if referer.present?
|
||||
parsed = URI.parse(referer)
|
||||
self.domain = parsed.host
|
||||
end
|
||||
|
||||
# Our URL
|
||||
if url.present?
|
||||
|
||||
parsed = URI.parse(url)
|
||||
|
||||
begin
|
||||
params = Rails.application.routes.recognize_path(parsed.path)
|
||||
self.topic_id = params[:topic_id] if params[:topic_id].present?
|
||||
self.post_number = params[:post_number] if params[:post_number].present?
|
||||
rescue ActionController::RoutingError
|
||||
# If we can't route to the url, that's OK. Don't save those two fields.
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# Update appropriate incoming link counts
|
||||
after_create do
|
||||
if topic_id.present?
|
||||
exec_sql("UPDATE topics
|
||||
SET incoming_link_count = incoming_link_count + 1
|
||||
WHERE id = ?", topic_id)
|
||||
if post_number.present?
|
||||
exec_sql("UPDATE posts
|
||||
SET incoming_link_count = incoming_link_count + 1
|
||||
WHERE topic_id = ? and post_number = ?", topic_id, post_number)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
87
app/models/invite.rb
Normal file
87
app/models/invite.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
class Invite < ActiveRecord::Base
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :topic
|
||||
belongs_to :invited_by, class_name: User
|
||||
|
||||
has_many :topic_invites
|
||||
has_many :topics, through: :topic_invites, source: :topic
|
||||
validates_presence_of :email
|
||||
validates_presence_of :invited_by_id
|
||||
|
||||
acts_as_paranoid
|
||||
|
||||
|
||||
before_create do
|
||||
self.invite_key ||= SecureRandom.hex
|
||||
end
|
||||
|
||||
before_save do
|
||||
self.email = self.email.downcase
|
||||
end
|
||||
|
||||
validate :user_doesnt_already_exist
|
||||
attr_accessor :email_already_exists
|
||||
|
||||
def user_doesnt_already_exist
|
||||
@email_already_exists = false
|
||||
return if email.blank?
|
||||
if User.where("lower(email) = ?", email.downcase).exists?
|
||||
@email_already_exists = true
|
||||
errors.add(:email)
|
||||
end
|
||||
end
|
||||
|
||||
def redeemed?
|
||||
redeemed_at.present?
|
||||
end
|
||||
|
||||
def expired?
|
||||
created_at < SiteSetting.invite_expiry_days.days.ago
|
||||
end
|
||||
|
||||
def redeem
|
||||
result = nil
|
||||
Invite.transaction do
|
||||
# Avoid a race condition
|
||||
row_count = Invite.update_all('redeemed_at = CURRENT_TIMESTAMP',
|
||||
['id = ? AND redeemed_at IS NULL AND created_at >= ?', self.id, SiteSetting.invite_expiry_days.days.ago])
|
||||
|
||||
if row_count == 1
|
||||
|
||||
# Create the user if we are redeeming the invite and the user doesn't exist
|
||||
result = User.where(email: email).first
|
||||
result = User.create_for_email(email, trust_level: SiteSetting.default_invitee_trust_level) if result.blank?
|
||||
result.send_welcome_message = false
|
||||
|
||||
# If there are topic invites for private topics
|
||||
topics.private_messages.each do |t|
|
||||
t.topic_allowed_users.create(user_id: result.id)
|
||||
end
|
||||
|
||||
# Check for other invites by the same email. Don't redeem them, but approve their
|
||||
# topics.
|
||||
Invite.where('invites.email = ? and invites.id != ?', self.email, self.id).includes(:topics).where('topics.archetype = ?', Archetype::private_message).each do |i|
|
||||
i.topics.each do |t|
|
||||
t.topic_allowed_users.create(user_id: result.id)
|
||||
end
|
||||
end
|
||||
|
||||
if Invite.update_all(['user_id = ?', result.id], ['email = ?', self.email]) == 1
|
||||
result.send_welcome_message = true
|
||||
end
|
||||
|
||||
# Notify the invitee
|
||||
invited_by.notifications.create(notification_type: Notification.Types[:invitee_accepted],
|
||||
data: {display_username: result.username}.to_json)
|
||||
|
||||
else
|
||||
# Otherwise return the existing user
|
||||
result = User.where(email: email).first
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
end
|
||||
25
app/models/invited_list.rb
Normal file
25
app/models/invited_list.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# A nice object to help keep track of invited users
|
||||
class InvitedList
|
||||
|
||||
attr_accessor :pending
|
||||
attr_accessor :redeemed
|
||||
attr_accessor :by_user
|
||||
|
||||
def initialize(user)
|
||||
@pending = []
|
||||
@redeemed = []
|
||||
@by_user = user
|
||||
|
||||
invited = Invite.where(invited_by_id: @by_user.id)
|
||||
.includes(:user)
|
||||
.order(:redeemed_at)
|
||||
invited.each do |i|
|
||||
if i.redeemed?
|
||||
@redeemed << i
|
||||
else
|
||||
@pending << i unless i.expired?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
58
app/models/message_bus_observer.rb
Normal file
58
app/models/message_bus_observer.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
require_dependency 'message_bus'
|
||||
require_dependency 'discourse_observer'
|
||||
|
||||
# This class is responsible for notifying the message bus of various
|
||||
# events.
|
||||
class MessageBusObserver < DiscourseObserver
|
||||
observe :post, :notification, :user_action, :topic
|
||||
|
||||
def after_create_post(post)
|
||||
MessageBus.publish("/topic/#{post.topic_id}",
|
||||
id: post.id,
|
||||
created_at: post.created_at,
|
||||
user: BasicUserSerializer.new(post.user).as_json(root: false),
|
||||
post_number: post.post_number)
|
||||
end
|
||||
|
||||
def after_create_notification(notification)
|
||||
refresh_notification_count(notification)
|
||||
end
|
||||
|
||||
def after_destroy_notification(notification)
|
||||
refresh_notification_count(notification)
|
||||
end
|
||||
|
||||
def after_create_user_action(user_action)
|
||||
MessageBus.publish("/users/#{user_action.user.username.downcase}", user_action.id)
|
||||
end
|
||||
|
||||
def after_create_topic(topic)
|
||||
|
||||
# Don't publish invisible topics
|
||||
return unless topic.visible?
|
||||
|
||||
return if topic.private_message?
|
||||
|
||||
topic.posters = topic.posters_summary
|
||||
topic.posts_count = 1
|
||||
topic_json = TopicListItemSerializer.new(topic).as_json
|
||||
MessageBus.publish("/popular", topic_json)
|
||||
|
||||
# If it has a category, add it to the category views too
|
||||
if topic.category.present?
|
||||
MessageBus.publish("/category/#{topic.category.slug}", topic_json)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def refresh_notification_count(notification)
|
||||
user_id = notification.user.id
|
||||
MessageBus.publish("/notification",
|
||||
{unread_notifications: notification.user.unread_notifications,
|
||||
unread_private_messages: notification.user.unread_private_messages},
|
||||
user_ids: [notification.user.id] # only publish the notification to this user
|
||||
)
|
||||
end
|
||||
end
|
||||
95
app/models/notification.rb
Normal file
95
app/models/notification.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
class Notification < ActiveRecord::Base
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :topic
|
||||
|
||||
validates_presence_of :data
|
||||
validates_presence_of :notification_type
|
||||
|
||||
def self.Types
|
||||
{:mentioned => 1,
|
||||
:replied => 2,
|
||||
:quoted => 3,
|
||||
:edited => 4,
|
||||
:liked => 5,
|
||||
:private_message => 6,
|
||||
:invited_to_private_message => 7,
|
||||
:invitee_accepted => 8,
|
||||
:posted => 9,
|
||||
:moved_post => 10}
|
||||
end
|
||||
|
||||
def self.InvertedTypes
|
||||
@inverted_types ||= Notification.Types.invert
|
||||
end
|
||||
|
||||
def self.unread
|
||||
where(read: false)
|
||||
end
|
||||
|
||||
def self.mark_post_read(user, topic_id, post_number)
|
||||
Notification.update_all "read = true", ["user_id = ? and topic_id = ? and post_number = ?", user.id, topic_id, post_number]
|
||||
end
|
||||
|
||||
def self.recent
|
||||
order('created_at desc').limit(10)
|
||||
end
|
||||
|
||||
def self.interesting_after(min_date)
|
||||
result = where("created_at > ?", min_date)
|
||||
.includes(:topic)
|
||||
.unread
|
||||
.limit(20)
|
||||
.order("CASE WHEN notification_type = #{Notification.Types[:replied]} THEN 1
|
||||
WHEN notification_type = #{Notification.Types[:mentioned]} THEN 2
|
||||
ELSE 3
|
||||
END, created_at DESC").to_a
|
||||
|
||||
# Remove any duplicates by type and topic
|
||||
if result.present?
|
||||
seen = {}
|
||||
to_remove = Set.new
|
||||
|
||||
result.each do |r|
|
||||
seen[r.notification_type] ||= Set.new
|
||||
if seen[r.notification_type].include?(r.topic_id)
|
||||
to_remove << r.id
|
||||
else
|
||||
seen[r.notification_type] << r.topic_id
|
||||
end
|
||||
end
|
||||
result.reject! {|r| to_remove.include?(r.id) }
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# Be wary of calling this frequently. O(n) JSON parsing can suck.
|
||||
def data_hash
|
||||
@data_hash ||= begin
|
||||
return nil if data.blank?
|
||||
::JSON.parse(data).with_indifferent_access
|
||||
end
|
||||
end
|
||||
|
||||
def text_description
|
||||
link = block_given? ? yield : ""
|
||||
I18n.t("notification_types.#{Notification.InvertedTypes[notification_type]}", data_hash.merge(link: link))
|
||||
end
|
||||
|
||||
def url
|
||||
if topic.present?
|
||||
return topic.relative_url(post_number)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def post
|
||||
return nil unless topic_id.present?
|
||||
return nil unless post_number.present?
|
||||
|
||||
Post.where(topic_id: topic_id, post_number: post_number).first
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
10
app/models/onebox_render.rb
Normal file
10
app/models/onebox_render.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class OneboxRender < ActiveRecord::Base
|
||||
|
||||
validates_presence_of :url
|
||||
validates_presence_of :cooked
|
||||
validates_presence_of :expires_at
|
||||
|
||||
has_many :post_onebox_renders, :dependent => :delete_all
|
||||
has_many :posts, through: :post_onebox_renders
|
||||
|
||||
end
|
||||
469
app/models/post.rb
Normal file
469
app/models/post.rb
Normal file
@@ -0,0 +1,469 @@
|
||||
require_dependency 'jobs'
|
||||
require_dependency 'pretty_text'
|
||||
require_dependency 'rate_limiter'
|
||||
|
||||
require 'archetype'
|
||||
require 'hpricot'
|
||||
require 'digest/sha1'
|
||||
|
||||
class Post < ActiveRecord::Base
|
||||
include RateLimiter::OnCreateRecord
|
||||
|
||||
module HiddenReason
|
||||
FLAG_THRESHOLD_REACHED = 1
|
||||
FLAG_THRESHOLD_REACHED_AGAIN = 2
|
||||
end
|
||||
|
||||
# A custom rate limiter for edits
|
||||
class EditRateLimiter < RateLimiter
|
||||
def initialize(user)
|
||||
super(user, "edit-post:#{Date.today.to_s}", SiteSetting.max_edits_per_day, 1.day.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
versioned
|
||||
|
||||
rate_limit
|
||||
|
||||
acts_as_paranoid
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :topic, counter_cache: :posts_count
|
||||
|
||||
has_many :post_replies
|
||||
has_many :replies, through: :post_replies
|
||||
has_many :post_actions
|
||||
|
||||
validates_presence_of :raw, :user_id, :topic_id
|
||||
validates :raw, length: {in: SiteSetting.min_post_length..SiteSetting.max_post_length}
|
||||
validate :max_mention_validator
|
||||
validate :max_images_validator
|
||||
validate :max_links_validator
|
||||
validate :unique_post_validator
|
||||
|
||||
# We can pass a hash of image sizes when saving to prevent crawling those images
|
||||
attr_accessor :image_sizes, :quoted_post_numbers, :no_bump, :invalidate_oneboxes
|
||||
|
||||
SHORT_POST_CHARS = 1200
|
||||
|
||||
# Post Types
|
||||
REGULAR = 1
|
||||
MODERATOR_ACTION = 2
|
||||
|
||||
before_save :extract_quoted_post_numbers
|
||||
after_commit :feature_topic_users, on: :create
|
||||
after_commit :trigger_post_process, on: :create
|
||||
after_commit :email_private_message, on: :create
|
||||
|
||||
# Related to unique post tracking
|
||||
after_commit :store_unique_post_key, on: :create
|
||||
|
||||
after_create do
|
||||
TopicUser.auto_track(self.user_id, self.topic_id, TopicUser::NotificationReasons::CREATED_POST)
|
||||
end
|
||||
|
||||
before_validation do
|
||||
self.raw.strip! if self.raw.present?
|
||||
end
|
||||
|
||||
# Stop us from posting the same thing too quickly
|
||||
def unique_post_validator
|
||||
return if SiteSetting.unique_posts_mins == 0
|
||||
return if user.admin? or user.has_trust_level?(:moderator)
|
||||
|
||||
# If the post is empty, default to the validates_presence_of
|
||||
return if raw.blank?
|
||||
|
||||
if $redis.exists(unique_post_key)
|
||||
errors.add(:raw, I18n.t(:just_posted_that))
|
||||
end
|
||||
end
|
||||
|
||||
# On successful post, store a hash key to prevent the same post from happening again
|
||||
def store_unique_post_key
|
||||
return if SiteSetting.unique_posts_mins == 0
|
||||
$redis.setex(unique_post_key, SiteSetting.unique_posts_mins.minutes.to_i, "1")
|
||||
end
|
||||
|
||||
# The key we use in reddit to ensure unique posts
|
||||
def unique_post_key
|
||||
"post-#{user_id}:#{raw_hash}"
|
||||
end
|
||||
|
||||
def raw_hash
|
||||
return nil if raw.blank?
|
||||
Digest::SHA1.hexdigest(raw.gsub(/\s+/, "").downcase)
|
||||
end
|
||||
|
||||
def cooked_document
|
||||
self.cooked ||= cook(self.raw, topic_id: topic_id)
|
||||
@cooked_document ||= Nokogiri::HTML.fragment(self.cooked)
|
||||
end
|
||||
|
||||
def image_count
|
||||
return 0 unless self.raw.present?
|
||||
cooked_document.search("img.emoji").remove
|
||||
cooked_document.search("img").count
|
||||
end
|
||||
|
||||
def link_count
|
||||
return 0 unless self.raw.present?
|
||||
cooked_document.search("a[href]").count
|
||||
end
|
||||
|
||||
def max_mention_validator
|
||||
errors.add(:raw, I18n.t(:too_many_mentions)) if raw_mentions.size > SiteSetting.max_mentions_per_post
|
||||
end
|
||||
|
||||
def max_images_validator
|
||||
return if user.present? and user.has_trust_level?(:basic)
|
||||
errors.add(:raw, I18n.t(:too_many_images)) if image_count > 0
|
||||
end
|
||||
|
||||
def max_links_validator
|
||||
return if user.present? and user.has_trust_level?(:basic)
|
||||
errors.add(:raw, I18n.t(:too_many_links)) if link_count > 1
|
||||
end
|
||||
|
||||
|
||||
def raw_mentions
|
||||
return [] if raw.blank?
|
||||
|
||||
# We don't count mentions in quotes
|
||||
return @raw_mentions if @raw_mentions.present?
|
||||
raw_stripped = raw.gsub(/\[quote=(.*)\]([^\[]*?)\[\/quote\]/im, '')
|
||||
|
||||
# Strip pre and code tags
|
||||
doc = Nokogiri::HTML.fragment(raw_stripped)
|
||||
doc.search("pre").remove
|
||||
doc.search("code").remove
|
||||
|
||||
results = doc.to_html.scan(PrettyText.mention_matcher)
|
||||
if results.present?
|
||||
@raw_mentions = results.uniq.map {|un| un.first.downcase.gsub!(/^@/, '')}
|
||||
else
|
||||
@raw_mentions = []
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def archetype
|
||||
topic.archetype
|
||||
end
|
||||
|
||||
def self.regular_order
|
||||
order(:sort_order, :post_number)
|
||||
end
|
||||
|
||||
def self.reverse_order
|
||||
order('sort_order desc, post_number desc')
|
||||
end
|
||||
|
||||
def self.best_of
|
||||
where("(post_number = 1) or (score >= ?)", SiteSetting.best_of_score_threshold)
|
||||
end
|
||||
|
||||
def filter_quotes(parent_post=nil)
|
||||
return cooked if parent_post.blank?
|
||||
|
||||
# We only filter quotes when there is exactly 1
|
||||
return cooked unless (quote_count == 1)
|
||||
|
||||
parent_raw = parent_post.raw.sub(/\[quote.+\/quote\]/m, '').strip
|
||||
|
||||
if raw[parent_raw] or (parent_raw.size < SHORT_POST_CHARS)
|
||||
return cooked.sub(/\<aside.+\<\/aside\>/m, '')
|
||||
end
|
||||
|
||||
cooked
|
||||
end
|
||||
|
||||
def username
|
||||
user.username
|
||||
end
|
||||
|
||||
def external_id
|
||||
"#{topic_id}/#{post_number}"
|
||||
end
|
||||
|
||||
def quoteless?
|
||||
(quote_count == 0) and (reply_to_post_number.present?)
|
||||
end
|
||||
|
||||
# Get the post that we reply to.
|
||||
def reply_to_user
|
||||
return nil unless reply_to_post_number.present?
|
||||
User.where('id = (select user_id from posts where topic_id = ? and post_number = ?)', topic_id, reply_to_post_number).first
|
||||
end
|
||||
|
||||
def reply_notification_target
|
||||
return nil unless reply_to_post_number.present?
|
||||
reply_post = Post.where("topic_id = :topic_id AND post_number = :post_number AND user_id <> :user_id",
|
||||
topic_id: topic_id,
|
||||
post_number: reply_to_post_number,
|
||||
user_id: user_id).first
|
||||
return reply_post.try(:user)
|
||||
end
|
||||
|
||||
def self.excerpt(cooked, maxlength=nil)
|
||||
maxlength ||= SiteSetting.post_excerpt_maxlength
|
||||
PrettyText.excerpt(cooked, maxlength)
|
||||
end
|
||||
|
||||
# Strip out most of the markup
|
||||
def excerpt(maxlength=nil)
|
||||
Post.excerpt(cooked, maxlength)
|
||||
end
|
||||
|
||||
# What we use to cook posts
|
||||
def cook(*args)
|
||||
cooked = PrettyText.cook(*args)
|
||||
|
||||
# If we have any of the oneboxes in the cache, throw them in right away, don't
|
||||
# wait for the post processor.
|
||||
dirty = false
|
||||
doc = Oneboxer.each_onebox_link(cooked) do |url, elem|
|
||||
cached = Oneboxer.render_from_cache(url)
|
||||
if cached.present?
|
||||
elem.swap(cached.cooked)
|
||||
dirty = true
|
||||
end
|
||||
end
|
||||
|
||||
cooked = doc.to_html if dirty
|
||||
cooked
|
||||
end
|
||||
|
||||
# A list of versions including the initial version
|
||||
def all_versions
|
||||
result = []
|
||||
result << {number: 1, display_username: user.name, created_at: created_at}
|
||||
versions.order(:number).includes(:user).each do |v|
|
||||
result << {number: v.number, display_username: v.user.name, created_at: v.created_at}
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
# Update the body of a post. Will create a new version when appropriate
|
||||
def revise(updated_by, new_raw, opts={})
|
||||
|
||||
# Only update if it changes
|
||||
return false if self.raw == new_raw
|
||||
|
||||
updater = lambda do |new_version=false|
|
||||
|
||||
# Raw is about to change, enable validations
|
||||
@cooked_document = nil
|
||||
self.cooked = nil
|
||||
|
||||
self.raw = new_raw
|
||||
self.updated_by = updated_by
|
||||
self.last_editor_id = updated_by.id
|
||||
|
||||
if self.hidden && self.hidden_reason_id == HiddenReason::FLAG_THRESHOLD_REACHED
|
||||
self.hidden = false
|
||||
self.hidden_reason_id = nil
|
||||
self.topic.update_attributes(visible: true)
|
||||
|
||||
PostAction.clear_flags!(self, -1)
|
||||
end
|
||||
|
||||
self.save
|
||||
end
|
||||
|
||||
# We can optionally specify when this version was revised. Defaults to now.
|
||||
revised_at = opts[:revised_at] || Time.now
|
||||
new_version = false
|
||||
|
||||
# We always create a new version if the poster has changed
|
||||
new_version = true if (self.last_editor_id != updated_by.id)
|
||||
|
||||
# We always create a new version if it's been greater than the ninja edit window
|
||||
new_version = true if (revised_at - last_version_at) > SiteSetting.ninja_edit_window.to_i
|
||||
|
||||
# Create the new version (or don't)
|
||||
if new_version
|
||||
|
||||
self.cached_version = version + 1
|
||||
|
||||
Post.transaction do
|
||||
self.last_version_at = revised_at
|
||||
updater.call(true)
|
||||
EditRateLimiter.new(updated_by).performed!
|
||||
|
||||
# If a new version is created of the last post, bump it.
|
||||
unless Post.where('post_number > ? and topic_id = ?', self.post_number, self.topic_id).exists?
|
||||
topic.update_column(:bumped_at, Time.now)
|
||||
end
|
||||
end
|
||||
|
||||
else
|
||||
skip_version(&updater)
|
||||
end
|
||||
|
||||
# Invalidate any oneboxes
|
||||
self.invalidate_oneboxes = true
|
||||
trigger_post_process
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
|
||||
def url
|
||||
"/t/#{Slug.for(topic.title)}/#{topic.id}/#{post_number}"
|
||||
end
|
||||
|
||||
# Various callbacks
|
||||
before_create do
|
||||
self.post_number ||= Topic.next_post_number(topic_id, reply_to_post_number.present?)
|
||||
self.cooked ||= cook(raw, topic_id: topic_id)
|
||||
self.sort_order = post_number
|
||||
DiscourseEvent.trigger(:before_create_post, self)
|
||||
self.last_version_at ||= Time.now
|
||||
end
|
||||
|
||||
# TODO: Move some of this into an asynchronous job?
|
||||
after_create do
|
||||
|
||||
# Update attributes on the topic - featured users and last posted.
|
||||
attrs = {last_posted_at: self.created_at, last_post_user_id: self.user_id}
|
||||
attrs[:bumped_at] = self.created_at unless no_bump
|
||||
topic.update_attributes(attrs)
|
||||
|
||||
# Update the user's last posted at date
|
||||
user.update_column(:last_posted_at, self.created_at)
|
||||
|
||||
# Update topic user data
|
||||
TopicUser.change(user,
|
||||
topic.id,
|
||||
posted: true,
|
||||
last_read_post_number: self.post_number,
|
||||
seen_post_count: self.post_number)
|
||||
end
|
||||
|
||||
def email_private_message
|
||||
# send a mail to notify users in case of a private message
|
||||
if topic.private_message?
|
||||
topic.allowed_users.where(["users.email_private_messages = true and users.id != ?", self.user_id]).each do |u|
|
||||
Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes, :user_email, type: :private_message, user_id: u.id, post_id: self.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def feature_topic_users
|
||||
Jobs.enqueue(:feature_topic_users, topic_id: self.topic_id)
|
||||
end
|
||||
|
||||
# This calculates the geometric mean of the post timings and stores it along with
|
||||
# each post.
|
||||
def self.calculate_avg_time
|
||||
exec_sql("UPDATE posts
|
||||
SET avg_time = (x.gmean / 1000)
|
||||
FROM (SELECT post_timings.topic_id,
|
||||
post_timings.post_number,
|
||||
round(exp(avg(ln(msecs)))) AS gmean
|
||||
FROM post_timings
|
||||
INNER JOIN posts AS p2
|
||||
ON p2.post_number = post_timings.post_number
|
||||
AND p2.topic_id = post_timings.topic_id
|
||||
AND p2.user_id <> post_timings.user_id
|
||||
GROUP BY post_timings.topic_id, post_timings.post_number) AS x
|
||||
WHERE x.topic_id = posts.topic_id
|
||||
AND x.post_number = posts.post_number")
|
||||
end
|
||||
|
||||
before_save do
|
||||
self.last_editor_id ||= self.user_id
|
||||
self.cooked = cook(raw, topic_id: topic_id) unless new_record?
|
||||
end
|
||||
|
||||
before_destroy do
|
||||
|
||||
# Update the last post id to the previous post if it exists
|
||||
last_post = Post.where("topic_id = ? and id <> ?", self.topic_id, self.id).order('created_at desc').limit(1).first
|
||||
if last_post.present?
|
||||
topic.update_attributes(last_posted_at: last_post.created_at,
|
||||
last_post_user_id: last_post.user_id,
|
||||
highest_post_number: last_post.post_number)
|
||||
|
||||
# If the poster doesn't have any other posts in the topic, clear their posted flag
|
||||
unless Post.exists?(["topic_id = ? and user_id = ? and id <> ?", self.topic_id, self.user_id, self.id])
|
||||
TopicUser.update_all 'posted = false', ['topic_id = ? and user_id = ?', self.topic_id, self.user_id]
|
||||
end
|
||||
end
|
||||
|
||||
# Feature users in the topic
|
||||
Jobs.enqueue(:feature_topic_users, topic_id: topic_id, except_post_id: self.id)
|
||||
|
||||
end
|
||||
|
||||
after_destroy do
|
||||
|
||||
# Remove any reply records that point to deleted posts
|
||||
post_ids = PostReply.select(:post_id).where(reply_id: self.id).map(&:post_id)
|
||||
PostReply.delete_all ["reply_id = ?", self.id]
|
||||
|
||||
if post_ids.present?
|
||||
Post.where(id: post_ids).each {|p| p.update_column :reply_count, p.replies.count}
|
||||
end
|
||||
|
||||
# Remove any notifications that point to this deleted post
|
||||
Notification.delete_all ["topic_id = ? and post_number = ?", self.topic_id, self.post_number]
|
||||
end
|
||||
|
||||
after_save do
|
||||
|
||||
DraftSequence.next! self.last_editor_id, self.topic.draft_key if self.topic # could be deleted
|
||||
|
||||
quoted_post_numbers << reply_to_post_number if reply_to_post_number.present?
|
||||
|
||||
# Create a reply relationship between quoted posts and this new post
|
||||
if self.quoted_post_numbers.present?
|
||||
self.quoted_post_numbers.map! {|pid| pid.to_i}.uniq!
|
||||
self.quoted_post_numbers.each do |p|
|
||||
post = Post.where(topic_id: topic_id, post_number: p).first
|
||||
if post.present?
|
||||
post_reply = post.post_replies.new(reply_id: self.id)
|
||||
if post_reply.save
|
||||
Post.update_all ['reply_count = reply_count + 1, reply_below_post_number = COALESCE(reply_below_post_number, ?)', self.post_number],
|
||||
["id = ?", post.id]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def extract_quoted_post_numbers
|
||||
self.quoted_post_numbers = []
|
||||
|
||||
# Create relationships for the quotes
|
||||
raw.scan(/\[quote=\"([^"]+)"\]/).each do |m|
|
||||
if m.present?
|
||||
args = {}
|
||||
m.first.scan(/([a-z]+)\:(\d+)/).each do |arg|
|
||||
args[arg[0].to_sym] = arg[1].to_i
|
||||
end
|
||||
|
||||
if args[:topic].present?
|
||||
# If the topic attribute is present, ensure it's the same topic
|
||||
self.quoted_post_numbers << args[:post] if self.topic_id == args[:topic]
|
||||
else
|
||||
self.quoted_post_numbers << args[:post]
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
self.quoted_post_numbers.uniq!
|
||||
self.quote_count = self.quoted_post_numbers.size
|
||||
end
|
||||
|
||||
# Process this post after comitting it
|
||||
def trigger_post_process
|
||||
args = {post_id: self.id}
|
||||
args[:image_sizes] = self.image_sizes if self.image_sizes.present?
|
||||
args[:invalidate_oneboxes] = true if self.invalidate_oneboxes.present?
|
||||
Jobs.enqueue(:process_post, args)
|
||||
end
|
||||
|
||||
end
|
||||
168
app/models/post_action.rb
Normal file
168
app/models/post_action.rb
Normal file
@@ -0,0 +1,168 @@
|
||||
require_dependency 'rate_limiter'
|
||||
require_dependency 'system_message'
|
||||
|
||||
class PostAction < ActiveRecord::Base
|
||||
include RateLimiter::OnCreateRecord
|
||||
|
||||
attr_accessible :deleted_at, :post_action_type_id, :post_id, :user_id, :post, :user, :post_action_type, :message
|
||||
|
||||
belongs_to :post
|
||||
belongs_to :user
|
||||
belongs_to :post_action_type
|
||||
|
||||
rate_limit :post_action_rate_limiter
|
||||
|
||||
def self.update_flagged_posts_count
|
||||
val = exec_sql('select count(*) from posts where deleted_at is null and id in (select post_id from post_actions where post_action_type_id in (?) and deleted_at is null)', PostActionType.FlagTypes).values[0][0].to_i
|
||||
$redis.set('posts_flagged_count', val)
|
||||
|
||||
admins = User.exec_sql("select id from users where admin = 't'").map{|r| r["id"].to_i}
|
||||
MessageBus.publish('/flagged_counts', {total: val}, {user_ids: admins})
|
||||
end
|
||||
|
||||
def self.flagged_posts_count
|
||||
$redis.get('posts_flagged_count').to_i
|
||||
end
|
||||
|
||||
def self.counts_for(collection, user)
|
||||
|
||||
return {} if collection.blank?
|
||||
|
||||
collection_ids = collection.map {|p| p.id}
|
||||
|
||||
user_id = user.present? ? user.id : 0
|
||||
|
||||
result = PostAction.where(post_id: collection_ids, user_id: user_id, deleted_at: nil)
|
||||
user_actions = {}
|
||||
result.each do |r|
|
||||
user_actions[r.post_id] ||= {}
|
||||
user_actions[r.post_id][r.post_action_type_id] = r
|
||||
end
|
||||
|
||||
user_actions
|
||||
end
|
||||
|
||||
def self.clear_flags!(post, moderator_id)
|
||||
|
||||
# -1 is the automatic system cleary
|
||||
actions = moderator_id == -1 ? PostActionType.AutoActionFlagTypes : PostActionType.FlagTypes
|
||||
|
||||
PostAction.exec_sql('update post_actions set deleted_at = ?, deleted_by = ?
|
||||
where post_id = ? and deleted_at is null and post_action_type_id in (?)',
|
||||
DateTime.now, moderator_id, post.id, actions
|
||||
)
|
||||
|
||||
r = PostActionType.Types.invert
|
||||
f = actions.map{|t| ["#{r[t]}_count", 0]}
|
||||
|
||||
Post.update_all(Hash[*f.flatten], id: post.id)
|
||||
|
||||
update_flagged_posts_count
|
||||
end
|
||||
|
||||
def self.act(user, post, post_action_type_id, message = nil)
|
||||
begin
|
||||
create(post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id, message: message)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# can happen despite being .create
|
||||
# since already bookmarked
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def self.remove_act(user, post, post_action_type_id)
|
||||
if action = self.where(post_id: post.id, user_id: user.id, post_action_type_id: post_action_type_id, deleted_at: nil).first
|
||||
|
||||
transaction do
|
||||
d = DateTime.now
|
||||
count = PostAction.update_all({deleted_at: d},{id: action.id, deleted_at: nil})
|
||||
|
||||
if(count == 1)
|
||||
action.deleted_at = DateTime.now
|
||||
action.run_callbacks(:save)
|
||||
action.run_callbacks(:destroy)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def is_bookmark?
|
||||
post_action_type_id == PostActionType.Types[:bookmark]
|
||||
end
|
||||
|
||||
def is_like?
|
||||
post_action_type_id == PostActionType.Types[:like]
|
||||
end
|
||||
|
||||
def is_flag?
|
||||
PostActionType.FlagTypes.include?(post_action_type_id)
|
||||
end
|
||||
|
||||
# A custom rate limiter for this model
|
||||
def post_action_rate_limiter
|
||||
return nil unless is_flag? or is_bookmark? or is_like?
|
||||
|
||||
return @rate_limiter if @rate_limiter.present?
|
||||
|
||||
%w(like flag bookmark).each do |type|
|
||||
if send("is_#{type}?")
|
||||
@rate_limiter = RateLimiter.new(user, "create_#{type}:#{Date.today.to_s}", SiteSetting.send("max_#{type}s_per_day"), 1.day.to_i)
|
||||
return @rate_limiter
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
after_save do
|
||||
|
||||
# Update denormalized counts
|
||||
post_action_type = PostActionType.Types.invert[post_action_type_id]
|
||||
column = "#{post_action_type.to_s}_count"
|
||||
delta = deleted_at.nil? ? 1 : -1
|
||||
|
||||
# Voting also changes the sort_order
|
||||
if post_action_type == :vote
|
||||
Post.update_all ["vote_count = vote_count + :delta, sort_order = :max - (vote_count + :delta)", delta: delta, max: Topic::MAX_SORT_ORDER], ["id = ?", post_id]
|
||||
else
|
||||
Post.update_all ["#{column} = #{column} + ?", delta], ["id = ?", post_id]
|
||||
end
|
||||
|
||||
exec_sql "UPDATE topics SET #{column} = #{column} + ? WHERE id = (select p.topic_id from posts p where p.id = ?)", delta, post_id
|
||||
|
||||
if PostActionType.FlagTypes.include?(post_action_type_id)
|
||||
PostAction.update_flagged_posts_count
|
||||
end
|
||||
|
||||
if SiteSetting.flags_required_to_hide_post > 0
|
||||
# automatic hiding of posts
|
||||
info = exec_sql("select case when deleted_at is null then 'new' else 'old' end, count(*) from post_actions
|
||||
where post_id = ? and
|
||||
post_action_type_id in (?)
|
||||
group by case when deleted_at is null then 'new' else 'old' end
|
||||
", self.post_id, PostActionType.AutoActionFlagTypes).values
|
||||
|
||||
old_flags = new_flags = 0
|
||||
info.each do |r,v|
|
||||
old_flags = v.to_i if r == 'old'
|
||||
new_flags = v.to_i if r == 'new'
|
||||
end
|
||||
|
||||
|
||||
if new_flags >= SiteSetting.flags_required_to_hide_post
|
||||
exec_sql("update posts set hidden = ?, hidden_reason_id = coalesce(hidden_reason_id, ?) where id = ?",
|
||||
true, old_flags > 0 ? Post::HiddenReason::FLAG_THRESHOLD_REACHED_AGAIN : Post::HiddenReason::FLAG_THRESHOLD_REACHED, self.post_id)
|
||||
|
||||
exec_sql("update topics set visible = 'f'
|
||||
where id = ? and not exists (select 1 from posts where hidden = 'f' and topic_id = ?)", self.post.topic_id, self.post.topic_id)
|
||||
|
||||
# inform user
|
||||
if self.post.user
|
||||
SystemMessage.create(self.post.user, :post_hidden,
|
||||
url: self.post.url,
|
||||
edit_delay: SiteSetting.cooldown_minutes_after_hiding_posts)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
31
app/models/post_action_type.rb
Normal file
31
app/models/post_action_type.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
class PostActionType < ActiveRecord::Base
|
||||
attr_accessible :id, :is_flag, :name_key, :icon
|
||||
|
||||
def self.ordered
|
||||
self.order('position asc').all
|
||||
end
|
||||
|
||||
def self.Types
|
||||
@types ||= {:bookmark => 1,
|
||||
:like => 2,
|
||||
:off_topic => 3,
|
||||
:inappropriate => 4,
|
||||
:vote => 5,
|
||||
:custom_flag => 6,
|
||||
:spam => 8
|
||||
}
|
||||
end
|
||||
|
||||
def self.is_flag?(sym)
|
||||
self.FlagTypes.include?(self.Types[sym])
|
||||
end
|
||||
|
||||
def self.AutoActionFlagTypes
|
||||
@auto_action_flag_types ||= [self.Types[:off_topic], self.Types[:spam], self.Types[:inappropriate]]
|
||||
end
|
||||
|
||||
def self.FlagTypes
|
||||
@flag_types ||= self.AutoActionFlagTypes + [self.Types[:custom_flag]]
|
||||
end
|
||||
|
||||
end
|
||||
141
app/models/post_alert_observer.rb
Normal file
141
app/models/post_alert_observer.rb
Normal file
@@ -0,0 +1,141 @@
|
||||
class PostAlertObserver < ActiveRecord::Observer
|
||||
observe :post, VestalVersions::Version, :post_action
|
||||
|
||||
# Dispatch to an after_save_#{class_name} method
|
||||
def after_save(model)
|
||||
method_name = callback_for('after_save', model)
|
||||
send(method_name, model) if respond_to?(method_name)
|
||||
end
|
||||
|
||||
# Dispatch to an after_create_#{class_name} method
|
||||
def after_create(model)
|
||||
method_name = callback_for('after_create', model)
|
||||
send(method_name, model) if respond_to?(method_name)
|
||||
end
|
||||
|
||||
# We need to consider new people to mention / quote when a post is edited
|
||||
def after_save_post(post)
|
||||
mentioned_users = extract_mentioned_users(post)
|
||||
quoted_users = extract_quoted_users(post)
|
||||
|
||||
reply_to_user = post.reply_notification_target
|
||||
notify_users(mentioned_users - [reply_to_user], :mentioned, post)
|
||||
notify_users(quoted_users - mentioned_users - [reply_to_user], :quoted, post)
|
||||
end
|
||||
|
||||
def after_save_post_action(post_action)
|
||||
# We only care about deleting post actions for now
|
||||
return unless post_action.deleted_at.present?
|
||||
Notification.where(["post_action_id = ?", post_action.id]).each {|n| n.destroy}
|
||||
end
|
||||
|
||||
def after_create_post_action(post_action)
|
||||
|
||||
# We only notify on likes for now
|
||||
return unless post_action.is_like?
|
||||
|
||||
post = post_action.post
|
||||
return if post_action.user.blank?
|
||||
return if post.topic.private_message?
|
||||
|
||||
create_notification(post.user,
|
||||
Notification.Types[:liked],
|
||||
post,
|
||||
display_username: post_action.user.username,
|
||||
post_action_id: post_action.id)
|
||||
end
|
||||
|
||||
def after_create_version(version)
|
||||
post = version.versioned
|
||||
|
||||
return unless post.is_a?(Post)
|
||||
return if version.user.blank?
|
||||
return if version.user_id == post.user_id
|
||||
return if post.topic.private_message?
|
||||
|
||||
create_notification(post.user, Notification.Types[:edited], post, display_username: version.user.username)
|
||||
end
|
||||
|
||||
def after_create_post(post)
|
||||
if post.topic.private_message?
|
||||
# If it's a private message, notify the topic_allowed_users
|
||||
post.topic.topic_allowed_users.reject{|a| a.user_id == post.user_id}.each do |a|
|
||||
create_notification(a.user, Notification.Types[:private_message], post)
|
||||
end
|
||||
else
|
||||
# If it's not a private message, notify the users
|
||||
notify_post_users(post)
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def callback_for(action, model)
|
||||
"#{action}_#{model.class.name.underscore.gsub(/.+\//, '')}"
|
||||
end
|
||||
|
||||
def create_notification(user, type, post, opts={})
|
||||
return if user.blank?
|
||||
|
||||
# skip if muted on the topic
|
||||
return if TopicUser.get(post.topic, user).try(:notification_level) == TopicUser::NotificationLevel::MUTED
|
||||
|
||||
# Don't notify the same user about the same notification on the same post
|
||||
return if user.notifications.exists?(notification_type: type, topic_id: post.topic_id, post_number: post.post_number)
|
||||
|
||||
user.notifications.create(notification_type: type,
|
||||
topic_id: post.topic_id,
|
||||
post_number: post.post_number,
|
||||
post_action_id: opts[:post_action_id],
|
||||
data: {topic_title: post.topic.title,
|
||||
display_username: opts[:display_username] || post.user.username}.to_json)
|
||||
end
|
||||
|
||||
# Returns a list users who have been mentioned
|
||||
def extract_mentioned_users(post)
|
||||
User.where("username_lower in (?)", post.raw_mentions).where("id <> ?", post.user_id)
|
||||
end
|
||||
|
||||
# Returns a list of users who were quoted in the post
|
||||
def extract_quoted_users(post)
|
||||
result = []
|
||||
post.raw.scan(/\[quote=\"([^,]+),.+\"\]/).uniq.each do |m|
|
||||
username = m.first.strip.downcase
|
||||
user = User.where("(LOWER(username_lower) = :username or LOWER(name) = :username) and id != :id", username: username, id: post.user_id).first
|
||||
result << user if user.present?
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
# Notify a bunch of users
|
||||
def notify_users(users, type, post)
|
||||
users = [users] unless users.is_a?(Array)
|
||||
users.each do |u|
|
||||
create_notification(u, Notification.Types[type], post)
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: This should use javascript for parsing rather than re-doing it this way.
|
||||
def notify_post_users(post)
|
||||
|
||||
# Is this post a reply to a user?
|
||||
reply_to_user = post.reply_notification_target
|
||||
notify_users(reply_to_user, :replied, post)
|
||||
|
||||
|
||||
# find all users watching
|
||||
if post.post_number > 1
|
||||
exclude_user_ids = []
|
||||
exclude_user_ids << post.user_id
|
||||
exclude_user_ids << reply_to_user.id if reply_to_user.present?
|
||||
exclude_user_ids << extract_mentioned_users(post).map{|u| u.id}
|
||||
exclude_user_ids << extract_quoted_users(post).map{|u| u.id}
|
||||
exclude_user_ids.flatten!
|
||||
|
||||
TopicUser.where(topic_id: post.topic_id, notification_level: TopicUser::NotificationLevel::WATCHING).includes(:user).each do |tu|
|
||||
create_notification(tu.user, Notification.Types[:posted], post) unless exclude_user_ids.include?(tu.user_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
6
app/models/post_onebox_render.rb
Normal file
6
app/models/post_onebox_render.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class PostOneboxRender < ActiveRecord::Base
|
||||
belongs_to :post
|
||||
belongs_to :onebox_render
|
||||
|
||||
validates_uniqueness_of :post_id, scope: :onebox_render_id
|
||||
end
|
||||
7
app/models/post_reply.rb
Normal file
7
app/models/post_reply.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class PostReply < ActiveRecord::Base
|
||||
|
||||
belongs_to :post
|
||||
belongs_to :reply, class_name: 'Post'
|
||||
|
||||
validates_uniqueness_of :reply_id, scope: :post_id
|
||||
end
|
||||
42
app/models/post_timing.rb
Normal file
42
app/models/post_timing.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
class PostTiming < ActiveRecord::Base
|
||||
|
||||
belongs_to :topic
|
||||
belongs_to :user
|
||||
|
||||
validates_presence_of :post_number
|
||||
validates_presence_of :msecs
|
||||
|
||||
|
||||
# Increases a timer if a row exists, otherwise create it
|
||||
def self.record_timing(args)
|
||||
|
||||
rows = exec_sql_row_count("UPDATE post_timings
|
||||
SET msecs = msecs + :msecs
|
||||
WHERE topic_id = :topic_id
|
||||
AND user_id = :user_id
|
||||
AND post_number = :post_number",
|
||||
args)
|
||||
|
||||
if rows == 0
|
||||
Post.update_all 'reads = reads + 1', ['topic_id = :topic_id and post_number = :post_number', args]
|
||||
exec_sql("INSERT INTO post_timings (topic_id, user_id, post_number, msecs)
|
||||
SELECT :topic_id, :user_id, :post_number, :msecs
|
||||
WHERE NOT EXISTS(SELECT 1 FROM post_timings
|
||||
WHERE topic_id = :topic_id
|
||||
AND user_id = :user_id
|
||||
AND post_number = :post_number)",
|
||||
args)
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
||||
def self.destroy_for(user_id, topic_id)
|
||||
PostTiming.transaction do
|
||||
PostTiming.delete_all(['user_id = ? and topic_id = ?', user_id, topic_id])
|
||||
TopicUser.delete_all(['user_id = ? and topic_id = ?', user_id, topic_id])
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
103
app/models/search_observer.rb
Normal file
103
app/models/search_observer.rb
Normal file
@@ -0,0 +1,103 @@
|
||||
class SearchObserver < ActiveRecord::Observer
|
||||
observe :topic, :post, :user, :category
|
||||
|
||||
def self.scrub_html_for_search(html)
|
||||
HtmlScrubber.scrub(html)
|
||||
end
|
||||
|
||||
def self.update_index(table, id, idx)
|
||||
Post.exec_sql("delete from #{table} where id = ?", id)
|
||||
sql = "insert into #{table} (id, search_data) values (?, to_tsvector('english', ?))"
|
||||
begin
|
||||
Post.exec_sql(sql, id, idx)
|
||||
rescue
|
||||
# don't allow concurrency to mess up saving a post
|
||||
end
|
||||
end
|
||||
|
||||
def self.update_posts_index(post_id, cooked, title, category)
|
||||
idx = scrub_html_for_search(cooked)
|
||||
idx << " " << title
|
||||
idx << " " << category if category
|
||||
update_index('posts_search', post_id, idx)
|
||||
end
|
||||
|
||||
def self.update_users_index(user_id, username, name)
|
||||
idx = username.dup
|
||||
idx << " " << (name || "")
|
||||
|
||||
update_index('users_search', user_id, idx)
|
||||
end
|
||||
|
||||
def self.update_categories_index(category_id, name)
|
||||
update_index('categories_search', category_id, name)
|
||||
end
|
||||
|
||||
def after_save(obj)
|
||||
if obj.class == Post && obj.cooked_changed?
|
||||
category_name = obj.topic.category.name if obj.topic.category
|
||||
SearchObserver.update_posts_index(obj.id, obj.cooked, obj.topic.title, category_name)
|
||||
end
|
||||
if obj.class == User && (obj.username_changed? || obj.name_changed?)
|
||||
SearchObserver.update_users_index(obj.id, obj.username, obj.name)
|
||||
end
|
||||
|
||||
if obj.class == Topic && obj.title_changed?
|
||||
if obj.posts
|
||||
post = obj.posts.where(post_number: 1).first
|
||||
if post
|
||||
category_name = obj.category.name if obj.category
|
||||
SearchObserver.update_posts_index(post.id, post.cooked, obj.title, category_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if obj.class == Category && obj.name_changed?
|
||||
SearchObserver.update_categories_index(obj.id, obj.name)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
class HtmlScrubber < Nokogiri::XML::SAX::Document
|
||||
attr_reader :scrubbed
|
||||
|
||||
def initialize
|
||||
@scrubbed = ""
|
||||
end
|
||||
|
||||
def self.scrub(html)
|
||||
me = self.new
|
||||
parser = Nokogiri::HTML::SAX::Parser.new(me)
|
||||
begin
|
||||
copy = "<div>"
|
||||
copy << html unless html.nil?
|
||||
copy << "</div>"
|
||||
parser.parse(html) unless html.nil?
|
||||
end
|
||||
me.scrubbed
|
||||
end
|
||||
|
||||
def start_element(name, attributes=[])
|
||||
attributes = Hash[*attributes.flatten]
|
||||
if attributes["alt"]
|
||||
scrubbed << " "
|
||||
scrubbed << attributes["alt"]
|
||||
scrubbed << " "
|
||||
end
|
||||
if attributes["title"]
|
||||
scrubbed << " "
|
||||
scrubbed << attributes["title"]
|
||||
scrubbed << " "
|
||||
end
|
||||
end
|
||||
|
||||
def characters(string)
|
||||
scrubbed << " "
|
||||
scrubbed << string
|
||||
scrubbed << " "
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
49
app/models/site.rb
Normal file
49
app/models/site.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# A class we can use to serialize the site data
|
||||
require_dependency 'score_calculator'
|
||||
require_dependency 'trust_level'
|
||||
|
||||
class Site
|
||||
include ActiveModel::Serialization
|
||||
|
||||
def site_setting
|
||||
SiteSetting
|
||||
end
|
||||
|
||||
def post_action_types
|
||||
PostActionType.ordered
|
||||
end
|
||||
|
||||
def notification_types
|
||||
Notification.Types
|
||||
end
|
||||
|
||||
def trust_levels
|
||||
TrustLevel.all
|
||||
end
|
||||
|
||||
def categories
|
||||
Category.popular
|
||||
end
|
||||
|
||||
def archetypes
|
||||
Archetype.list.reject{|t| t.id==Archetype.private_message}
|
||||
end
|
||||
|
||||
def self.cache_key
|
||||
"site_json"
|
||||
end
|
||||
|
||||
def self.cached_json
|
||||
# Sam: bumping this way down, SiteSerializer will serialize post actions as well,
|
||||
# On my local this was not being flushed as post actions types changed, it turn this
|
||||
# broke local.
|
||||
Rails.cache.fetch(Site.cache_key, expires_in: 1.minute) do
|
||||
MultiJson.dump(SiteSerializer.new(Site.new, root: false))
|
||||
end
|
||||
end
|
||||
|
||||
def self.invalidate_cache
|
||||
Rails.cache.delete(Site.cache_key)
|
||||
end
|
||||
|
||||
end
|
||||
144
app/models/site_customization.rb
Normal file
144
app/models/site_customization.rb
Normal file
@@ -0,0 +1,144 @@
|
||||
class SiteCustomization < ActiveRecord::Base
|
||||
|
||||
CACHE_PATH = 'stylesheet-cache'
|
||||
@lock = Mutex.new
|
||||
|
||||
before_create do
|
||||
self.position ||= (SiteCustomization.maximum(:position) || 0) + 1
|
||||
self.enabled ||= false
|
||||
self.key ||= SecureRandom.uuid
|
||||
true
|
||||
end
|
||||
|
||||
before_save do
|
||||
if self.stylesheet_changed?
|
||||
begin
|
||||
self.stylesheet_baked = Sass.compile self.stylesheet
|
||||
rescue Sass::SyntaxError => e
|
||||
error = e.sass_backtrace_str("custom stylesheet")
|
||||
error.gsub!("\n", '\A ')
|
||||
error.gsub!("'", '\27 ')
|
||||
|
||||
self.stylesheet_baked =
|
||||
"#main {display: none;}
|
||||
footer {white-space: pre; margin-left: 100px;}
|
||||
footer:after{ content: '#{error}' }"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
after_save do
|
||||
if self.stylesheet_changed?
|
||||
if File.exists?(self.stylesheet_fullpath)
|
||||
File.delete self.stylesheet_fullpath
|
||||
end
|
||||
end
|
||||
self.remove_from_cache!
|
||||
if self.stylesheet_changed?
|
||||
self.ensure_stylesheet_on_disk!
|
||||
MessageBus.publish "/file-change/#{self.key}", self.stylesheet_hash
|
||||
end
|
||||
MessageBus.publish "/header-change/#{self.key}", self.header if self.header_changed?
|
||||
|
||||
end
|
||||
|
||||
after_destroy do
|
||||
if File.exists?(self.stylesheet_fullpath)
|
||||
File.delete self.stylesheet_fullpath
|
||||
end
|
||||
self.remove_from_cache!
|
||||
end
|
||||
|
||||
|
||||
def self.custom_stylesheet(preview_style)
|
||||
style = lookup_style(preview_style)
|
||||
style.stylesheet_link_tag.html_safe if style
|
||||
end
|
||||
|
||||
def self.custom_header(preview_style)
|
||||
style = lookup_style(preview_style)
|
||||
style.header.html_safe if style
|
||||
end
|
||||
|
||||
def self.override_default_style(preview_style)
|
||||
style = lookup_style(preview_style)
|
||||
style.override_default_style if style
|
||||
end
|
||||
|
||||
def self.lookup_style(key)
|
||||
|
||||
return if key.blank?
|
||||
|
||||
# cache is cross site resiliant cause key is secure random
|
||||
@cache ||= {}
|
||||
ensure_cache_listener
|
||||
style = @cache[key]
|
||||
return style if style
|
||||
|
||||
@lock.synchronize do
|
||||
style = self.where(key: key).first
|
||||
@cache[key] = style
|
||||
end
|
||||
end
|
||||
|
||||
def self.ensure_cache_listener
|
||||
unless @subscribed
|
||||
klass = self
|
||||
MessageBus.subscribe("/site_customization") do |msg|
|
||||
message = msg.data
|
||||
klass.remove_from_cache!(message["key"], false)
|
||||
end
|
||||
|
||||
@subscribed = true
|
||||
end
|
||||
end
|
||||
|
||||
def self.remove_from_cache!(key, broadcast=true)
|
||||
MessageBus.publish('/site_customization', {key: key}) if broadcast
|
||||
if @cache
|
||||
@lock.synchronize do
|
||||
@cache[key] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_cache!
|
||||
self.class.remove_from_cache!(self.key)
|
||||
end
|
||||
|
||||
def stylesheet_hash
|
||||
Digest::MD5.hexdigest(self.stylesheet)
|
||||
end
|
||||
|
||||
def ensure_stylesheet_on_disk!
|
||||
path = stylesheet_fullpath
|
||||
dir = "#{Rails.root}/public/#{CACHE_PATH}"
|
||||
FileUtils.mkdir_p(dir)
|
||||
unless File.exists?(path)
|
||||
File.open(path, "w") do |f|
|
||||
f.puts self.stylesheet_baked
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stylesheet_filename
|
||||
file = ""
|
||||
dir = "#{Rails.root}/public/#{CACHE_PATH}"
|
||||
path = dir + file
|
||||
|
||||
"/#{CACHE_PATH}/#{self.key}.css"
|
||||
end
|
||||
|
||||
def stylesheet_fullpath
|
||||
"#{Rails.root}/public#{self.stylesheet_filename}"
|
||||
end
|
||||
|
||||
def stylesheet_link_tag
|
||||
return "" unless self.stylesheet.present?
|
||||
return @stylesheet_link_tag if @stylesheet_link_tag
|
||||
ensure_stylesheet_on_disk!
|
||||
@stylesheet_link_tag = "<link class=\"custom-css\" rel=\"stylesheet\" href=\"#{self.stylesheet_filename}?#{self.stylesheet_hash}\" type=\"text/css\" media=\"screen\">"
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
129
app/models/site_setting.rb
Normal file
129
app/models/site_setting.rb
Normal file
@@ -0,0 +1,129 @@
|
||||
require 'site_setting_extension'
|
||||
|
||||
class SiteSetting < ActiveRecord::Base
|
||||
extend SiteSettingExtension
|
||||
|
||||
validates_presence_of :name
|
||||
validates_presence_of :data_type
|
||||
|
||||
attr_accessible :description, :name, :value, :data_type
|
||||
|
||||
# settings available in javascript under Discourse.SiteSettings
|
||||
client_setting(:title, "Discourse")
|
||||
client_setting(:logo_url, '/assets/logo.png')
|
||||
client_setting(:logo_small_url, '')
|
||||
client_setting(:traditional_markdown_linebreaks, false)
|
||||
client_setting(:popup_delay, 1500)
|
||||
client_setting(:top_menu, 'popular|new|unread|favorited|categories')
|
||||
client_setting(:post_menu, 'like|edit|flag|delete|share|bookmark|reply')
|
||||
client_setting(:max_length_show_reply, 1500)
|
||||
client_setting(:track_external_right_clicks, false)
|
||||
client_setting(:must_approve_users, false)
|
||||
client_setting(:ga_tracking_code, "")
|
||||
client_setting(:new_topics_rollup, 1)
|
||||
client_setting(:enable_long_polling, true)
|
||||
client_setting(:polling_interval, 3000)
|
||||
client_setting(:anon_polling_interval, 30000)
|
||||
client_setting(:min_post_length, Rails.env.test? ? 5 : 20)
|
||||
client_setting(:max_post_length, 16000)
|
||||
client_setting(:min_topic_title_length, 5)
|
||||
client_setting(:max_topic_title_length, 255)
|
||||
client_setting(:flush_timings_secs, 5)
|
||||
|
||||
|
||||
# settings only available server side
|
||||
setting(:auto_track_topics_after, 60000)
|
||||
setting(:long_polling_interval, 15000)
|
||||
setting(:flags_required_to_hide_post, 3)
|
||||
setting(:cooldown_minutes_after_hiding_posts, 10)
|
||||
setting(:port, Rails.env.development? ? 3000 : '')
|
||||
setting(:enable_private_messages, true)
|
||||
setting(:use_ssl, false)
|
||||
setting(:secret_token)
|
||||
setting(:restrict_access, false)
|
||||
setting(:access_password)
|
||||
setting(:queue_jobs, !Rails.env.test?)
|
||||
setting(:crawl_images, !Rails.env.test?)
|
||||
setting(:enable_imgur, false)
|
||||
setting(:imgur_api_key, '')
|
||||
setting(:imgur_endpoint, "http://api.imgur.com/2/upload.json")
|
||||
setting(:max_image_width, 690)
|
||||
setting(:category_featured_topics, 6)
|
||||
setting(:topics_per_page, 30)
|
||||
setting(:posts_per_page, 20)
|
||||
setting(:invite_expiry_days, 14)
|
||||
setting(:active_user_rate_limit_secs, 60)
|
||||
setting(:previous_visit_timeout_hours, 1)
|
||||
setting(:favicon_url, '/assets/favicon.ico')
|
||||
|
||||
setting(:ninja_edit_window, 5.minutes.to_i)
|
||||
setting(:post_undo_action_window_mins, 10)
|
||||
setting(:system_username, '')
|
||||
setting(:max_mentions_per_post, 5)
|
||||
|
||||
setting(:uncategorized_name, 'uncategorized')
|
||||
|
||||
setting(:unique_posts_mins, Rails.env.test? ? 0 : 5)
|
||||
|
||||
# Rate Limits
|
||||
setting(:rate_limit_create_topic, 5)
|
||||
setting(:rate_limit_create_post, 5)
|
||||
setting(:max_topics_per_day, 20)
|
||||
setting(:max_private_messages_per_day, 20)
|
||||
setting(:max_likes_per_day, 20)
|
||||
setting(:max_bookmarks_per_day, 20)
|
||||
setting(:max_flags_per_day, 20)
|
||||
setting(:max_edits_per_day, 30)
|
||||
setting(:max_favorites_per_day, 20)
|
||||
|
||||
|
||||
setting(:email_time_window_mins, 5)
|
||||
|
||||
# How many characters we can import into a onebox
|
||||
setting(:onebox_max_chars, 5000)
|
||||
|
||||
setting(:suggested_topics, 5)
|
||||
|
||||
setting(:allow_duplicate_topic_titles, false)
|
||||
|
||||
setting(:post_excerpt_maxlength, 300)
|
||||
setting(:post_onebox_maxlength, 500)
|
||||
setting(:best_of_score_threshold, 15)
|
||||
setting(:best_of_posts_required, 50)
|
||||
setting(:best_of_likes_required, 1)
|
||||
setting(:category_post_template,
|
||||
"[Replace this first paragraph with a short description of your new category. Try to keep it below 200 characters.]\n\nUse this space below for a longer description, as well as to establish any rules or discussion!")
|
||||
|
||||
# we need to think of a way to force users to enter certain settings, this is a minimal config thing
|
||||
setting(:notification_email, 'info@discourse.org')
|
||||
|
||||
setting(:send_welcome_message, true)
|
||||
|
||||
setting(:twitter_consumer_key, '')
|
||||
setting(:twitter_consumer_secret, '')
|
||||
|
||||
setting(:facebook_app_id, '')
|
||||
setting(:facebook_app_secret, '')
|
||||
|
||||
setting(:enforce_global_nicknames, true)
|
||||
setting(:discourse_org_access_key, '')
|
||||
setting(:enable_s3_uploads, false)
|
||||
setting(:s3_upload_bucket, '')
|
||||
|
||||
setting(:default_trust_level, 0)
|
||||
setting(:default_invitee_trust_level, 1)
|
||||
|
||||
# Import/Export settings
|
||||
setting(:allow_import, false)
|
||||
|
||||
# Trust related
|
||||
setting(:basic_requires_topics_entered, 5)
|
||||
setting(:basic_requires_read_posts, 100)
|
||||
setting(:basic_requires_time_spent_mins, 30)
|
||||
|
||||
|
||||
def self.call_mothership?
|
||||
self.enforce_global_nicknames? and self.discourse_org_access_key.present?
|
||||
end
|
||||
|
||||
end
|
||||
516
app/models/topic.rb
Normal file
516
app/models/topic.rb
Normal file
@@ -0,0 +1,516 @@
|
||||
require_dependency 'slug'
|
||||
require_dependency 'avatar_lookup'
|
||||
require_dependency 'topic_view'
|
||||
require_dependency 'rate_limiter'
|
||||
|
||||
class Topic < ActiveRecord::Base
|
||||
include RateLimiter::OnCreateRecord
|
||||
|
||||
MAX_SORT_ORDER = 2147483647
|
||||
FEATURED_USERS = 4
|
||||
|
||||
versioned :if => :new_version_required?
|
||||
acts_as_paranoid
|
||||
|
||||
rate_limit :default_rate_limiter
|
||||
rate_limit :limit_topics_per_day
|
||||
rate_limit :limit_private_messages_per_day
|
||||
|
||||
validates_presence_of :title
|
||||
validates :title, length: {in: SiteSetting.min_topic_title_length..SiteSetting.max_topic_title_length}
|
||||
|
||||
serialize :meta_data, ActiveRecord::Coders::Hstore
|
||||
|
||||
validate :unique_title
|
||||
|
||||
belongs_to :category
|
||||
has_many :posts
|
||||
has_many :topic_allowed_users
|
||||
has_many :allowed_users, through: :topic_allowed_users, source: :user
|
||||
belongs_to :user
|
||||
belongs_to :last_poster, class_name: 'User', foreign_key: :last_post_user_id
|
||||
belongs_to :featured_user1, class_name: 'User', foreign_key: :featured_user1_id
|
||||
belongs_to :featured_user2, class_name: 'User', foreign_key: :featured_user2_id
|
||||
belongs_to :featured_user3, class_name: 'User', foreign_key: :featured_user3_id
|
||||
belongs_to :featured_user4, class_name: 'User', foreign_key: :featured_user4_id
|
||||
|
||||
has_many :topic_users
|
||||
has_many :topic_links
|
||||
has_many :topic_invites
|
||||
has_many :invites, through: :topic_invites, source: :invite
|
||||
|
||||
# When we want to temporarily attach some data to a forum topic (usually before serialization)
|
||||
attr_accessor :user_data
|
||||
attr_accessor :posters # TODO: can replace with posters_summary once we remove old list code
|
||||
|
||||
|
||||
# The regular order
|
||||
scope :topic_list_order, lambda { order('topics.bumped_at desc') }
|
||||
|
||||
# Return private message topics
|
||||
scope :private_messages, lambda {
|
||||
where(archetype: Archetype::private_message)
|
||||
}
|
||||
|
||||
scope :listable_topics, lambda { where('topics.archetype <> ?', [Archetype.private_message]) }
|
||||
|
||||
# Helps us limit how many favorites can be made in a day
|
||||
class FavoriteLimiter < RateLimiter
|
||||
def initialize(user)
|
||||
super(user, "favorited:#{Date.today.to_s}", SiteSetting.max_favorites_per_day, 1.day.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
before_validation do
|
||||
self.title.strip! if self.title.present?
|
||||
end
|
||||
|
||||
before_create do
|
||||
self.bumped_at ||= Time.now
|
||||
self.last_post_user_id ||= self.user_id
|
||||
end
|
||||
|
||||
after_create do
|
||||
changed_to_category(category)
|
||||
TopicUser.change(
|
||||
self.user_id, self.id,
|
||||
notification_level: TopicUser::NotificationLevel::WATCHING,
|
||||
notifications_reason_id: TopicUser::NotificationReasons::CREATED_TOPIC
|
||||
)
|
||||
if self.archetype == Archetype.private_message
|
||||
DraftSequence.next!(self.user, Draft::NEW_PRIVATE_MESSAGE)
|
||||
else
|
||||
DraftSequence.next!(self.user, Draft::NEW_TOPIC)
|
||||
end
|
||||
end
|
||||
|
||||
# Additional rate limits on topics: per day and private messages per day
|
||||
def limit_topics_per_day
|
||||
RateLimiter.new(user, "topics-per-day:#{Date.today.to_s}", SiteSetting.max_topics_per_day, 1.day.to_i)
|
||||
end
|
||||
|
||||
def limit_private_messages_per_day
|
||||
return unless private_message?
|
||||
RateLimiter.new(user, "pms-per-day:#{Date.today.to_s}", SiteSetting.max_private_messages_per_day, 1.day.to_i)
|
||||
end
|
||||
|
||||
# Validate unique titles if a site setting is set
|
||||
def unique_title
|
||||
return if SiteSetting.allow_duplicate_topic_titles?
|
||||
|
||||
# Let presence validation catch it if it's blank
|
||||
return if title.blank?
|
||||
|
||||
# Private messages can be called whatever they want
|
||||
return if private_message?
|
||||
|
||||
finder = Topic.listable_topics.where("lower(title) = ?", title.downcase)
|
||||
finder = finder.where("id != ?", self.id) if self.id.present?
|
||||
|
||||
errors.add(:title, I18n.t(:has_already_been_used)) if finder.exists?
|
||||
end
|
||||
|
||||
|
||||
def new_version_required?
|
||||
return true if title_changed?
|
||||
return true if category_id_changed?
|
||||
false
|
||||
end
|
||||
|
||||
# Returns new topics since a date
|
||||
def self.new_topics(since)
|
||||
Topic
|
||||
.visible
|
||||
.where("created_at >= ?", since)
|
||||
.listable_topics
|
||||
.topic_list_order
|
||||
.includes(:user)
|
||||
.limit(5)
|
||||
end
|
||||
|
||||
def update_meta_data(data)
|
||||
self.meta_data = (self.meta_data || {}).merge(data.stringify_keys)
|
||||
save
|
||||
end
|
||||
|
||||
def post_numbers
|
||||
@post_numbers ||= posts.order(:post_number).pluck(:post_number)
|
||||
end
|
||||
|
||||
def has_meta_data_boolean?(key)
|
||||
meta_data_string(key) == 'true'
|
||||
end
|
||||
|
||||
def meta_data_string(key)
|
||||
return nil unless meta_data.present?
|
||||
meta_data[key.to_s]
|
||||
end
|
||||
|
||||
def self.visible
|
||||
where(visible: true)
|
||||
end
|
||||
|
||||
def private_message?
|
||||
self.archetype == Archetype.private_message
|
||||
end
|
||||
|
||||
def links_grouped
|
||||
exec_sql("SELECT ftl.url,
|
||||
ft.title,
|
||||
ftl.link_topic_id,
|
||||
ftl.reflection,
|
||||
ftl.internal,
|
||||
MIN(ftl.user_id) AS user_id,
|
||||
SUM(clicks) AS clicks
|
||||
FROM topic_links AS ftl
|
||||
LEFT OUTER JOIN topics AS ft ON ftl.link_topic_id = ft.id
|
||||
WHERE ftl.topic_id = ?
|
||||
GROUP BY ftl.url, ft.title, ftl.link_topic_id, ftl.reflection, ftl.internal
|
||||
ORDER BY clicks DESC",
|
||||
self.id).to_a
|
||||
end
|
||||
|
||||
def update_status(property, status, user)
|
||||
Topic.transaction do
|
||||
update_column(property, status)
|
||||
key = "topic_statuses.#{property}_"
|
||||
key << (status ? 'enabled' : 'disabled')
|
||||
|
||||
opts = {}
|
||||
|
||||
# We don't bump moderator posts except for the re-open post.
|
||||
opts[:bump] = true if property == 'closed' and (!status)
|
||||
|
||||
add_moderator_post(user, I18n.t(key), opts)
|
||||
end
|
||||
end
|
||||
|
||||
# Atomically creates the next post number
|
||||
def self.next_post_number(topic_id, reply=false)
|
||||
highest = exec_sql("select coalesce(max(post_number),0) as max from posts where topic_id = ?", topic_id).first['max'].to_i
|
||||
|
||||
reply_sql = reply ? ", reply_count = reply_count + 1" : ""
|
||||
result = exec_sql("UPDATE topics SET highest_post_number = ? + 1#{reply_sql}
|
||||
WHERE id = ? RETURNING highest_post_number", highest, topic_id)
|
||||
result.first['highest_post_number'].to_i
|
||||
end
|
||||
|
||||
# If a post is deleted we have to update our highest post counters
|
||||
def self.reset_highest(topic_id)
|
||||
result = exec_sql "UPDATE topics
|
||||
SET highest_post_number = (SELECT COALESCE(MAX(post_number), 0) FROM posts WHERE topic_id = :topic_id AND deleted_at IS NULL),
|
||||
posts_count = (SELECT count(*) FROM posts WHERE deleted_at IS NULL AND topic_id = :topic_id)
|
||||
WHERE id = :topic_id
|
||||
RETURNING highest_post_number", topic_id: topic_id
|
||||
highest_post_number = result.first['highest_post_number'].to_i
|
||||
|
||||
# Update the forum topic user records
|
||||
exec_sql "UPDATE topic_users
|
||||
SET last_read_post_number = CASE
|
||||
WHEN last_read_post_number > :highest THEN :highest
|
||||
ELSE last_read_post_number
|
||||
END,
|
||||
seen_post_count = CASE
|
||||
WHEN seen_post_count > :highest THEN :highest
|
||||
ELSE seen_post_count
|
||||
END
|
||||
WHERE topic_id = :topic_id",
|
||||
highest: highest_post_number,
|
||||
topic_id: topic_id
|
||||
end
|
||||
|
||||
# This calculates the geometric mean of the posts and stores it with the topic
|
||||
def self.calculate_avg_time
|
||||
exec_sql("UPDATE topics
|
||||
SET avg_time = x.gmean
|
||||
FROM (SELECT topic_id,
|
||||
round(exp(avg(ln(avg_time)))) AS gmean
|
||||
FROM posts
|
||||
GROUP BY topic_id) AS x
|
||||
WHERE x.topic_id = topics.id")
|
||||
end
|
||||
|
||||
def changed_to_category(cat)
|
||||
|
||||
return if cat.blank?
|
||||
return if Category.where(topic_id: self.id).first.present?
|
||||
|
||||
Topic.transaction do
|
||||
old_category = category
|
||||
|
||||
if category_id.present? and category_id != cat.id
|
||||
Category.update_all 'topic_count = topic_count - 1', ['id = ?', category_id]
|
||||
end
|
||||
|
||||
self.category_id = cat.id
|
||||
self.save
|
||||
|
||||
CategoryFeaturedTopic.feature_topics_for(old_category)
|
||||
Category.update_all 'topic_count = topic_count + 1', ['id = ?', cat.id]
|
||||
CategoryFeaturedTopic.feature_topics_for(cat) unless old_category.try(:id) == cat.try(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def add_moderator_post(user, text, opts={})
|
||||
new_post = nil
|
||||
Topic.transaction do
|
||||
new_post = posts.create(user: user, raw: text, post_type: Post::MODERATOR_ACTION, no_bump: opts[:bump].blank?)
|
||||
increment!(:moderator_posts_count)
|
||||
new_post
|
||||
end
|
||||
|
||||
|
||||
if new_post.present?
|
||||
# If we are moving posts, we want to insert the moderator post where the previous posts were
|
||||
# in the stream, not at the end.
|
||||
new_post.update_attributes(post_number: opts[:post_number], sort_order: opts[:post_number]) if opts[:post_number].present?
|
||||
|
||||
# Grab any links that are present
|
||||
TopicLink.extract_from(new_post)
|
||||
end
|
||||
|
||||
new_post
|
||||
end
|
||||
|
||||
# Changes the category to a new name
|
||||
def change_category(name)
|
||||
# If the category name is blank, reset the attribute
|
||||
if name.blank?
|
||||
if category_id.present?
|
||||
CategoryFeaturedTopic.feature_topics_for(category)
|
||||
Category.update_all 'topic_count = topic_count - 1', ['id = ?', category_id]
|
||||
end
|
||||
self.category_id = nil
|
||||
self.save
|
||||
return
|
||||
end
|
||||
|
||||
cat = Category.where(name: name).first
|
||||
return if cat == category
|
||||
changed_to_category(cat)
|
||||
end
|
||||
|
||||
def featured_user_ids
|
||||
[featured_user1_id, featured_user2_id, featured_user3_id, featured_user4_id].uniq.compact
|
||||
end
|
||||
|
||||
# Invite a user to the topic by username or email. Returns success/failure
|
||||
def invite(invited_by, username_or_email)
|
||||
if private_message?
|
||||
# If the user exists, add them to the topic.
|
||||
user = User.where("lower(username) = :user OR lower(email) = :user", user: username_or_email.downcase).first
|
||||
if user.present?
|
||||
if topic_allowed_users.create!(user_id: user.id)
|
||||
# Notify the user they've been invited
|
||||
user.notifications.create(notification_type: Notification.Types[:invited_to_private_message],
|
||||
topic_id: self.id,
|
||||
post_number: 1,
|
||||
data: {topic_title: self.title,
|
||||
display_username: invited_by.username}.to_json)
|
||||
return true
|
||||
end
|
||||
elsif username_or_email =~ /^.+@.+$/
|
||||
# If the user doesn't exist, but it looks like an email, invite the user by email.
|
||||
return invite_by_email(invited_by, username_or_email)
|
||||
end
|
||||
else
|
||||
# Success is whether the invite was created
|
||||
return invite_by_email(invited_by, username_or_email).present?
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
# Invite a user by email and return the invite. Return the previously existing invite
|
||||
# if already exists. Returns nil if the invite can't be created.
|
||||
def invite_by_email(invited_by, email)
|
||||
lower_email = email.downcase
|
||||
|
||||
#
|
||||
invite = Invite.with_deleted.where('invited_by_id = ? and email = ?', invited_by.id, lower_email).first
|
||||
|
||||
|
||||
if invite.blank?
|
||||
invite = Invite.create(invited_by: invited_by, email: lower_email)
|
||||
unless invite.valid?
|
||||
|
||||
# If the email already exists, grant permission to that user
|
||||
if invite.email_already_exists and private_message?
|
||||
user = User.where(email: lower_email).first
|
||||
topic_allowed_users.create!(user_id: user.id)
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
# Recover deleted invites if we invite them again
|
||||
invite.recover if invite.deleted_at.present?
|
||||
|
||||
topic_invites.create(invite_id: invite.id)
|
||||
Jobs.enqueue(:invite_email, invite_id: invite.id)
|
||||
invite
|
||||
end
|
||||
|
||||
def move_posts(moved_by, new_title, post_ids)
|
||||
topic = nil
|
||||
first_post_number = nil
|
||||
Topic.transaction do
|
||||
topic = Topic.create(user: moved_by, title: new_title, category: self.category)
|
||||
|
||||
to_move = posts.where(id: post_ids).order(:created_at)
|
||||
raise Discourse::InvalidParameters.new(:post_ids) if to_move.blank?
|
||||
|
||||
to_move.each_with_index do |post, i|
|
||||
first_post_number ||= post.post_number
|
||||
row_count = Post.update_all ["post_number = :post_number, topic_id = :topic_id, sort_order = :post_number", post_number: i+1, topic_id: topic.id],
|
||||
['id = ? AND topic_id = ?', post.id, self.id]
|
||||
|
||||
# We raise an error if any of the posts can't be moved
|
||||
raise Discourse::InvalidParameters.new(:post_ids) if row_count == 0
|
||||
end
|
||||
|
||||
# Update denormalized values since we've manually moved stuff
|
||||
Topic.reset_highest(topic.id)
|
||||
Topic.reset_highest(self.id)
|
||||
end
|
||||
|
||||
# Add a moderator post explaining that the post was moved
|
||||
if topic.present?
|
||||
topic_url = "#{Discourse.base_url}#{topic.relative_url}"
|
||||
topic_link = "[#{new_title}](#{topic_url})"
|
||||
|
||||
post = add_moderator_post(moved_by, I18n.t("move_posts.moderator_post", count: post_ids.size, topic_link: topic_link), post_number: first_post_number)
|
||||
Jobs.enqueue(:notify_moved_posts, post_ids: post_ids, moved_by_id: moved_by.id)
|
||||
end
|
||||
|
||||
topic
|
||||
end
|
||||
|
||||
|
||||
# Create the summary of the interesting posters in a topic. Cheats to avoid
|
||||
# many queries.
|
||||
def posters_summary(topic_user=nil, current_user=nil, opts={})
|
||||
return @posters_summary if @posters_summary.present?
|
||||
descriptions = {}
|
||||
|
||||
# Use an avatar lookup object if we have it, otherwise create one just for this forum topic
|
||||
al = opts[:avatar_lookup]
|
||||
if al.blank?
|
||||
al = AvatarLookup.new([user_id, last_post_user_id, featured_user1_id, featured_user2_id, featured_user3_id])
|
||||
end
|
||||
|
||||
# Helps us add a description to a poster
|
||||
add_description = lambda do |u, desc|
|
||||
if u.present?
|
||||
descriptions[u.id] ||= []
|
||||
descriptions[u.id] << I18n.t(desc)
|
||||
end
|
||||
end
|
||||
|
||||
posted = if topic_user.present? and current_user.present?
|
||||
current_user if topic_user.posted?
|
||||
end
|
||||
|
||||
add_description.call(current_user, :youve_posted) if posted.present?
|
||||
add_description.call(al[user_id], :original_poster)
|
||||
add_description.call(al[featured_user1_id], :most_posts)
|
||||
add_description.call(al[featured_user2_id], :frequent_poster)
|
||||
add_description.call(al[featured_user3_id], :frequent_poster)
|
||||
add_description.call(al[featured_user4_id], :frequent_poster)
|
||||
add_description.call(al[last_post_user_id], :most_recent_poster)
|
||||
|
||||
|
||||
@posters_summary = [al[user_id],
|
||||
posted,
|
||||
al[last_post_user_id],
|
||||
al[featured_user1_id],
|
||||
al[featured_user2_id],
|
||||
al[featured_user3_id],
|
||||
al[featured_user4_id]
|
||||
].compact.uniq[0..4]
|
||||
|
||||
unless @posters_summary[0] == al[last_post_user_id]
|
||||
# shuffle last_poster to back
|
||||
@posters_summary.reject!{|u| u == al[last_post_user_id]}
|
||||
@posters_summary << al[last_post_user_id]
|
||||
end
|
||||
@posters_summary.map! do |p|
|
||||
result = TopicPoster.new
|
||||
result.user = p
|
||||
result.description = descriptions[p.id].join(', ')
|
||||
result.extras = "latest" if al[last_post_user_id] == p
|
||||
result
|
||||
end
|
||||
|
||||
@posters_summary
|
||||
end
|
||||
|
||||
# Enable/disable the star on the topic
|
||||
def toggle_star(user, starred)
|
||||
|
||||
Topic.transaction do
|
||||
TopicUser.change(user, self.id, starred: starred, starred_at: starred ? DateTime.now : nil)
|
||||
|
||||
# Update the star count
|
||||
exec_sql "UPDATE topics
|
||||
SET star_count = (SELECT COUNT(*)
|
||||
FROM topic_users AS ftu
|
||||
WHERE ftu.topic_id = topics.id
|
||||
AND ftu.starred = true)
|
||||
WHERE id = ?", self.id
|
||||
|
||||
if starred
|
||||
FavoriteLimiter.new(user).performed!
|
||||
else
|
||||
FavoriteLimiter.new(user).rollback!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Enable/disable the mute on the topic
|
||||
def toggle_mute(user, muted)
|
||||
TopicUser.change(user, self.id, notification_level: muted?(user) ? TopicUser::NotificationLevel::REGULAR : TopicUser::NotificationLevel::MUTED )
|
||||
end
|
||||
|
||||
def slug
|
||||
Slug.for(title)
|
||||
end
|
||||
|
||||
def last_post_url
|
||||
"/t/#{slug}/#{id}/#{posts_count}"
|
||||
end
|
||||
|
||||
def relative_url(post_number=nil)
|
||||
url = "/t/#{slug}/#{id}"
|
||||
url << "/#{post_number}" if post_number.present? and post_number.to_i > 1
|
||||
url
|
||||
end
|
||||
|
||||
def muted?(user)
|
||||
return false unless user && user.id
|
||||
tu = topic_users.where(user_id: user.id).first
|
||||
tu && tu.notification_level == TopicUser::NotificationLevel::MUTED
|
||||
end
|
||||
|
||||
def draft_key
|
||||
"#{Draft::EXISTING_TOPIC}#{self.id}"
|
||||
end
|
||||
|
||||
# notification stuff
|
||||
def notify_watch!(user)
|
||||
TopicUser.change(user, self.id, notification_level: TopicUser::NotificationLevel::WATCHING)
|
||||
end
|
||||
|
||||
def notify_tracking!(user)
|
||||
TopicUser.change(user, self.id, notification_level: TopicUser::NotificationLevel::TRACKING)
|
||||
end
|
||||
|
||||
def notify_regular!(user)
|
||||
TopicUser.change(user, self.id, notification_level: TopicUser::NotificationLevel::REGULAR)
|
||||
end
|
||||
|
||||
def notify_muted!(user)
|
||||
TopicUser.change(user, self.id, notification_level: TopicUser::NotificationLevel::MUTED)
|
||||
end
|
||||
end
|
||||
7
app/models/topic_allowed_user.rb
Normal file
7
app/models/topic_allowed_user.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
class TopicAllowedUser < ActiveRecord::Base
|
||||
belongs_to :topic
|
||||
belongs_to :user
|
||||
attr_accessible :topic_id, :user_id
|
||||
|
||||
validates_uniqueness_of :topic_id, scope: :user_id
|
||||
end
|
||||
10
app/models/topic_invite.rb
Normal file
10
app/models/topic_invite.rb
Normal file
@@ -0,0 +1,10 @@
|
||||
class TopicInvite < ActiveRecord::Base
|
||||
|
||||
belongs_to :topic
|
||||
belongs_to :invite
|
||||
|
||||
validates_presence_of :topic_id
|
||||
validates_presence_of :invite_id
|
||||
|
||||
validates_uniqueness_of :topic_id, scope: :invite_id
|
||||
end
|
||||
112
app/models/topic_link.rb
Normal file
112
app/models/topic_link.rb
Normal file
@@ -0,0 +1,112 @@
|
||||
require 'uri'
|
||||
require_dependency 'slug'
|
||||
|
||||
class TopicLink < ActiveRecord::Base
|
||||
|
||||
belongs_to :topic
|
||||
belongs_to :user
|
||||
belongs_to :post
|
||||
belongs_to :link_topic, class_name: 'Topic'
|
||||
|
||||
validates_presence_of :url
|
||||
|
||||
validates_length_of :url, maximum: 500
|
||||
|
||||
validates_uniqueness_of :url, scope: [:topic_id, :post_id]
|
||||
|
||||
has_many :topic_link_clicks
|
||||
|
||||
validate :link_to_self
|
||||
|
||||
# Make sure a topic can't link to itself
|
||||
def link_to_self
|
||||
errors.add(:base, "can't link to the same topic") if (topic_id == link_topic_id)
|
||||
end
|
||||
|
||||
# Extract any urls in body
|
||||
def self.extract_from(post)
|
||||
return unless post.present?
|
||||
|
||||
TopicLink.transaction do
|
||||
|
||||
added_urls = []
|
||||
reflected_urls = []
|
||||
|
||||
PrettyText
|
||||
.extract_links(post.cooked)
|
||||
.map{|u| [u, URI.parse(u)] rescue nil}
|
||||
.reject{|u,p| p.nil?}
|
||||
.uniq{|u,p| u}
|
||||
.each do |url, parsed|
|
||||
begin
|
||||
|
||||
internal = false
|
||||
topic_id = nil
|
||||
post_number = nil
|
||||
if parsed.host == Discourse.current_hostname || !parsed.host
|
||||
internal = true
|
||||
|
||||
route = Rails.application.routes.recognize_path(parsed.path)
|
||||
topic_id = route[:topic_id]
|
||||
post_number = route[:post_number] || 1
|
||||
end
|
||||
|
||||
# Skip linking to ourselves
|
||||
next if topic_id == post.topic_id
|
||||
|
||||
added_urls << url
|
||||
TopicLink.create(post_id: post.id,
|
||||
user_id: post.user_id,
|
||||
topic_id: post.topic_id,
|
||||
url: url,
|
||||
domain: parsed.host || Discourse.current_hostname,
|
||||
internal: internal,
|
||||
link_topic_id: topic_id)
|
||||
|
||||
# Create the reflection if we can
|
||||
if topic_id.present?
|
||||
topic = Topic.where(id: topic_id).first
|
||||
|
||||
if topic && post.topic.archetype != 'private_message' && topic.archetype != 'private_message'
|
||||
|
||||
prefix = Discourse.base_url
|
||||
|
||||
reflected_post = nil
|
||||
if post_number.present?
|
||||
reflected_post = Post.where(topic_id: topic_id, post_number: post_number.to_i).first
|
||||
end
|
||||
|
||||
reflected_url = "#{prefix}#{post.topic.relative_url(post.post_number)}"
|
||||
|
||||
reflected_urls << reflected_url
|
||||
TopicLink.create(user_id: post.user_id,
|
||||
topic_id: topic_id,
|
||||
post_id: reflected_post.try(:id),
|
||||
url: reflected_url,
|
||||
domain: Discourse.current_hostname,
|
||||
reflection: true,
|
||||
internal: true,
|
||||
link_topic_id: post.topic_id,
|
||||
link_post_id: post.id)
|
||||
end
|
||||
end
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
# if the URI is invalid, don't store it.
|
||||
rescue ActionController::RoutingError
|
||||
# If we can't find the route, no big deal
|
||||
end
|
||||
end
|
||||
|
||||
# Remove links that aren't there anymore
|
||||
if added_urls.present?
|
||||
TopicLink.delete_all ["(url not in (:urls)) AND (post_id = :post_id)", urls: added_urls, post_id: post.id]
|
||||
TopicLink.delete_all ["(url not in (:urls)) AND (link_post_id = :post_id)", urls: reflected_urls, post_id: post.id]
|
||||
else
|
||||
TopicLink.delete_all ["post_id = :post_id OR link_post_id = :post_id", post_id: post.id]
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
58
app/models/topic_link_click.rb
Normal file
58
app/models/topic_link_click.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
require_dependency 'discourse'
|
||||
require 'ipaddr'
|
||||
|
||||
class TopicLinkClick < ActiveRecord::Base
|
||||
|
||||
belongs_to :topic_link, counter_cache: :clicks
|
||||
belongs_to :user
|
||||
|
||||
has_ip_address :ip
|
||||
|
||||
validates_presence_of :topic_link_id
|
||||
validates_presence_of :ip
|
||||
|
||||
# Create a click from a URL and post_id
|
||||
def self.create_from(args={})
|
||||
|
||||
# Find the forum topic link
|
||||
link = TopicLink.select(:id).where(url: args[:url])
|
||||
link = link.where("user_id <> ?", args[:user_id]) if args[:user_id].present?
|
||||
link = link.where(post_id: args[:post_id]) if args[:post_id].present?
|
||||
|
||||
# If we don't have a post, just find the first occurance of the link
|
||||
link = link.where(topic_id: args[:topic_id]) if args[:topic_id].present?
|
||||
link = link.first
|
||||
|
||||
return unless link.present?
|
||||
|
||||
# Rate limit the click counts to once in 24 hours
|
||||
rate_key = "link-clicks:#{link.id}:#{args[:user_id] || args[:ip]}"
|
||||
if $redis.setnx(rate_key, "1")
|
||||
$redis.expire(rate_key, 1.day.to_i)
|
||||
create!(topic_link_id: link.id, user_id: args[:user_id], ip: args[:ip])
|
||||
end
|
||||
|
||||
args[:url]
|
||||
end
|
||||
|
||||
def self.counts_for(topic, posts)
|
||||
return {} if posts.blank?
|
||||
links = TopicLink
|
||||
.includes(:link_topic)
|
||||
.where(topic_id: topic.id, post_id: posts.map(&:id))
|
||||
.order('reflection asc, clicks desc')
|
||||
|
||||
result = {}
|
||||
links.each do |l|
|
||||
result[l.post_id] ||= []
|
||||
result[l.post_id] << {url: l.url,
|
||||
clicks: l.clicks,
|
||||
title: l.link_topic.try(:title),
|
||||
internal: l.internal,
|
||||
reflection: l.reflection}
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
end
|
||||
76
app/models/topic_list.rb
Normal file
76
app/models/topic_list.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
require_dependency 'avatar_lookup'
|
||||
|
||||
class TopicList
|
||||
include ActiveModel::Serialization
|
||||
|
||||
attr_accessor :more_topics_url, :draft, :draft_key, :draft_sequence
|
||||
|
||||
def initialize(current_user, topics)
|
||||
@current_user = current_user
|
||||
@topics_input = topics
|
||||
end
|
||||
|
||||
# Lazy initialization
|
||||
def topics
|
||||
return @topics if @topics.present?
|
||||
|
||||
@topics = @topics_input.to_a
|
||||
|
||||
# Attach some data for serialization to each topic
|
||||
@topic_lookup = TopicUser.lookup_for(@current_user, @topics) if @current_user.present?
|
||||
|
||||
# Create a lookup for all the user ids we need
|
||||
user_ids = []
|
||||
@topics.each do |ft|
|
||||
user_ids << ft.user_id << ft.last_post_user_id << ft.featured_user_ids
|
||||
end
|
||||
|
||||
avatar_lookup = AvatarLookup.new(user_ids)
|
||||
|
||||
@topics.each do |ft|
|
||||
ft.user_data = @topic_lookup[ft.id] if @topic_lookup.present?
|
||||
ft.posters = ft.posters_summary(ft.user_data, @current_user, avatar_lookup: avatar_lookup)
|
||||
end
|
||||
|
||||
return @topics
|
||||
end
|
||||
|
||||
def filter_summary
|
||||
@filter_summary ||= get_summary
|
||||
end
|
||||
|
||||
def attributes
|
||||
{'more_topics_url' => page}
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def get_summary
|
||||
s = {}
|
||||
return s unless @current_user
|
||||
split = SiteSetting.top_menu.split("|")
|
||||
|
||||
split.each do |i|
|
||||
name, filter = i.split(",")
|
||||
|
||||
exclude = nil
|
||||
if filter && filter[0] == "-"
|
||||
exclude = filter[1..-1]
|
||||
end
|
||||
|
||||
query = TopicQuery.new(@current_user, exclude_category: exclude)
|
||||
s["unread"] = query.unread_count if name == 'unread'
|
||||
s["new"] = query.new_count if name == 'new'
|
||||
|
||||
catSplit = name.split("/")
|
||||
if catSplit[0] == "category" && catSplit.length == 2 && @current_user
|
||||
query = TopicQuery.new(@current_user, only_category: catSplit[1], limit: false)
|
||||
s[name] = query.unread_count + query.new_count
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
s
|
||||
end
|
||||
|
||||
end
|
||||
18
app/models/topic_poster.rb
Normal file
18
app/models/topic_poster.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class TopicPoster < OpenStruct
|
||||
include ActiveModel::Serialization
|
||||
|
||||
attr_accessor :user, :description, :extras, :id
|
||||
|
||||
def attributes
|
||||
{'user' => user,
|
||||
'description' => description,
|
||||
'extras' => extras,
|
||||
'id' => id}
|
||||
end
|
||||
|
||||
# TODO: Remove when old list is removed
|
||||
def [](attr)
|
||||
send(attr)
|
||||
end
|
||||
|
||||
end
|
||||
197
app/models/topic_user.rb
Normal file
197
app/models/topic_user.rb
Normal file
@@ -0,0 +1,197 @@
|
||||
class TopicUser < ActiveRecord::Base
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :topic
|
||||
|
||||
module NotificationLevel
|
||||
WATCHING = 3
|
||||
TRACKING = 2
|
||||
REGULAR = 1
|
||||
MUTED = 0
|
||||
end
|
||||
|
||||
module NotificationReasons
|
||||
CREATED_TOPIC = 1
|
||||
USER_CHANGED = 2
|
||||
USER_INTERACTED = 3
|
||||
CREATED_POST = 4
|
||||
end
|
||||
|
||||
def self.auto_track(user_id, topic_id, reason)
|
||||
if exec_sql("select 1 from topic_users where user_id = ? and topic_id = ? and notifications_reason_id is null", user_id, topic_id).count == 1
|
||||
self.change(user_id, topic_id,
|
||||
notification_level: NotificationLevel::TRACKING,
|
||||
notifications_reason_id: reason
|
||||
)
|
||||
|
||||
MessageBus.publish("/topic/#{topic_id}", {
|
||||
notification_level_change: NotificationLevel::TRACKING,
|
||||
notifications_reason_id: reason
|
||||
}, user_ids: [user_id])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# Find the information specific to a user in a forum topic
|
||||
def self.lookup_for(user, topics)
|
||||
|
||||
# If the user isn't logged in, there's no last read posts
|
||||
return {} if user.blank?
|
||||
return {} if topics.blank?
|
||||
|
||||
topic_ids = topics.map {|ft| ft.id}
|
||||
create_lookup(TopicUser.where(topic_id: topic_ids, user_id: user.id))
|
||||
end
|
||||
|
||||
def self.create_lookup(topic_users)
|
||||
topic_users = topic_users.to_a
|
||||
|
||||
result = {}
|
||||
return result if topic_users.blank?
|
||||
|
||||
topic_users.each do |ftu|
|
||||
result[ftu.topic_id] = ftu
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def self.get(topic,user)
|
||||
if Topic === topic
|
||||
topic = topic.id
|
||||
end
|
||||
if User === user
|
||||
user = user.id
|
||||
end
|
||||
|
||||
TopicUser.where('topic_id = ? and user_id = ?', topic, user).first
|
||||
end
|
||||
|
||||
# Change attributes for a user (creates a record when none is present). First it tries an update
|
||||
# since there's more likely to be an existing record than not. If the update returns 0 rows affected
|
||||
# it then creates the row instead.
|
||||
def self.change(user_id, topic_id, attrs)
|
||||
|
||||
# Sometimes people pass objs instead of the ids. We can handle that.
|
||||
topic_id = topic_id.id if topic_id.is_a?(Topic)
|
||||
user_id = user_id.id if user_id.is_a?(User)
|
||||
|
||||
TopicUser.transaction do
|
||||
attrs = attrs.dup
|
||||
attrs[:starred_at] = DateTime.now if attrs[:starred_at].nil? && attrs[:starred]
|
||||
|
||||
if attrs[:notification_level]
|
||||
attrs[:notifications_changed_at] ||= DateTime.now
|
||||
attrs[:notifications_reason_id] ||= TopicUser::NotificationReasons::USER_CHANGED
|
||||
end
|
||||
attrs_array = attrs.to_a
|
||||
|
||||
attrs_sql = attrs_array.map {|t| "#{t[0]} = ?"}.join(", ")
|
||||
vals = attrs_array.map {|t| t[1]}
|
||||
rows = TopicUser.update_all([attrs_sql, *vals], ["topic_id = ? and user_id = ?", topic_id.to_i, user_id])
|
||||
|
||||
if rows == 0
|
||||
now = DateTime.now
|
||||
auto_track_after = self.exec_sql("select auto_track_topics_after_msecs from users where id = ?", user_id).values[0][0]
|
||||
auto_track_after ||= SiteSetting.auto_track_topics_after
|
||||
auto_track_after = auto_track_after.to_i
|
||||
|
||||
if auto_track_after >= 0 && auto_track_after <= (attrs[:total_msecs_viewed] || 0)
|
||||
attrs[:notification_level] ||= TopicUser::NotificationLevel::TRACKING
|
||||
end
|
||||
|
||||
TopicUser.create(attrs.merge!(user_id: user_id, topic_id: topic_id.to_i, first_visited_at: now ,last_visited_at: now))
|
||||
end
|
||||
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# In case of a race condition to insert, do nothing
|
||||
end
|
||||
|
||||
def self.track_visit!(topic,user)
|
||||
now = DateTime.now
|
||||
rows = exec_sql_row_count(
|
||||
"update topic_users set last_visited_at=? where topic_id=? and user_id=?",
|
||||
now, topic.id, user.id
|
||||
)
|
||||
|
||||
if rows == 0
|
||||
exec_sql('insert into topic_users(topic_id, user_id, last_visited_at, first_visited_at)
|
||||
values(?,?,?,?)',
|
||||
topic.id, user.id, now, now)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
# Update the last read and the last seen post count, but only if it doesn't exist.
|
||||
# This would be a lot easier if psql supported some kind of upsert
|
||||
def self.update_last_read(user, topic_id, post_number, msecs)
|
||||
return if post_number.blank?
|
||||
msecs = 0 if msecs.to_i < 0
|
||||
|
||||
args = {
|
||||
user_id: user.id,
|
||||
topic_id: topic_id,
|
||||
post_number: post_number,
|
||||
now: DateTime.now,
|
||||
msecs: msecs,
|
||||
tracking: TopicUser::NotificationLevel::TRACKING,
|
||||
threshold: SiteSetting.auto_track_topics_after
|
||||
}
|
||||
|
||||
rows = exec_sql("UPDATE topic_users
|
||||
SET
|
||||
last_read_post_number = greatest(:post_number, tu.last_read_post_number),
|
||||
seen_post_count = t.highest_post_number,
|
||||
total_msecs_viewed = tu.total_msecs_viewed + :msecs,
|
||||
notification_level =
|
||||
case when tu.notifications_reason_id is null and (tu.total_msecs_viewed + :msecs) >
|
||||
coalesce(u.auto_track_topics_after_msecs,:threshold) and
|
||||
coalesce(u.auto_track_topics_after_msecs, :threshold) >= 0 then
|
||||
:tracking
|
||||
else
|
||||
tu.notification_level
|
||||
end
|
||||
FROM topic_users tu
|
||||
join topics t on t.id = tu.topic_id
|
||||
join users u on u.id = :user_id
|
||||
WHERE
|
||||
tu.topic_id = topic_users.topic_id AND
|
||||
tu.user_id = topic_users.user_id AND
|
||||
tu.topic_id = :topic_id AND
|
||||
tu.user_id = :user_id
|
||||
RETURNING
|
||||
topic_users.notification_level, tu.notification_level old_level
|
||||
",
|
||||
args).values
|
||||
|
||||
if rows.length == 1
|
||||
before = rows[0][1].to_i
|
||||
after = rows[0][0].to_i
|
||||
|
||||
if before != after
|
||||
MessageBus.publish("/topic/#{topic_id}", {notification_level_change: after}, user_ids: [user.id])
|
||||
end
|
||||
end
|
||||
|
||||
if rows.length == 0
|
||||
|
||||
self
|
||||
|
||||
args[:tracking] = TopicUser::NotificationLevel::TRACKING
|
||||
args[:regular] = TopicUser::NotificationLevel::REGULAR
|
||||
args[:site_setting] = SiteSetting.auto_track_topics_after
|
||||
exec_sql("INSERT INTO topic_users (user_id, topic_id, last_read_post_number, seen_post_count, last_visited_at, first_visited_at, notification_level)
|
||||
SELECT :user_id, :topic_id, :post_number, ft.highest_post_number, :now, :now,
|
||||
case when coalesce(u.auto_track_topics_after_msecs, :site_setting) = 0 then :tracking else :regular end
|
||||
FROM topics AS ft
|
||||
JOIN users u on u.id = :user_id
|
||||
WHERE ft.id = :topic_id
|
||||
AND NOT EXISTS(SELECT 1
|
||||
FROM topic_users AS ftu
|
||||
WHERE ftu.user_id = :user_id and ftu.topic_id = :topic_id)",
|
||||
args)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
3
app/models/twitter_user_info.rb
Normal file
3
app/models/twitter_user_info.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class TwitterUserInfo < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
end
|
||||
98
app/models/upload.rb
Normal file
98
app/models/upload.rb
Normal file
@@ -0,0 +1,98 @@
|
||||
require 'digest/sha1'
|
||||
|
||||
class Upload < ActiveRecord::Base
|
||||
# attr_accessible :title, :body
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :topic
|
||||
|
||||
validates_presence_of :filesize
|
||||
validates_presence_of :original_filename
|
||||
|
||||
|
||||
# Create an upload given a user, file and optional topic_id
|
||||
def self.create_for(user, file, topic_id = nil)
|
||||
# TODO: Need specs/tests for this functionality
|
||||
return create_on_imgur(user, file, topic_id) if SiteSetting.enable_imgur?
|
||||
return create_on_s3(user, file, topic_id) if SiteSetting.enable_s3_uploads?
|
||||
return create_locally(user, file, topic_id)
|
||||
end
|
||||
|
||||
# Store uploads on s3
|
||||
def self.create_on_imgur(user, file, topic_id)
|
||||
|
||||
@imgur_loaded = require 'imgur' unless @imgur_loaded
|
||||
|
||||
|
||||
info = Imgur.upload_file(file)
|
||||
Upload.create!({user_id: user.id,
|
||||
topic_id: topic_id,
|
||||
original_filename: file.original_filename}.merge!(info))
|
||||
end
|
||||
|
||||
def self.create_locally(user, file, topic_id)
|
||||
upload = Upload.create!(user_id: user.id,
|
||||
topic_id: topic_id,
|
||||
url: "",
|
||||
filesize: File.size(file.tempfile),
|
||||
original_filename: file.original_filename)
|
||||
|
||||
# populate the rest of the info
|
||||
clean_name = file.original_filename.gsub(" ", "_").downcase.gsub(/[^a-z0-9\._]/, "")
|
||||
split = clean_name.split(".")
|
||||
if split.length > 1
|
||||
clean_name = split[0..-2].join("_")
|
||||
end
|
||||
image_info = FastImage.new(file.tempfile)
|
||||
clean_name += ".#{image_info.type}"
|
||||
url_root = "/uploads/#{RailsMultisite::ConnectionManagement.current_db}/#{upload.id}"
|
||||
path = "#{Rails.root}/public#{url_root}"
|
||||
upload.width, upload.height = ImageSizer.resize(*image_info.size)
|
||||
FileUtils.mkdir_p path
|
||||
# not using cause mv, cause permissions are no good on move
|
||||
File.open("#{path}/#{clean_name}", "wb") do |f|
|
||||
f.write File.read(file.tempfile)
|
||||
end
|
||||
upload.url = "#{url_root}/#{clean_name}"
|
||||
upload.save
|
||||
|
||||
upload
|
||||
end
|
||||
|
||||
def self.create_on_s3(user, file, topic_id)
|
||||
|
||||
@fog_loaded = require 'fog' unless @fog_loaded
|
||||
|
||||
tempfile = file.tempfile
|
||||
|
||||
upload = Upload.new(user_id: user.id,
|
||||
topic_id: topic_id,
|
||||
filesize: File.size(tempfile),
|
||||
original_filename: file.original_filename)
|
||||
|
||||
image_info = FastImage.new(tempfile)
|
||||
blob = file.read
|
||||
sha1 = Digest::SHA1.hexdigest(blob)
|
||||
|
||||
|
||||
Fog.credentials_path = "#{Rails.root}/config/fog_credentials.yml"
|
||||
fog = Fog::Storage.new(provider: 'AWS')
|
||||
|
||||
remote_filename = "#{sha1[2..-1]}.#{image_info.type}"
|
||||
path = "/uploads/#{sha1[0]}/#{sha1[1]}"
|
||||
location = "#{SiteSetting.s3_upload_bucket}#{path}"
|
||||
directory = fog.directories.create(key: location)
|
||||
|
||||
Rails.logger.info "#{blob.size.inspect}"
|
||||
file = directory.files.create(key: remote_filename,
|
||||
body: tempfile,
|
||||
public: true,
|
||||
content_type: file.content_type)
|
||||
upload.width, upload.height = ImageSizer.resize(*image_info.size)
|
||||
upload.url = "#{Rails.configuration.action_controller.asset_host}#{path}/#{remote_filename}"
|
||||
upload.save
|
||||
|
||||
upload
|
||||
end
|
||||
|
||||
end
|
||||
451
app/models/user.rb
Normal file
451
app/models/user.rb
Normal file
@@ -0,0 +1,451 @@
|
||||
require_dependency 'email_token'
|
||||
require_dependency 'trust_level'
|
||||
require_dependency 'sql_builder'
|
||||
|
||||
class User < ActiveRecord::Base
|
||||
|
||||
attr_accessible :name, :username, :password, :email, :bio_raw, :website
|
||||
|
||||
has_many :posts
|
||||
has_many :notifications
|
||||
has_many :topic_users
|
||||
has_many :topics
|
||||
has_many :user_open_ids
|
||||
has_many :user_actions
|
||||
has_many :post_actions
|
||||
has_many :email_logs
|
||||
has_many :post_timings
|
||||
has_many :topic_allowed_users
|
||||
has_many :topics_allowed, through: :topic_allowed_users, source: :topic
|
||||
has_many :email_tokens
|
||||
has_many :views
|
||||
has_many :user_visits
|
||||
has_many :invites
|
||||
has_one :twitter_user_info
|
||||
belongs_to :approved_by, class_name: 'User'
|
||||
|
||||
validates_presence_of :username
|
||||
validates_presence_of :email
|
||||
validates_uniqueness_of :email
|
||||
validate :username_validator
|
||||
validate :password_validator
|
||||
|
||||
before_save :cook
|
||||
before_save :update_username_lower
|
||||
before_save :ensure_password_is_hashed
|
||||
after_initialize :add_trust_level
|
||||
|
||||
after_save :update_tracked_topics
|
||||
|
||||
after_create :create_email_token
|
||||
|
||||
# Whether we need to be sending a system message after creation
|
||||
attr_accessor :send_welcome_message
|
||||
|
||||
# This is just used to pass some information into the serializer
|
||||
attr_accessor :notification_channel_position
|
||||
|
||||
def self.username_length
|
||||
3..15
|
||||
end
|
||||
|
||||
def self.suggest_username(name)
|
||||
# If it's an email
|
||||
if name =~ /([^@]+)@([^\.]+)/
|
||||
name = Regexp.last_match[1]
|
||||
|
||||
# Special case, if it's me @ something, take the something.
|
||||
name = Regexp.last_match[2] if name == 'me'
|
||||
end
|
||||
|
||||
name.gsub!(/^[^A-Za-z0-9]+/, "")
|
||||
name.gsub!(/[^A-Za-z0-9_]+$/, "")
|
||||
name.gsub!(/[^A-Za-z0-9_]+/, "_")
|
||||
|
||||
# Pad the length with 1s
|
||||
missing_chars = User.username_length.begin - name.length
|
||||
name << ('1' * missing_chars) if missing_chars > 0
|
||||
|
||||
# Trim extra length
|
||||
name = name[0..User.username_length.end-1]
|
||||
|
||||
i = 1
|
||||
attempt = name
|
||||
while !username_available?(attempt)
|
||||
suffix = i.to_s
|
||||
max_length = User.username_length.end - 1 - suffix.length
|
||||
attempt = "#{name[0..max_length]}#{suffix}"
|
||||
i+=1
|
||||
end
|
||||
attempt
|
||||
end
|
||||
|
||||
def self.create_for_email(email, opts={})
|
||||
username = suggest_username(email)
|
||||
|
||||
if SiteSetting.call_mothership?
|
||||
begin
|
||||
match, available, suggestion = Mothership.nickname_match?( username, email )
|
||||
username = suggestion unless match or available
|
||||
rescue => e
|
||||
Rails.logger.error e.message + "\n" + e.backtrace.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
user = User.new(email: email, username: username, name: username)
|
||||
user.trust_level = opts[:trust_level] if opts[:trust_level].present?
|
||||
user.save!
|
||||
|
||||
if SiteSetting.call_mothership?
|
||||
begin
|
||||
Mothership.register_nickname( username, email )
|
||||
rescue => e
|
||||
Rails.logger.error e.message + "\n" + e.backtrace.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def self.username_available?(username)
|
||||
lower = username.downcase
|
||||
!User.where(username_lower: lower).exists?
|
||||
end
|
||||
|
||||
def enqueue_welcome_message(message_type)
|
||||
return unless SiteSetting.send_welcome_message?
|
||||
Jobs.enqueue(:send_system_message, user_id: self.id, message_type: message_type)
|
||||
end
|
||||
|
||||
def self.suggest_name(email)
|
||||
return "" unless email
|
||||
name = email.split(/[@\+]/)[0]
|
||||
name = name.sub(".", " ")
|
||||
name.split(" ").collect{|word| word[0] = word[0].upcase; word}.join(" ")
|
||||
end
|
||||
|
||||
def change_username(new_username)
|
||||
self.username = new_username
|
||||
|
||||
if SiteSetting.call_mothership? and self.valid?
|
||||
begin
|
||||
Mothership.register_nickname( self.username, self.email )
|
||||
rescue Mothership::NicknameUnavailable
|
||||
return false
|
||||
rescue => e
|
||||
Rails.logger.error e.message + "\n" + e.backtrace.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
self.save
|
||||
end
|
||||
|
||||
# Use a temporary key to find this user, store it in redis with an expiry
|
||||
def temporary_key
|
||||
key = SecureRandom.hex(32)
|
||||
$redis.setex "temporary_key:#{key}", 1.week, id.to_s
|
||||
key
|
||||
end
|
||||
|
||||
# Find a user by temporary key, nil if not found or key is invalid
|
||||
def self.find_by_temporary_key(key)
|
||||
user_id = $redis.get("temporary_key:#{key}")
|
||||
if user_id.present?
|
||||
User.where(id: user_id.to_i).first
|
||||
end
|
||||
end
|
||||
|
||||
# tricky, we need our bus to be subscribed from the right spot
|
||||
def sync_notification_channel_position
|
||||
@unread_notifications_by_type = nil
|
||||
self.notification_channel_position = MessageBus.last_id('/notification')
|
||||
end
|
||||
|
||||
def invited_by
|
||||
used_invite = invites.where("redeemed_at is not null").includes(:invited_by).first
|
||||
return nil unless used_invite.present?
|
||||
used_invite.invited_by
|
||||
end
|
||||
|
||||
# Approve this user
|
||||
def approve(approved_by)
|
||||
self.approved = true
|
||||
self.approved_by = approved_by
|
||||
self.approved_at = Time.now
|
||||
enqueue_welcome_message('welcome_approved') if save
|
||||
end
|
||||
|
||||
def self.email_hash(email)
|
||||
Digest::MD5.hexdigest(email)
|
||||
end
|
||||
|
||||
def email_hash
|
||||
User.email_hash(self.email)
|
||||
end
|
||||
|
||||
def unread_notifications_by_type
|
||||
@unread_notifications_by_type ||= notifications.where("id > ? and read = false", seen_notification_id).group(:notification_type).count
|
||||
end
|
||||
|
||||
def reload
|
||||
@unread_notifications_by_type = nil
|
||||
super
|
||||
end
|
||||
|
||||
|
||||
def unread_private_messages
|
||||
return 0 if unread_notifications_by_type.blank?
|
||||
return unread_notifications_by_type[Notification.Types[:private_message]] || 0
|
||||
end
|
||||
|
||||
def unread_notifications
|
||||
result = 0
|
||||
unread_notifications_by_type.each do |k,v|
|
||||
result += v unless k == Notification.Types[:private_message]
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def saw_notification_id(notification_id)
|
||||
User.update_all ["seen_notification_id = ?", notification_id], ["seen_notification_id < ?", notification_id]
|
||||
end
|
||||
|
||||
def publish_notifications_state
|
||||
MessageBus.publish("/notification",
|
||||
{unread_notifications: self.unread_notifications,
|
||||
unread_private_messages: self.unread_private_messages},
|
||||
user_ids: [self.id] # only publish the notification to this user
|
||||
)
|
||||
end
|
||||
|
||||
# A selection of people to autocomplete on @mention
|
||||
def self.mentionable_usernames
|
||||
User.select(:username).order('last_posted_at desc').limit(20)
|
||||
end
|
||||
|
||||
def regular?
|
||||
(not admin?) and (not has_trust_level?(:moderator))
|
||||
end
|
||||
|
||||
def password=(password)
|
||||
# special case for passwordless accounts
|
||||
unless password.blank?
|
||||
@raw_password = password
|
||||
end
|
||||
end
|
||||
|
||||
def confirm_password?(password)
|
||||
return false unless self.password_hash && self.salt
|
||||
self.password_hash == hash_password(password,self.salt)
|
||||
end
|
||||
|
||||
def update_last_seen!
|
||||
now = DateTime.now
|
||||
now_date = now.to_date
|
||||
|
||||
# Only update last seen once every minute
|
||||
redis_key = "user:#{self.id}:#{now_date.to_s}"
|
||||
if $redis.setnx(redis_key, "1")
|
||||
$redis.expire(redis_key, SiteSetting.active_user_rate_limit_secs)
|
||||
|
||||
if self.last_seen_at.nil? || self.last_seen_at.to_date < now_date
|
||||
# count it
|
||||
row_count = User.exec_sql('insert into user_visits(user_id,visited_at) select :user_id, :visited_at
|
||||
where not exists(select 1 from user_visits where user_id = :user_id and visited_at = :visited_at)', user_id: self.id, visited_at: now.to_date)
|
||||
if row_count.cmd_tuples == 1
|
||||
User.update_all "days_visited = days_visited + 1", ["id = ? and days_visited = ?", self.id, self.days_visited]
|
||||
end
|
||||
end
|
||||
|
||||
# using a builder to avoid the AR transaction
|
||||
sql = SqlBuilder.new "update users /*set*/ where id = :id"
|
||||
# Keep track of our last visit
|
||||
if self.last_seen_at.present? and (self.last_seen_at < (now - SiteSetting.previous_visit_timeout_hours.hours))
|
||||
self.previous_visit_at = self.last_seen_at
|
||||
sql.set('previous_visit_at = :prev', prev: self.previous_visit_at)
|
||||
end
|
||||
self.last_seen_at = now
|
||||
sql.set('last_seen_at = :last', last: self.last_seen_at)
|
||||
sql.exec(id: self.id)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def self.avatar_template(email)
|
||||
email_hash = self.email_hash(email)
|
||||
# robohash was possibly causing caching issues
|
||||
# robohash = CGI.escape("http://robohash.org/size_") << "{size}x{size}" << CGI.escape("/#{email_hash}.png")
|
||||
"http://www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon"
|
||||
end
|
||||
|
||||
# return null for local avatars, a template for gravatar
|
||||
def avatar_template
|
||||
# robohash = CGI.escape("http://robohash.org/size_") << "{size}x{size}" << CGI.escape("/#{email_hash}.png")
|
||||
"http://www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon"
|
||||
end
|
||||
|
||||
|
||||
# Updates the denormalized view counts for all users
|
||||
def self.update_view_counts
|
||||
|
||||
# Update denormalized topics_entered
|
||||
exec_sql "UPDATE users SET topics_entered = x.c
|
||||
FROM
|
||||
(SELECT v.user_id,
|
||||
COUNT(DISTINCT parent_id) AS c
|
||||
FROM views AS v
|
||||
WHERE parent_type = 'Topic'
|
||||
GROUP BY v.user_id) AS X
|
||||
WHERE x.user_id = users.id"
|
||||
|
||||
# Update denormalzied posts_read_count
|
||||
exec_sql "UPDATE users SET posts_read_count = x.c
|
||||
FROM
|
||||
(SELECT pt.user_id,
|
||||
COUNT(*) AS c
|
||||
FROM post_timings AS pt
|
||||
GROUP BY pt.user_id) AS X
|
||||
WHERE x.user_id = users.id"
|
||||
|
||||
end
|
||||
|
||||
# The following count methods are somewhat slow - definitely don't use them in a loop.
|
||||
# They might need to be denormialzied
|
||||
def like_count
|
||||
UserAction.where(user_id: self.id, action_type: UserAction::WAS_LIKED).count
|
||||
end
|
||||
|
||||
def post_count
|
||||
posts.count
|
||||
end
|
||||
|
||||
def flags_given_count
|
||||
PostAction.where(user_id: self.id, post_action_type_id: PostActionType.FlagTypes).count
|
||||
end
|
||||
|
||||
def flags_received_count
|
||||
posts.includes(:post_actions).where('post_actions.post_action_type_id in (?)', PostActionType.FlagTypes).count
|
||||
end
|
||||
|
||||
def private_topics_count
|
||||
topics_allowed.where(archetype: Archetype.private_message).count
|
||||
end
|
||||
|
||||
def bio_excerpt
|
||||
PrettyText.excerpt(bio_cooked, 350)
|
||||
end
|
||||
|
||||
def is_banned?
|
||||
!banned_till.nil? && banned_till > DateTime.now
|
||||
end
|
||||
|
||||
# Use this helper to determine if the user has a particular trust level.
|
||||
# Takes into account admin, etc.
|
||||
def has_trust_level?(level)
|
||||
raise "Invalid trust level #{level}" unless TrustLevel.Levels.has_key?(level)
|
||||
|
||||
# Admins can do everything
|
||||
return true if admin?
|
||||
|
||||
# Otherwise compare levels
|
||||
(self.trust_level || TrustLevel.Levels[:new]) >= TrustLevel.Levels[level]
|
||||
end
|
||||
|
||||
def guardian
|
||||
Guardian.new(self)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def cook
|
||||
if self.bio_raw.present?
|
||||
self.bio_cooked = PrettyText.cook(bio_raw) if bio_raw_changed?
|
||||
else
|
||||
self.bio_cooked = nil
|
||||
end
|
||||
end
|
||||
|
||||
def update_tracked_topics
|
||||
if self.auto_track_topics_after_msecs_changed?
|
||||
|
||||
if auto_track_topics_after_msecs < 0
|
||||
|
||||
User.exec_sql('update topic_users set notification_level = ?
|
||||
where notifications_reason_id is null and
|
||||
user_id = ?' , TopicUser::NotificationLevel::REGULAR , self.id)
|
||||
else
|
||||
|
||||
User.exec_sql('update topic_users set notification_level = ?
|
||||
where notifications_reason_id is null and
|
||||
user_id = ? and
|
||||
total_msecs_viewed < ?' , TopicUser::NotificationLevel::REGULAR , self.id, auto_track_topics_after_msecs)
|
||||
|
||||
User.exec_sql('update topic_users set notification_level = ?
|
||||
where notifications_reason_id is null and
|
||||
user_id = ? and
|
||||
total_msecs_viewed >= ?' , TopicUser::NotificationLevel::TRACKING , self.id, auto_track_topics_after_msecs)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def create_email_token
|
||||
email_tokens.create(email: self.email)
|
||||
end
|
||||
|
||||
def ensure_password_is_hashed
|
||||
if @raw_password
|
||||
self.salt = SecureRandom.hex(16)
|
||||
self.password_hash = hash_password(@raw_password, salt)
|
||||
end
|
||||
end
|
||||
|
||||
def hash_password(password, salt)
|
||||
PBKDF2.new(:password => password, :salt => salt, :iterations => Rails.configuration.pbkdf2_iterations).hex_string
|
||||
end
|
||||
|
||||
def add_trust_level
|
||||
self.trust_level ||= SiteSetting.default_trust_level
|
||||
rescue ActiveModel::MissingAttributeError
|
||||
# Ignore it, safely - see http://www.tatvartha.com/2011/03/activerecordmissingattributeerror-missing-attribute-a-bug-or-a-features/
|
||||
end
|
||||
|
||||
def update_username_lower
|
||||
self.username_lower = username.downcase
|
||||
end
|
||||
|
||||
def password_validator
|
||||
if @raw_password
|
||||
return errors.add(:password, "must be 6 letters or longer") if @raw_password.length < 6
|
||||
end
|
||||
end
|
||||
|
||||
def username_validator
|
||||
unless username
|
||||
return errors.add(:username, I18n.t(:'user.username.blank'))
|
||||
end
|
||||
|
||||
if username.length < User.username_length.begin
|
||||
return errors.add(:username, I18n.t(:'user.username.short', min: User.username_length.begin))
|
||||
end
|
||||
|
||||
if username.length > User.username_length.end
|
||||
return errors.add(:username, I18n.t(:'user.username.long', max: User.username_length.end))
|
||||
end
|
||||
|
||||
if username =~ /[^A-Za-z0-9_]/
|
||||
return errors.add(:username, I18n.t(:'user.username.characters'))
|
||||
end
|
||||
|
||||
if username[0,1] =~ /[^A-Za-z0-9]/
|
||||
return errors.add(:username, I18n.t(:'user.username.must_begin_with_alphanumeric'))
|
||||
end
|
||||
|
||||
lower = username.downcase
|
||||
if username_changed? && User.where(username_lower: lower).exists?
|
||||
return errors.add(:username, I18n.t(:'user.username.unique'))
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
213
app/models/user_action.rb
Normal file
213
app/models/user_action.rb
Normal file
@@ -0,0 +1,213 @@
|
||||
require_dependency 'message_bus'
|
||||
require_dependency 'sql_builder'
|
||||
|
||||
class UserAction < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
attr_accessible :acting_user_id, :action_type, :target_topic_id, :target_post_id, :target_user_id, :user_id
|
||||
|
||||
validates_presence_of :action_type
|
||||
validates_presence_of :user_id
|
||||
|
||||
LIKE = 1
|
||||
WAS_LIKED = 2
|
||||
BOOKMARK = 3
|
||||
NEW_TOPIC = 4
|
||||
POST = 5
|
||||
RESPONSE= 6
|
||||
MENTION = 7
|
||||
TOPIC_RESPONSE = 8
|
||||
QUOTE = 9
|
||||
STAR = 10
|
||||
EDIT = 11
|
||||
NEW_PRIVATE_MESSAGE = 12
|
||||
GOT_PRIVATE_MESSAGE = 13
|
||||
|
||||
ORDER = Hash[*[
|
||||
NEW_PRIVATE_MESSAGE,
|
||||
GOT_PRIVATE_MESSAGE,
|
||||
BOOKMARK,
|
||||
NEW_TOPIC,
|
||||
POST,
|
||||
RESPONSE,
|
||||
TOPIC_RESPONSE,
|
||||
LIKE,
|
||||
WAS_LIKED,
|
||||
MENTION,
|
||||
QUOTE,
|
||||
STAR,
|
||||
EDIT
|
||||
].each_with_index.to_a.flatten]
|
||||
|
||||
def self.stats(user_id, guardian)
|
||||
sql = <<SQL
|
||||
select action_type, count(*) count
|
||||
from user_actions
|
||||
where user_id = ?
|
||||
group by action_type
|
||||
SQL
|
||||
|
||||
results = self.exec_sql(sql, user_id).to_a
|
||||
|
||||
# should push this into the sql at some point, but its simple enough for now
|
||||
unless guardian.can_see_private_messages?(user_id)
|
||||
results.reject!{|a| [GOT_PRIVATE_MESSAGE, NEW_PRIVATE_MESSAGE].include?(a["action_type"].to_i)}
|
||||
end
|
||||
|
||||
results.sort!{|a,b| ORDER[a["action_type"].to_i] <=> ORDER[b["action_type"].to_i]}
|
||||
results.each do |row|
|
||||
row["description"] = self.description(row["action_type"], detailed: true)
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def self.stream_item(action_id, guardian)
|
||||
stream(action_id:action_id, guardian: guardian)[0]
|
||||
end
|
||||
|
||||
def self.stream(opts={})
|
||||
user_id = opts[:user_id]
|
||||
offset = opts[:offset]||0
|
||||
limit = opts[:limit] ||60
|
||||
action_id = opts[:action_id]
|
||||
action_types = opts[:action_types]
|
||||
guardian = opts[:guardian]
|
||||
ignore_private_messages = opts[:ignore_private_messages]
|
||||
|
||||
builder = SqlBuilder.new("
|
||||
select t.title, a.action_type, a.created_at,
|
||||
t.id topic_id, coalesce(p.post_number, 1) post_number, u.email ,u.username, u.name, u.id user_id, coalesce(p.cooked, p2.cooked) cooked
|
||||
from user_actions as a
|
||||
join topics t on t.id = a.target_topic_id
|
||||
left join posts p on p.id = a.target_post_id
|
||||
left join users u on u.id = a.acting_user_id
|
||||
left join posts p2 on p2.topic_id = a.target_topic_id and p2.post_number = 1
|
||||
/*where*/
|
||||
/*order_by*/
|
||||
/*offset*/
|
||||
/*limit*/
|
||||
")
|
||||
|
||||
unless guardian.can_see_deleted_posts?
|
||||
builder.where("p.deleted_at is null and p2.deleted_at is null")
|
||||
end
|
||||
|
||||
if !guardian.can_see_private_messages?(user_id) || ignore_private_messages
|
||||
builder.where("a.action_type not in (#{NEW_PRIVATE_MESSAGE},#{GOT_PRIVATE_MESSAGE})")
|
||||
end
|
||||
|
||||
if action_id
|
||||
builder.where("a.id = :id", id: action_id.to_i)
|
||||
data = builder.exec.to_a
|
||||
else
|
||||
builder.where("a.user_id = :user_id", user_id: user_id.to_i)
|
||||
builder.where("a.action_type in (:action_types)", action_types: action_types) if action_types && action_types.length > 0
|
||||
builder.order_by("a.created_at desc")
|
||||
builder.offset(offset.to_i)
|
||||
builder.limit(limit.to_i)
|
||||
data = builder.exec.to_a
|
||||
end
|
||||
|
||||
data.each do |row|
|
||||
row["description"] = self.description(row["action_type"])
|
||||
row["created_at"] = DateTime.parse(row["created_at"])
|
||||
# we should probably cache the excerpts in the db at some point
|
||||
row["excerpt"] = PrettyText.excerpt(row["cooked"],300) if row["cooked"]
|
||||
row["cooked"] = nil
|
||||
row["avatar_template"] = User.avatar_template(row["email"])
|
||||
row.delete("email")
|
||||
row["slug"] = Slug.for(row["title"])
|
||||
end
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def self.description(row, opts = {})
|
||||
t = I18n.t('user_action_descriptions')
|
||||
if opts[:detailed]
|
||||
# will localize as soon as we stablize the names here
|
||||
desc = case row.to_i
|
||||
when BOOKMARK
|
||||
t[:bookmarks]
|
||||
when NEW_TOPIC
|
||||
t[:topics]
|
||||
when WAS_LIKED
|
||||
t[:likes_received]
|
||||
when LIKE
|
||||
t[:likes_given]
|
||||
when RESPONSE
|
||||
t[:responses]
|
||||
when TOPIC_RESPONSE
|
||||
t[:topic_responses]
|
||||
when POST
|
||||
t[:posts]
|
||||
when MENTION
|
||||
t[:mentions]
|
||||
when QUOTE
|
||||
t[:quotes]
|
||||
when EDIT
|
||||
t[:edits]
|
||||
when STAR
|
||||
t[:favorites]
|
||||
when NEW_PRIVATE_MESSAGE
|
||||
t[:sent_items]
|
||||
when GOT_PRIVATE_MESSAGE
|
||||
t[:inbox]
|
||||
end
|
||||
else
|
||||
desc =
|
||||
case row.to_i
|
||||
when NEW_TOPIC
|
||||
then t[:posted]
|
||||
when LIKE,WAS_LIKED
|
||||
then t[:liked]
|
||||
when RESPONSE, TOPIC_RESPONSE,POST
|
||||
then t[:responded_to]
|
||||
when BOOKMARK
|
||||
then t[:bookmarked]
|
||||
when MENTION
|
||||
then t[:mentioned]
|
||||
when QUOTE
|
||||
then t[:quoted]
|
||||
when STAR
|
||||
then t[:favorited]
|
||||
when EDIT
|
||||
then t[:edited]
|
||||
end
|
||||
end
|
||||
desc
|
||||
end
|
||||
|
||||
def self.log_action!(hash)
|
||||
require_parameters(hash, :action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id)
|
||||
transaction(requires_new: true) do
|
||||
begin
|
||||
action = self.new(hash)
|
||||
|
||||
if hash[:created_at]
|
||||
action.created_at = hash[:created_at]
|
||||
end
|
||||
action.save!
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# can happen, don't care already logged
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.remove_action!(hash)
|
||||
require_parameters(hash, :action_type, :user_id, :acting_user_id, :target_topic_id, :target_post_id)
|
||||
if action = UserAction.where(hash).first
|
||||
action.destroy
|
||||
MessageBus.publish("/user/#{hash[:user_id]}", {user_action_id: action.id, remove: true})
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
def self.require_parameters(data, *params)
|
||||
params.each do |p|
|
||||
raise Discourse::InvalidParameters.new(p) if data[p].nil?
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
189
app/models/user_action_observer.rb
Normal file
189
app/models/user_action_observer.rb
Normal file
@@ -0,0 +1,189 @@
|
||||
class UserActionObserver < ActiveRecord::Observer
|
||||
observe :post_action, :topic, :post, :notification, :topic_user
|
||||
|
||||
|
||||
def after_save(model)
|
||||
case
|
||||
when (model.is_a?(PostAction) and (model.is_bookmark? or model.is_like?))
|
||||
log_post_action(model)
|
||||
when (model.is_a?(Topic))
|
||||
log_topic(model)
|
||||
when (model.is_a?(Post))
|
||||
log_post(model)
|
||||
when (model.is_a?(Notification))
|
||||
log_notification(model)
|
||||
when (model.is_a?(TopicUser))
|
||||
log_topic_user(model)
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def log_topic_user(model)
|
||||
action = UserAction::STAR
|
||||
|
||||
row = {
|
||||
action_type: action,
|
||||
user_id: model.user_id,
|
||||
acting_user_id: model.user_id,
|
||||
target_topic_id: model.topic_id,
|
||||
target_post_id: -1,
|
||||
created_at: model.starred_at
|
||||
}
|
||||
|
||||
if model.starred
|
||||
UserAction.log_action!(row)
|
||||
else
|
||||
UserAction.remove_action!(row)
|
||||
end
|
||||
end
|
||||
|
||||
def log_notification(model)
|
||||
|
||||
action =
|
||||
case model.notification_type
|
||||
when Notification.Types[:quoted]
|
||||
UserAction::QUOTE
|
||||
when Notification.Types[:replied]
|
||||
UserAction::RESPONSE
|
||||
when Notification.Types[:mentioned]
|
||||
UserAction::MENTION
|
||||
when Notification.Types[:edited]
|
||||
UserAction::EDIT
|
||||
end
|
||||
|
||||
# like is skipped
|
||||
return unless action
|
||||
|
||||
post = Post.where(post_number: model.post_number, topic_id: model.topic_id).first
|
||||
|
||||
# stray data
|
||||
return unless post
|
||||
|
||||
row = {
|
||||
action_type: action,
|
||||
user_id: model.user_id,
|
||||
acting_user_id: (action == UserAction::EDIT) ? post.last_editor_id : post.user_id,
|
||||
target_topic_id: model.topic_id,
|
||||
target_post_id: post.id,
|
||||
created_at: model.created_at
|
||||
}
|
||||
|
||||
if post.deleted_at.nil?
|
||||
UserAction.log_action!(row)
|
||||
else
|
||||
UserAction.remove_action!(row)
|
||||
end
|
||||
end
|
||||
|
||||
def log_post(model)
|
||||
|
||||
# first post gets nada
|
||||
return if model.post_number == 1
|
||||
|
||||
|
||||
row = {
|
||||
action_type: UserAction::POST,
|
||||
user_id: model.user_id,
|
||||
acting_user_id: model.user_id,
|
||||
target_post_id: model.id,
|
||||
target_topic_id: model.topic_id,
|
||||
created_at: model.created_at
|
||||
}
|
||||
|
||||
rows = [row]
|
||||
|
||||
if model.topic.private_message?
|
||||
rows = []
|
||||
model.topic.topic_allowed_users.each do |ta|
|
||||
row = row.dup
|
||||
row[:user_id] = ta.user_id
|
||||
row[:action_type] = ta.user_id == model.user_id ? UserAction::NEW_PRIVATE_MESSAGE : UserAction::GOT_PRIVATE_MESSAGE
|
||||
rows << row
|
||||
end
|
||||
end
|
||||
|
||||
rows.each do |row|
|
||||
if model.deleted_at.nil?
|
||||
UserAction.log_action!(row)
|
||||
else
|
||||
UserAction.remove_action!(row)
|
||||
end
|
||||
end
|
||||
|
||||
return if model.topic.private_message?
|
||||
|
||||
# a bit odd but we may have stray records
|
||||
if model.topic and model.topic.user_id != model.user_id
|
||||
row[:action_type] = UserAction::TOPIC_RESPONSE
|
||||
row[:user_id] = model.topic.user_id
|
||||
|
||||
if model.deleted_at.nil?
|
||||
UserAction.log_action!(row)
|
||||
else
|
||||
UserAction.remove_action!(row)
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def log_topic(model)
|
||||
row = {
|
||||
action_type: model.archetype == Archetype.private_message ? UserAction::NEW_PRIVATE_MESSAGE : UserAction::NEW_TOPIC,
|
||||
user_id: model.user_id,
|
||||
acting_user_id: model.user_id,
|
||||
target_topic_id: model.id,
|
||||
target_post_id: -1,
|
||||
created_at: model.created_at
|
||||
}
|
||||
|
||||
rows = [row]
|
||||
|
||||
if model.private_message?
|
||||
model.topic_allowed_users.reject{|a| a.user_id == model.user_id}.each do |ta|
|
||||
row = row.dup
|
||||
row[:user_id] = ta.user_id
|
||||
row[:action_type] = UserAction::GOT_PRIVATE_MESSAGE
|
||||
rows << row
|
||||
end
|
||||
end
|
||||
|
||||
rows.each do |row|
|
||||
if model.deleted_at.nil?
|
||||
UserAction.log_action!(row)
|
||||
else
|
||||
UserAction.remove_action!(row)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def log_post_action(model)
|
||||
action = UserAction::BOOKMARK if model.is_bookmark?
|
||||
action = UserAction::LIKE if model.is_like?
|
||||
|
||||
row = {
|
||||
action_type: action,
|
||||
user_id: model.user_id,
|
||||
acting_user_id: model.user_id,
|
||||
target_post_id: model.post_id,
|
||||
target_topic_id: model.post.topic_id,
|
||||
created_at: model.created_at
|
||||
}
|
||||
|
||||
if model.deleted_at.nil?
|
||||
UserAction.log_action!(row)
|
||||
else
|
||||
UserAction.remove_action!(row)
|
||||
end
|
||||
|
||||
if model.is_like?
|
||||
row[:action_type] = UserAction::WAS_LIKED
|
||||
row[:user_id] = model.post.user_id
|
||||
if model.deleted_at.nil?
|
||||
UserAction.log_action!(row)
|
||||
else
|
||||
UserAction.remove_action!(row)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
50
app/models/user_email_observer.rb
Normal file
50
app/models/user_email_observer.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
class UserEmailObserver < ActiveRecord::Observer
|
||||
observe :notification
|
||||
|
||||
def after_commit(notification)
|
||||
if notification.send(:transaction_include_action?, :create)
|
||||
notification_type = Notification.InvertedTypes[notification.notification_type]
|
||||
|
||||
# Delegate to email_user_{{NOTIFICATION_TYPE}} if exists
|
||||
email_method = :"email_user_#{notification_type.to_s}"
|
||||
send(email_method, notification) if respond_to?(email_method)
|
||||
end
|
||||
end
|
||||
|
||||
def email_user_mentioned(notification)
|
||||
return unless notification.user.email_direct?
|
||||
Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes,
|
||||
:user_email,
|
||||
type: :user_mentioned,
|
||||
user_id: notification.user_id,
|
||||
notification_id: notification.id)
|
||||
end
|
||||
|
||||
def email_user_quoted(notification)
|
||||
return unless notification.user.email_direct?
|
||||
Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes,
|
||||
:user_email,
|
||||
type: :user_quoted,
|
||||
user_id: notification.user_id,
|
||||
notification_id: notification.id)
|
||||
end
|
||||
|
||||
def email_user_replied(notification)
|
||||
return unless notification.user.email_direct?
|
||||
Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes,
|
||||
:user_email,
|
||||
type: :user_replied,
|
||||
user_id: notification.user_id,
|
||||
notification_id: notification.id)
|
||||
end
|
||||
|
||||
def email_user_invited_to_private_message(notification)
|
||||
return unless notification.user.email_direct?
|
||||
Jobs.enqueue_in(SiteSetting.email_time_window_mins.minutes,
|
||||
:user_email,
|
||||
type: :user_invited_to_private_message,
|
||||
user_id: notification.user_id,
|
||||
notification_id: notification.id)
|
||||
end
|
||||
|
||||
end
|
||||
8
app/models/user_open_id.rb
Normal file
8
app/models/user_open_id.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class UserOpenId < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
attr_accessible :email, :url, :user_id, :active
|
||||
|
||||
validates_presence_of :email
|
||||
validates_presence_of :url
|
||||
|
||||
end
|
||||
3
app/models/user_visit.rb
Normal file
3
app/models/user_visit.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class UserVisit < ActiveRecord::Base
|
||||
attr_accessible :visited_at, :user_id
|
||||
end
|
||||
36
app/models/view.rb
Normal file
36
app/models/view.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
require 'ipaddr'
|
||||
|
||||
class View < ActiveRecord::Base
|
||||
|
||||
belongs_to :parent, polymorphic: true
|
||||
belongs_to :user
|
||||
validates_presence_of :parent_type, :parent_id, :ip, :viewed_at
|
||||
|
||||
# TODO: This could happen asyncronously
|
||||
def self.create_for(parent, ip, user=nil)
|
||||
|
||||
# Only store a view once per day per thing per user per ip
|
||||
redis_key = "view:#{parent.class.name}:#{parent.id}:#{Date.today.to_s}"
|
||||
if user.present?
|
||||
redis_key << ":user-#{user.id}"
|
||||
else
|
||||
redis_key << ":ip-#{ip}"
|
||||
end
|
||||
|
||||
if $redis.setnx(redis_key, "1")
|
||||
$redis.expire(redis_key, 1.day.to_i)
|
||||
|
||||
View.transaction do
|
||||
view = View.create(parent: parent, ip: IPAddr.new(ip).to_i, viewed_at: Date.today, user: user)
|
||||
|
||||
# Update the views count in the parent, if it exists.
|
||||
if parent.respond_to?(:views)
|
||||
parent.class.update_all 'views = views + 1', ['id = ?', parent.id]
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
Reference in New Issue
Block a user