Initial release of Discourse

This commit is contained in:
Robin Ward
2013-02-05 14:16:51 -05:00
commit 21b5628528
2932 changed files with 143949 additions and 0 deletions

0
app/models/.gitkeep Normal file
View File

86
app/models/category.rb Normal file
View 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

View 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

View 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

View 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
View 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

View 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
View 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
View 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
View 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

View 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

View 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
View 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

View 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

View 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

View 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

View 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
View 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
View 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

View 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

View 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

View 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
View 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
View 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

View 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
View 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

View 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
View 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
View 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

View 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

View 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
View 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

View 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
View 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

View 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
View 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

View File

@@ -0,0 +1,3 @@
class TwitterUserInfo < ActiveRecord::Base
belongs_to :user
end

98
app/models/upload.rb Normal file
View 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
View 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
View 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

View 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

View 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

View 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
View File

@@ -0,0 +1,3 @@
class UserVisit < ActiveRecord::Base
attr_accessible :visited_at, :user_id
end

36
app/models/view.rb Normal file
View 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