Permalink
Newer
100644
945 lines (795 sloc)
25.9 KB
14
:autosave => true,
15
:dependent => :destroy
16
has_many :suggested_taggings, :dependent => :destroy
17
has_many :suggested_titles, :dependent => :destroy
26
has_many :hidings, :class_name => 'HiddenStory', :inverse_of => :story, :dependent => :destroy
27
has_many :savings, :class_name => 'SavedStory', :inverse_of => :story, :dependent => :destroy
30
scope :deleted, -> { where(is_expired: true) }
31
scope :not_deleted, -> { where(is_expired: false) }
34
scope :low_scoring, ->(max = 5) { where("#{Story.score_sql} < ?", max) }
35
scope :hottest, ->(user = nil, exclude_tags = nil) {
37
.filter_tags(exclude_tags || [])
38
.positive_ranked
39
.order('hotness')
40
}
41
scope :recent, ->(user = nil, exclude_tags = nil) {
42
base.low_scoring
43
.not_hidden_by(user)
44
.filter_tags(exclude_tags || [])
45
.where("created_at >= ?", 10.days.ago)
46
.order("stories.created_at DESC")
47
}
50
Tagging.select('TRUE').where('taggings.story_id = stories.id').where(tag_id: tags).exists
51
)
52
}
53
scope :filter_tags_for, ->(user) {
54
user.nil? ? all : where.not(
55
Tagging.joins(tag: :tag_filters)
56
.select('TRUE')
57
.where('taggings.story_id = stories.id')
58
.where(tag_filters: { user_id: user }).exists
59
)
60
}
61
scope :hidden_by, ->(user) {
62
user.nil? ? none : joins(:hidings).merge(HiddenStory.by(user))
63
}
64
scope :not_hidden_by, ->(user) {
65
user.nil? ? all : where.not(
66
HiddenStory.select('TRUE')
67
.where(Arel.sql('hidden_stories.story_id = stories.id'))
68
.by(user)
69
.exists
72
scope :saved_by, ->(user) {
73
user.nil? ? none : joins(:savings).merge(SavedStory.by(user))
74
}
75
scope :to_tweet, -> {
76
hottest(nil, Tag.where(tag: 'meta').pluck(:id))
77
.where(twitter_id: nil)
78
.where("#{Story.score_sql} >= 2")
79
.where("created_at >= ?", 2.days.ago)
80
.limit(10)
81
}
83
validates :title, length: { :in => 3..150 }
84
validates :description, length: { :maximum => (64 * 1024) }
85
validates :url, length: { :maximum => 250, :allow_nil => true }
86
validates :user_id, presence: true
89
if value.to_i == record.id
90
record.errors.add(:merge_story_short_id, "id cannot be itself.")
91
end
92
end
93
108
# let a hot story linger for this many seconds
109
HOTNESS_WINDOW = 60 * 60 * 22
110
111
# drop these words from titles when making URLs
112
TITLE_DROP_WORDS = ["", "a", "an", "and", "but", "in", "of", "or", "that", "the", "to"].freeze
113
114
# link shortening and other ad tracking domains
115
TRACKING_DOMAINS = %w{ 1url.com 7.ly adf.ly al.ly bc.vc bit.do bit.ly
116
bitly.com buzurl.com cur.lv cutt.us db.tt db.tt doiop.com filoops.info
117
goo.gl is.gd ity.im j.mp lnkd.in ow.ly ph.dog po.st prettylinkpro.com q.gs
118
qr.ae qr.net scrnch.me s.id sptfy.com t.co tinyarrows.com tiny.cc
119
tinyurl.com tny.im tr.im tweez.md twitthis.com u.bb u.to v.gd vzturl.com
123
URL_RE = /\A(?<protocol>https?):\/\/(?<domain>([^\.\/]+\.)+[a-z]+)(?<port>:\d+)?(\/|\z)/i
125
# Dingbats, emoji, and other graphics https://www.unicode.org/charts/
126
GRAPHICS_RE = /[\u{0000}-\u{001F}\u{2190}-\u{27BF}\u{1F000}-\u{1F9FF}]/
127
128
attr_accessor :already_posted_story, :editing_from_suggestions, :editor,
129
:fetching_ip, :is_hidden_by_cur_user, :is_saved_by_cur_user,
149
if self.title.starts_with?("Ask") && self.tags_a.include?('ask')
150
errors.add(:title, " starting 'Ask #{Rails.application.name}' or similar is redundant " <<
151
"with the ask tag.")
152
end
153
if self.title.match(GRAPHICS_RE)
154
errors.add(:title, " may not contain graphic codepoints")
155
end
165
return unless self.url.present? && self.new_record?
166
167
self.already_posted_story = Story.find_similar_by_url(self.url)
171
errors.add(:url, "has already been submitted within the past " <<
172
"#{RECENT_DAYS} days")
176
def check_not_tracking_domain
177
return unless self.url.present? && self.new_record?
178
188
189
# https
190
urls.each do |u|
191
urls2.push u.gsub(/^http:\/\//i, "https://")
192
urls2.push u.gsub(/^https:\/\//i, "http://")
193
end
194
urls = urls2.clone
195
196
# trailing slash
197
urls.each do |u|
198
urls2.push u.gsub(/\/+\z/, "")
200
end
201
urls = urls2.clone
202
203
# www prefix
204
urls.each do |u|
205
urls2.push u.gsub(/^(https?:\/\/)www\d*\./i) {|_| $1 }
206
urls2.push u.gsub(/^(https?:\/\/)/i) {|_| "#{$1}www." }
207
end
208
urls = urls2.clone
209
210
# if a previous submission was moderated, return it to block it from being
211
# submitted again
212
Story
213
.where(:url => urls)
214
.where("is_expired = ? OR is_moderated = ?", false, true)
215
.order("id DESC").first
220
Story.order("id DESC").limit(100).each(&:recalculate_hotness!)
221
Story.find_each(&:recalculate_hotness!)
226
Arel.sql("(CAST(upvotes AS #{votes_cast_type}) - " <<
227
"CAST(downvotes AS #{votes_cast_type}))")
230
def self.votes_cast_type
231
Story.connection.adapter_name.match(/mysql/i) ? "signed" : "integer"
232
end
233
245
:score,
246
:upvotes,
247
:downvotes,
248
{ :comment_count => :comments_count },
249
{ :description => :markeddown_description },
250
:comments_url,
251
{ :submitter_user => :user },
259
js = {}
260
h.each do |k|
261
if k.is_a?(Symbol)
262
js[k] = self.send(k)
263
elsif k.is_a?(Hash)
264
if k.values.first.is_a?(Symbol)
265
js[k.keys.first] = self.send(k.values.first)
266
else
267
js[k.keys.first] = k.values.first
268
end
269
end
270
end
271
272
js
285
# take each tag's hotness modifier into effect, and give a slight bump to
286
# stories submitted by the author
287
base = self.tags.sum(:hotness_mod) + (self.user_is_author? && self.url.present? ? 0.25 : 0.0)
294
if base < 0
295
# in stories already starting out with a bad hotness mod, only look
296
# at the downvotes to find out if this tire fire needs to be put out
297
c.downvotes * -0.5
298
else
299
c.upvotes + 1 - c.downvotes
300
end
307
# if a story has many comments but few votes, it's probably a bad story, so
308
# cap the comment points at the number of upvotes
309
if cpoints > self.upvotes
310
cpoints = self.upvotes
311
end
312
327
def can_be_seen_by_user?(user)
328
if is_gone? && !(user && (user.is_moderator? || user.id == self.user_id))
329
return false
330
end
347
# this has to happen just before save rather than in tags_a= because we need
348
# to have a valid user_id
362
errors.add(:base, "Must have at least one non-media (PDF, video) " <<
363
"tag. If no tags apply to your content, it probably doesn't " <<
364
"belong here.")
376
def description=(desc)
377
self[:description] = desc.to_s.rstrip
378
self.markeddown_description = self.generated_markeddown_description
383
self.markeddown_description.gsub(/<[^>]*>/, "")
384
else
385
self.story_cache
386
end
406
def fix_bogus_chars
407
# this is needlessly complicated to work around character encoding issues
408
# that arise when doing just self.title.to_s.gsub(160.chr, "")
410
if chr.ord == 160
411
" "
412
else
413
chr
414
end
415
}.join("")
416
417
true
418
end
419
422
end
423
424
def give_upvote_or_downvote_and_recalculate_hotness!(upvote, downvote)
425
self.upvotes += upvote.to_i
426
self.downvotes += downvote.to_i
427
428
Story.connection.execute("UPDATE #{Story.table_name} SET " <<
429
"upvotes = COALESCE(upvotes, 0) + #{upvote.to_i}, " <<
430
"downvotes = COALESCE(downvotes, 0) + #{downvote.to_i}, " <<
431
"hotness = '#{self.calculated_hotness}' WHERE id = #{self.id.to_i}")
432
end
433
434
def has_suggestions?
435
self.suggested_taggings.any? || self.suggested_titles.any?
436
end
437
443
c = []
444
if !self.user.is_active?
445
c.push "inactive_user"
446
elsif self.user.is_new?
447
c.push "new_user"
448
elsif self.user_is_author?
449
c.push "user_is_author"
450
end
451
452
c.join("")
453
end
454
463
def is_editable_by_user?(user)
464
if user && user.is_moderator?
465
return true
466
elsif user && user.id == self.user_id
467
if self.is_moderated?
468
return false
469
else
498
self.unavailable_at = (what.to_i == 1 && !self.is_unavailable ? Time.current : nil)
501
def is_undeletable_by_user?(user)
502
if user && user.is_moderator?
503
return true
504
elsif user && user.id == self.user_id && !self.is_moderated?
505
return true
506
else
507
return false
508
end
513
(!self.editing_from_suggestions && (!self.editor || self.editor.id == self.user_id))
518
all_changes.delete("unavailable_at")
519
520
if !all_changes.any?
521
return
522
end
525
if self.editing_from_suggestions
526
m.is_from_suggestions = true
527
else
528
m.moderator_user_id = self.editor.try(:id)
529
end
532
if all_changes["is_expired"] && self.is_expired?
533
m.action = "deleted story"
534
elsif all_changes["is_expired"] && !self.is_expired?
535
m.action = "undeleted story"
536
else
538
if k == "merged_story_id"
539
if v[1]
540
"merged into #{self.merged_into_story.short_id} " <<
541
"(#{self.merged_into_story.title})"
542
else
543
"unmerged from another story"
544
end
545
else
546
"changed #{k} from #{v[0].inspect} to #{v[1].inspect}"
547
end
548
}.join(", ")
549
end
550
551
m.reason = self.moderation_reason
552
m.save
553
554
self.is_moderated = true
561
def mark_submitter
562
Keystore.increment_value_for("user:#{self.user_id}:stories_submitted")
563
end
564
572
self.merged_story_id = sid.present? ? Story.where(:short_id => sid).pluck(:id).first : nil
575
def merge_story_short_id
576
self.merged_story_id ? self.merged_into_story.try(:short_id) : nil
577
end
578
579
def recalculate_hotness!
580
update_column :hotness, calculated_hotness
581
end
582
584
Vote.vote_thusly_on_story_or_comment_for_user_because(1, self.id, nil, self.user_id, nil, false)
591
def short_id_path
592
Rails.application.routes.url_helpers.root_path + "s/#{self.short_id}"
593
end
594
601
new_tags_a = self.taggings.reject(&:marked_for_destruction?).map {|tg| tg.tag.tag }.join(" ")
614
def tags_a=(new_tag_names_a)
615
self.taggings.each do |tagging|
616
if !new_tag_names_a.include?(tagging.tag.tag)
617
tagging.mark_for_destruction
624
# we can't lookup whether the user is allowed to use this tag yet
625
# because we aren't assured to have a user_id by now; we'll do it in
626
# the validation with check_tags
627
tg = self.taggings.build
628
tg.tag_id = t.id
634
def save_suggested_tags_a_for_user!(new_tag_names_a, user)
635
st = self.suggested_taggings.where(:user_id => user.id)
636
637
st.each do |tagging|
638
if !new_tag_names_a.include?(tagging.tag.tag)
639
tagging.destroy
640
end
641
end
642
643
st.reload
644
645
new_tag_names_a.each do |tag_name|
646
# XXX: AR bug? st.exists?(:tag => tag_name) does not work
650
tg = self.suggested_taggings.build
651
tg.user_id = user.id
652
tg.tag_id = t.id
653
tg.save!
654
655
st.reload
656
else
657
next
658
end
659
end
660
end
661
662
# if enough users voted on the same set of replacement tags, do it
663
tag_votes = {}
673
if v >= SUGGESTION_QUORUM
674
final_tags.push k
675
end
676
end
677
678
if final_tags.any? && (final_tags.sort != self.tags_a.sort)
679
Rails.logger.info "[s#{self.id}] promoting suggested tags " <<
681
self.editor = nil
682
self.editing_from_suggestions = true
683
self.moderation_reason = "Automatically changed from user suggestions"
684
self.tags_a = final_tags.sort
685
if !self.save
686
Rails.logger.error "[s#{self.id}] failed auto promoting: " <<
694
if !st
695
st = self.suggested_titles.build
696
st.user_id = user.id
697
end
698
st.title = title
699
st.save!
703
self.suggested_titles.each do |s|
704
title_votes[s.title] ||= 0
705
title_votes[s.title] += 1
709
if kv[1] >= SUGGESTION_QUORUM
710
Rails.logger.info "[s#{self.id}] promoting suggested title " <<
712
self.editor = nil
713
self.editing_from_suggestions = true
714
self.moderation_reason = "Automatically changed from user suggestions"
715
self.title = kv[0]
716
if !self.save
717
Rails.logger.error "[s#{self.id}] failed auto promoting: " <<
742
if wl + w.length <= max_len
743
words.push w
744
wl += w.length
745
else
746
if wl == 0
747
words.push w[0, max_len]
748
end
749
break
750
end
767
elsif self.unavailable_at && !self.is_unavailable
768
self.unavailable_at = nil
769
end
770
end
771
776
self.update_column :comments_count, (self.comments_count = comments.count {|c| !c.is_gone? })
781
def update_merged_into_story_comments
782
if self.merged_into_story
783
self.merged_into_story.update_comments_count!
784
end
785
end
786
787
def domain
788
return @domain if @domain
789
set_domain self.url.match(URL_RE) if self.url
790
end
791
792
def set_domain match
793
@domain = match ? match[:domain].sub(/^www\d*\./, '') : nil
794
end
795
801
@url_port = match[:port]
802
if match[:protocol] == 'http' && match[:port] == ':80' ||
803
match[:protocol] == 'https' && match[:port] == ':443'
804
u = u[0...match.begin(3)] + u[match.end(3)..-1]
805
@url_port = nil
806
end
807
end
818
end
819
820
def url_is_editable_by_user?(user)
821
if self.new_record?
822
true
823
elsif user && user.is_moderator? && self.url.present?
824
true
825
else
826
false
827
end
828
end
829
843
r_counts[v.reason.to_s] ||= 0
844
r_counts[v.reason.to_s] += v.vote
845
if user && user.is_moderator?
846
r_whos[v.reason.to_s] ||= []
847
r_whos[v.reason.to_s].push v.user.username
848
end
849
end
850
870
# security: do not connect to arbitrary user-submitted ports
871
return @fetched_attributes if @url_port
872
873
if !@fetched_content
874
begin
875
s = Sponge.new
876
s.timeout = 3
877
@fetched_content = s.fetch(self.url, :get, nil, nil, {
894
rescue
895
end
896
897
# then try <meta name="title">
898
if title.to_s == ""
899
begin
901
rescue
902
end
903
end
904
905
# then try plain old <title>
906
if title.to_s == ""
910
# see if the site name is available, so we can strip it out in case it was
911
# present in the fetched title
912
begin
913
site_name = parsed.at_css("meta[property='og:site_name']")
914
.attributes["content"].text
916
if site_name.present? &&
917
site_name.length < title.length &&
918
title[-(site_name.length), site_name.length] == site_name
919
title = title[0, title.length - site_name.length]
920
921
# remove title/site name separator
922
if title.match(/ [ \-\|\u2013] $/)
923
title = title[0, title.length - 3]
924
end
925
end
926
rescue
927
end
928
931
# now get canonical version of url (though some cms software puts incorrect
932
# urls here, hopefully the user will notice)
933
begin
934
if (cu = parsed.at_css("link[rel='canonical']").attributes["href"] .text).present? &&
935
(ucu = URI.parse(cu)) && ucu.scheme.present? &&
936
ucu.host.present?