Permalink
Newer
Older
100644 945 lines (795 sloc) 25.9 KB
1
class Story < ApplicationRecord
2
belongs_to :user
3
belongs_to :merged_into_story,
4
:class_name => "Story",
5
:foreign_key => "merged_story_id",
6
:inverse_of => :merged_stories,
7
:required => false
8
has_many :merged_stories,
9
:class_name => "Story",
10
:foreign_key => "merged_story_id",
11
:inverse_of => :merged_into_story,
12
:dependent => :nullify
14
:autosave => true,
15
:dependent => :destroy
16
has_many :suggested_taggings, :dependent => :destroy
17
has_many :suggested_titles, :dependent => :destroy
19
:inverse_of => :story,
20
:dependent => :destroy
21
has_many :tags, -> { order('tags.is_media desc, tags.tag') }, :through => :taggings
22
has_many :votes, -> { where(:comment_id => nil) }, :inverse_of => :story
Mar 11, 2013
23
has_many :voters, -> { where('votes.comment_id' => nil) },
24
:through => :votes,
25
:source => :user
26
has_many :hidings, :class_name => 'HiddenStory', :inverse_of => :story, :dependent => :destroy
27
has_many :savings, :class_name => 'SavedStory', :inverse_of => :story, :dependent => :destroy
29
scope :base, -> { includes(:tags).unmerged.not_deleted }
30
scope :deleted, -> { where(is_expired: true) }
31
scope :not_deleted, -> { where(is_expired: false) }
32
scope :unmerged, -> { where(:merged_story_id => nil) }
33
scope :positive_ranked, -> { where("#{Story.score_sql} >= 0") }
34
scope :low_scoring, ->(max = 5) { where("#{Story.score_sql} < ?", max) }
35
scope :hottest, ->(user = nil, exclude_tags = nil) {
36
base.not_hidden_by(user)
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
}
48
scope :filter_tags, ->(tags) {
49
tags.empty? ? all : where.not(
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
88
validates_each :merged_story_id do |record, _attr, value|
89
if value.to_i == record.id
90
record.errors.add(:merge_story_short_id, "id cannot be itself.")
91
end
92
end
93
94
DOWNVOTABLE_DAYS = 14
95
96
# the lowest a score can go
97
DOWNVOTABLE_MIN_SCORE = -5
98
99
# after this many minutes old, a story cannot be edited
100
MAX_EDIT_MINS = (60 * 6)
102
# days a story is considered recent, for resubmitting
105
# users needed to make similar suggestions go live
106
SUGGESTION_QUORUM = 2
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
120
wp.me ➡.ws ✩.ws x.co yep.it yourls.org zip.net }.freeze
122
# URI.parse is not very lenient, so we can't use it
Apr 19, 2018
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,
130
:moderation_reason, :previewing, :seen_previous, :vote
131
attr_writer :fetched_content
133
before_validation :assign_short_id_and_upvote, :on => :create
134
before_create :assign_initial_hotness
136
before_save :fix_bogus_chars
137
after_create :mark_submitter, :record_initial_upvote
138
after_save :update_merged_into_story_comments, :recalculate_hotness!
Jun 30, 2012
140
validate do
141
if self.url.present?
142
check_already_posted
143
check_not_tracking_domain
144
errors.add(:url, "is not valid") unless url.match(URL_RE)
145
elsif self.description.to_s.strip == ""
146
errors.add(:description, "must contain text if no URL posted")
147
end
148
May 4, 2018
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
May 4, 2018
156
157
if !errors.any? && self.url.blank?
158
self.user_is_author = true
159
end
160
Jun 30, 2012
162
end
164
def check_already_posted
165
return unless self.url.present? && self.new_record?
166
167
self.already_posted_story = Story.find_similar_by_url(self.url)
168
return unless self.already_posted_story
170
if self.already_posted_story.is_recent?
171
errors.add(:url, "has already been submitted within the past " <<
172
"#{RECENT_DAYS} days")
173
end
174
end
175
176
def check_not_tracking_domain
177
return unless self.url.present? && self.new_record?
178
179
if TRACKING_DOMAINS.include?(domain)
180
errors.add(:url, "is a link shortening or ad tracking domain")
181
end
182
end
183
184
# returns a story or nil
185
def self.find_similar_by_url(url)
186
urls = [url.to_s]
187
urls2 = [url.to_s]
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/, "")
199
urls2.push u + "/"
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
218
def self.recalculate_all_hotnesses!
219
# do the front page first, since find_each can't take an order
220
Story.order("id DESC").limit(100).each(&:recalculate_hotness!)
221
Story.find_each(&:recalculate_hotness!)
225
def self.score_sql
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
234
def archive_url
235
"https://archive.is/#{CGI.escape(self.url)}"
236
end
237
238
def as_json(options = {})
241
:short_id_url,
242
:created_at,
243
:title,
244
:url,
245
:score,
246
:upvotes,
247
:downvotes,
248
{ :comment_count => :comments_count },
249
{ :description => :markeddown_description },
250
:comments_url,
251
{ :submitter_user => :user },
252
{ :tags => self.tags.map(&:tag).sort },
254
255
if options && options[:with_comments]
256
h.push(:comments => options[:with_comments])
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
275
def assign_initial_hotness
276
self.hotness = self.calculated_hotness
277
end
278
279
def assign_short_id_and_upvote
280
self.short_id = ShortId.new(self.class).generate
284
def calculated_hotness
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)
289
# give a story's comment votes some weight, ignoring submitter's comments
290
cpoints = self.merged_comments
291
.where("user_id <> ?", self.user_id)
292
.select(:upvotes, :downvotes)
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
301
}
302
.inject(&:+).to_f * 0.5
304
# mix in any stories this one cannibalized
305
cpoints += self.merged_stories.map(&:score).inject(&:+).to_f
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
313
# don't immediately kill stories at 0 by bumping up score by one
314
order = Math.log([(score + 1).abs + cpoints, 1].max, 10)
315
if score > 0
316
sign = 1
317
elsif score < 0
318
sign = -1
319
else
321
end
322
Story: revert cb30045, using newest created_at of all merged
Jul 20, 2016
323
return -((order * sign) + base +
324
((self.created_at || Time.current).to_f / HOTNESS_WINDOW)).round(7)
325
end
326
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
333
end
334
335
def can_have_suggestions_from_user?(user)
336
if !user || (user.id == self.user_id) || !user.can_offer_suggestions?
337
return false
338
end
340
if self.taggings.select {|t| t.tag && t.tag.privileged? }.any?
341
return false
342
end
343
344
return true
347
# this has to happen just before save rather than in tags_a= because we need
348
# to have a valid user_id
350
u = self.editor || self.user
351
353
if !t.tag.valid_for?(u)
354
raise "#{u.username} does not have permission to use privileged tag #{t.tag.tag}"
355
elsif t.tag.inactive? && t.new_record? && !t.marked_for_destruction?
356
# stories can have inactive tags as long as they existed before
357
raise "#{u.username} cannot add inactive tag #{t.tag.tag}"
361
if self.taggings.reject {|t| t.marked_for_destruction? || t.tag.is_media? }.empty?
Feb 14, 2013
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.")
368
def comments_path
369
"#{short_id_path}/#{self.title_as_url}"
370
end
371
372
def comments_url
373
"#{short_id_url}/#{self.title_as_url}"
376
def description=(desc)
377
self[:description] = desc.to_s.rstrip
378
self.markeddown_description = self.generated_markeddown_description
381
def description_or_story_cache(chars = 0)
382
s = if self.description.present?
383
self.markeddown_description.gsub(/<[^>]*>/, "")
384
else
385
self.story_cache
386
end
388
if chars > 0 && s.to_s.length > chars
389
# remove last truncated word
390
s = s.to_s[0, chars].gsub(/ [^ ]*\z/, "")
391
end
392
393
HTMLEntities.new.decode(s.to_s)
397
"/search?order=newest&q=domain:#{self.domain}"
400
def fetch_story_cache!
401
if self.url.present?
402
self.story_cache = StoryCacher.get_story_text(self)
Jul 2, 2012
403
end
404
end
405
Apr 11, 2017
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, "")
409
self.title = self.title.to_s.split("").map {|chr|
Apr 11, 2017
410
if chr.ord == 160
411
" "
412
else
413
chr
414
end
415
}.join("")
416
417
true
418
end
419
420
def generated_markeddown_description
421
Markdowner.to_html(self.description, :allow_images => true)
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
438
def hider_count
439
@hider_count ||= HiddenStory.where(:story_id => self.id).count
442
def html_class_for_user
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
456
if self.created_at && self.score >= DOWNVOTABLE_MIN_SCORE
457
Time.current - self.created_at <= DOWNVOTABLE_DAYS.days
458
else
459
false
460
end
461
end
462
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
470
return (Time.current.to_i - self.created_at.to_i < (60 * MAX_EDIT_MINS))
478
is_expired? || (self.user.is_banned? && score < 0)
481
def is_hidden_by_user?(user)
482
!!HiddenStory.find_by(:user_id => user.id, :story_id => self.id)
483
end
484
485
def is_recent?
486
self.created_at >= RECENT_DAYS.days.ago
Jul 13, 2017
489
def is_saved_by_user?(user)
490
!!SavedStory.find_by(:user_id => user.id, :story_id => self.id)
Jul 13, 2017
491
end
492
493
def is_unavailable
494
self.unavailable_at != nil
495
end
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
511
def log_moderation
513
(!self.editing_from_suggestions && (!self.editor || self.editor.id == self.user_id))
517
all_changes = self.changes.merge(self.tagging_changes)
518
all_changes.delete("unavailable_at")
519
520
if !all_changes.any?
521
return
522
end
524
m = Moderation.new
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
530
m.story_id = self.id
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
537
m.action = all_changes.map {|k, v|
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
557
def mailing_list_message_id
558
"story.#{short_id}.#{created_at.to_i}@#{Rails.application.domain}"
559
end
560
561
def mark_submitter
562
Keystore.increment_value_for("user:#{self.user_id}:stories_submitted")
563
end
564
565
def merged_comments
566
# TODO: make this a normal has_many?
567
Comment.where(:story_id => Story.select(:id)
568
.where(:merged_story_id => self.id) + [self.id])
569
end
570
571
def merge_story_short_id=(sid)
572
self.merged_story_id = sid.present? ? Story.where(:short_id => sid).pluck(:id).first : nil
573
end
574
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
583
def record_initial_upvote
584
Vote.vote_thusly_on_story_or_comment_for_user_because(1, self.id, nil, self.user_id, nil, false)
587
def score
588
upvotes - downvotes
589
end
590
591
def short_id_path
592
Rails.application.routes.url_helpers.root_path + "s/#{self.short_id}"
593
end
594
595
def short_id_url
596
Rails.application.root_url + "s/#{self.short_id}"
597
end
598
599
def tagging_changes
600
old_tags_a = self.taggings.reject(&:new_record?).map {|tg| tg.tag.tag }.join(" ")
601
new_tags_a = self.taggings.reject(&:marked_for_destruction?).map {|tg| tg.tag.tag }.join(" ")
602
603
if old_tags_a == new_tags_a
604
{}
605
else
606
{ "tags" => [old_tags_a, new_tags_a] }
610
def tags_a
611
@_tags_a ||= self.taggings.reject(&:marked_for_destruction?).map {|t| t.tag.tag }
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
618
end
619
end
620
621
new_tag_names_a.uniq.each do |tag_name|
622
if tag_name.to_s != "" && !self.tags.exists?(:tag => tag_name)
623
if (t = Tag.active.find_by(:tag => tag_name))
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
629
end
630
end
631
end
632
end
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
647
if tag_name.to_s != "" && !st.map {|x| x.tag.tag }.include?(tag_name)
648
if (t = Tag.active.find_by(:tag => tag_name)) &&
649
t.valid_for?(user)
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 = {}
664
self.suggested_taggings.group_by(&:user_id).each do |_u, stg|
665
stg.each do |s|
666
tag_votes[s.tag.tag] ||= 0
667
tag_votes[s.tag.tag] += 1
668
end
669
end
670
671
final_tags = []
672
tag_votes.each do |k, v|
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 " <<
680
"#{final_tags.inspect} instead of #{self.tags_a.inspect}"
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: " <<
687
self.errors.inspect
690
end
691
692
def save_suggested_title_for_user!(title, user)
693
st = self.suggested_titles.find_by(:user_id => user.id)
694
if !st
695
st = self.suggested_titles.build
696
st.user_id = user.id
697
end
698
st.title = title
699
st.save!
700
701
# if enough users voted on the same exact title, save it
702
title_votes = {}
703
self.suggested_titles.each do |s|
704
title_votes[s.title] ||= 0
705
title_votes[s.title] += 1
Aug 15, 2018
708
title_votes.sort_by {|_k, v| v }.reverse_each do |kv|
709
if kv[1] >= SUGGESTION_QUORUM
710
Rails.logger.info "[s#{self.id}] promoting suggested title " <<
711
"#{kv[0].inspect} instead of #{self.title.inspect}"
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: " <<
718
self.errors.inspect
719
end
720
721
break
722
end
723
end
726
def title=(t)
727
# change unicode whitespace characters into real spaces
728
self[:title] = t.strip.gsub(/[\.,;:!]*$/, '')
731
def title_as_url
732
max_len = 35
733
wl = 0
734
words = []
735
736
self.title
737
.parameterize
738
.gsub(/[^a-z0-9]/, "_")
739
.split("_")
740
.reject {|z| TITLE_DROP_WORDS.include?(z) }
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
753
if words.empty?
754
words.push "_"
755
end
756
757
words.join("_").gsub(/_-_/, "-")
760
def to_param
761
self.short_id
762
end
763
764
def update_availability
765
if self.is_unavailable && !self.unavailable_at
766
self.unavailable_at = Time.current
767
elsif self.unavailable_at && !self.is_unavailable
768
self.unavailable_at = nil
769
end
770
end
771
772
def update_comments_count!
773
comments = self.merged_comments.arrange_for_user(nil)
774
775
# calculate count after removing deleted comments and threads
776
self.update_column :comments_count, (self.comments_count = comments.count {|c| !c.is_gone? })
777
778
self.recalculate_hotness!
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
Apr 19, 2018
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
Jul 11, 2018
797
super(u.try(:strip)) or return if u.blank?
798
799
if (match = u.match(URL_RE))
Apr 18, 2018
800
# remove well-known port for http and https if present
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
Apr 19, 2018
808
set_domain match
810
# strip out stupid google analytics parameters
811
if (match = u.match(/\A([^\?]+)\?(.+)\z/))
812
params = match[2].split(/[&\?]/)
813
params.reject! {|p| p.match(/^utm_(source|medium|campaign|term|content)=/) }
814
u = match[1] << (params.any?? "?" << params.join("&") : "")
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
830
def url_or_comments_path
831
self.url.presence || self.comments_path
834
def url_or_comments_url
835
self.url.presence || self.comments_url
837
838
def vote_summary_for(user)
839
r_counts = {}
840
r_whos = {}
841
votes.find_each do |v|
842
next if v.vote == 0
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
852
if k == ""
853
"+#{r_counts[k]}"
854
else
855
"#{r_counts[k]} " +
856
(Vote::STORY_REASONS[k] || Vote::OLD_STORY_REASONS[k] || k) +
857
(user && user.is_moderator? ? " (#{r_whos[k].join(', ')})" : "")
858
end
859
}.join(", ")
860
end
863
return @fetched_attributes if @fetched_attributes
864
865
@fetched_attributes = {
866
:url => self.url,
867
:title => "",
868
}
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, {
878
"User-agent" => "#{Rails.application.domain} for #{self.fetching_ip}",
879
}, 3)
880
rescue
881
return @fetched_attributes
882
end
885
parsed = Nokogiri::HTML(@fetched_content.to_s)
887
# parse best title from html tags
888
# try <meta property="og:title"> first, it probably won't have the site
889
# name
892
title = parsed.at_css("meta[property='og:title']")
893
.attributes["content"].text
894
rescue
895
end
896
897
# then try <meta name="title">
898
if title.to_s == ""
899
begin
900
title = parsed.at_css("meta[name='title']").attributes["content"].text
901
rescue
902
end
903
end
904
905
# then try plain old <title>
906
if title.to_s == ""
907
title = parsed.at_css("title").try(:text).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
929
@fetched_attributes[:title] = title
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?
937
@fetched_attributes[:url] = cu