2023-09-14 13:37:09 +00:00
|
|
|
# typed: false
|
|
|
|
|
2018-03-19 00:12:01 +00:00
|
|
|
class Story < ApplicationRecord
|
2012-06-30 22:41:00 +00:00
|
|
|
belongs_to :user
|
2020-01-22 14:30:31 +00:00
|
|
|
belongs_to :domain, optional: true
|
2014-04-08 22:51:12 +00:00
|
|
|
belongs_to :merged_into_story,
|
2018-03-14 16:44:26 +00:00
|
|
|
class_name: "Story",
|
2018-03-19 00:28:06 +00:00
|
|
|
foreign_key: "merged_story_id",
|
2018-08-21 14:04:46 +00:00
|
|
|
inverse_of: :merged_stories,
|
fix BelongsTo rubocop warnings
```
app/models/user.rb:22:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :invited_by_user,
^^^^^^^^^^
app/models/user.rb:26:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :banned_by_user,
^^^^^^^^^^
app/models/user.rb:30:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :disabled_invite_by_user,
^^^^^^^^^^
app/models/message.rb:10:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :hat,
^^^^^^^^^^
app/models/story.rb:3:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :merged_into_story,
^^^^^^^^^^
app/models/invitation.rb:3:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :new_user, class_name: 'User', inverse_of: nil, required: false
^^^^^^^^^^
app/models/comment.rb:9:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :parent_comment,
^^^^^^^^^^
app/models/comment.rb:17:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :hat,
^^^^^^^^^^
app/models/moderation.rb:2:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :moderator,
^^^^^^^^^^
app/models/moderation.rb:7:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :comment,
^^^^^^^^^^
app/models/moderation.rb:9:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :story,
^^^^^^^^^^
app/models/moderation.rb:11:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :tag,
^^^^^^^^^^
app/models/moderation.rb:13:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :user,
^^^^^^^^^^
app/models/vote.rb:2:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :user, required: false
^^^^^^^^^^
app/models/vote.rb:3:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :story, required: false
^^^^^^^^^^
app/models/vote.rb:4:3: C: Rails/BelongsTo: You specified required: false, in Rails > 5.0 the required option is deprecated and you want to use optional: true.
belongs_to :comment, required: false
^^^^^^^^^^
```
2019-06-13 14:06:07 +00:00
|
|
|
optional: true
|
2014-04-08 22:51:12 +00:00
|
|
|
has_many :merged_stories,
|
2018-03-14 16:44:26 +00:00
|
|
|
class_name: "Story",
|
2018-03-19 00:28:06 +00:00
|
|
|
foreign_key: "merged_story_id",
|
2018-03-19 00:56:05 +00:00
|
|
|
inverse_of: :merged_into_story,
|
|
|
|
dependent: :nullify
|
2012-11-26 19:39:16 +00:00
|
|
|
has_many :taggings,
|
2018-03-19 00:56:05 +00:00
|
|
|
autosave: true,
|
|
|
|
dependent: :destroy
|
|
|
|
has_many :suggested_taggings, dependent: :destroy
|
|
|
|
has_many :suggested_titles, dependent: :destroy
|
2019-03-13 13:04:59 +00:00
|
|
|
has_many :suggested_tagging_times,
|
|
|
|
-> { group(:tag_id).select("count(*) as times, tag_id").order("times desc") },
|
|
|
|
class_name: "SuggestedTagging",
|
|
|
|
inverse_of: :story
|
|
|
|
has_many :suggested_title_times,
|
|
|
|
-> { group(:title).select("count(*) as times, title").order("times desc") },
|
|
|
|
class_name: "SuggestedTitle",
|
|
|
|
inverse_of: :story
|
2014-01-07 22:58:22 +00:00
|
|
|
has_many :comments,
|
2018-03-19 00:56:05 +00:00
|
|
|
inverse_of: :story,
|
|
|
|
dependent: :destroy
|
2018-06-23 03:48:32 +00:00
|
|
|
has_many :tags, -> { order("tags.is_media desc, tags.tag") }, through: :taggings
|
2018-03-19 00:28:06 +00:00
|
|
|
has_many :votes, -> { where(comment_id: nil) }, inverse_of: :story
|
2013-03-11 18:19:26 +00:00
|
|
|
has_many :voters, -> { where("votes.comment_id" => nil) },
|
2018-03-14 16:44:26 +00:00
|
|
|
through: :votes,
|
|
|
|
source: :user
|
2018-04-19 16:09:38 +00:00
|
|
|
has_many :hidings, class_name: "HiddenStory", inverse_of: :story, dependent: :destroy
|
2018-04-25 16:28:07 +00:00
|
|
|
has_many :savings, class_name: "SavedStory", inverse_of: :story, dependent: :destroy
|
2020-08-25 12:51:09 +00:00
|
|
|
has_one :story_text, foreign_key: :id, dependent: :destroy, inverse_of: :story
|
2012-06-17 01:15:46 +00:00
|
|
|
|
2023-08-30 22:20:21 +00:00
|
|
|
scope :base, ->(user) { includes(:tags).not_deleted(user).unmerged.mod_preload?(user) }
|
2023-09-06 15:49:24 +00:00
|
|
|
scope :for_presentation, -> {
|
|
|
|
includes(:domain, :user, taggings: :tag)
|
|
|
|
}
|
2023-08-30 22:20:21 +00:00
|
|
|
scope :mod_preload?, ->(user) {
|
|
|
|
user.try(:is_moderator?) ? preload(:suggested_taggings, :suggested_titles) : all
|
2022-02-06 22:36:57 +00:00
|
|
|
}
|
2022-02-07 03:38:50 +00:00
|
|
|
scope :deleted, -> { where(is_deleted: true) }
|
2023-08-30 22:20:21 +00:00
|
|
|
scope :not_deleted, ->(user) {
|
2023-08-31 16:51:40 +00:00
|
|
|
user.try(:is_moderator?) ? all : where("is_deleted = false or stories.user_id = ?", user.try(:id).to_i)
|
2023-08-30 22:20:21 +00:00
|
|
|
}
|
2014-04-08 22:51:12 +00:00
|
|
|
scope :unmerged, -> { where(merged_story_id: nil) }
|
2020-05-12 02:52:05 +00:00
|
|
|
scope :positive_ranked, -> { where("score >= 0") }
|
|
|
|
scope :low_scoring, ->(max = 5) { where("score < ?", max) }
|
2020-07-14 00:10:31 +00:00
|
|
|
scope :front_page, -> { hottest.limit(StoriesPaginator::STORIES_PER_PAGE) }
|
2018-04-26 15:46:58 +00:00
|
|
|
scope :hottest, ->(user = nil, exclude_tags = nil) {
|
2022-02-06 22:36:57 +00:00
|
|
|
base(user).not_hidden_by(user)
|
2018-04-26 15:46:58 +00:00
|
|
|
.filter_tags(exclude_tags || [])
|
|
|
|
.positive_ranked
|
|
|
|
.order("hotness")
|
|
|
|
}
|
|
|
|
scope :recent, ->(user = nil, exclude_tags = nil) {
|
2022-02-06 22:36:57 +00:00
|
|
|
base(user).not_hidden_by(user)
|
2018-04-26 15:07:53 +00:00
|
|
|
.filter_tags(exclude_tags || [])
|
2020-07-14 00:10:31 +00:00
|
|
|
.low_scoring
|
2018-04-26 15:07:53 +00:00
|
|
|
.where("created_at >= ?", 10.days.ago)
|
2020-07-14 00:10:31 +00:00
|
|
|
.where.not(id: front_page.ids)
|
2018-04-26 15:07:53 +00:00
|
|
|
.order("stories.created_at DESC")
|
|
|
|
}
|
2018-04-25 16:20:31 +00:00
|
|
|
scope :filter_tags, ->(tags) {
|
perf attempt: refactor to remove dependent subquery
old Story.filter_tags([1, 2, 3]):
Story.where.not( Tagging.select('TRUE').where('taggings.story_id = stories.id').where(tag_id: [1, 2, 3]).arel.exists)
SELECT `stories`.* FROM `stories` WHERE NOT (EXISTS (SELECT TRUE FROM `taggings` WHERE (taggings.story_id = stories.id) AND `taggings`.`tag_id` IN (1, 2, 3)))
new Story.filter_tags([1, 2, 3]):
Story.where(Story.arel_table[:id].not_in(Tagging.where(tag_id: [1, 2, 3]).select(:story_id).arel))
SELECT `stories`.* FROM `stories` WHERE `stories`.`id` NOT IN (SELECT `taggings`.`story_id` FROM `taggings` WHERE `taggings`.`tag_id` IN (1, 2, 3))
same story for Story.filter_tags_for(1):
Story.where(Story.arel_table[:id].not_in(Tagging.joins(tag: :tag_filters).where(tag_filters: { user_id: 1 }).select(:story_id).arel))
SELECT `stories`.* FROM `stories` WHERE `stories`.`id` NOT IN (SELECT `taggings`.`story_id` FROM `taggings` INNER JOIN `tags` ON `tags`.`id` = `taggings`.`tag_id` INNER JOIN `tag_filters` ON `tag_filters`.`tag_id` = `tags`.`id` WHERE `tag_filters`.`user_id` = 78)
So this is a clear improvement... but the EXPLAIN is exactly the same, MariaDB
recognized the opportunity:
+------+--------------+----------+-------+------------------------------------+-----------------+---------+------+-------+--------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+--------------+----------+-------+------------------------------------+-----------------+---------+------+-------+--------------------------+
| 1 | PRIMARY | stories | ALL | NULL | NULL | NULL | NULL | 85056 | Using where |
| 2 | MATERIALIZED | taggings | index | story_id_tag_id,taggings_tag_id_fk | story_id_tag_id | 16 | NULL | 1 | Using where; Using index |
+------+--------------+----------+-------+------------------------------------+-----------------+---------+------+-------+--------------------------+
So this is a no-op on MariaDB, but I'm making the change because the
ActiveRecord is easier to read. Credit to Aaron Francis and Colleen Schnettler.
2022-06-08 13:18:11 +00:00
|
|
|
tags.empty? ? all : where(
|
|
|
|
Story.arel_table[:id].not_in(
|
|
|
|
Tagging.where(tag_id: tags).select(:story_id).arel
|
|
|
|
)
|
2018-04-25 16:20:31 +00:00
|
|
|
)
|
|
|
|
}
|
2018-04-26 13:00:44 +00:00
|
|
|
scope :filter_tags_for, ->(user) {
|
perf attempt: refactor to remove dependent subquery
old Story.filter_tags([1, 2, 3]):
Story.where.not( Tagging.select('TRUE').where('taggings.story_id = stories.id').where(tag_id: [1, 2, 3]).arel.exists)
SELECT `stories`.* FROM `stories` WHERE NOT (EXISTS (SELECT TRUE FROM `taggings` WHERE (taggings.story_id = stories.id) AND `taggings`.`tag_id` IN (1, 2, 3)))
new Story.filter_tags([1, 2, 3]):
Story.where(Story.arel_table[:id].not_in(Tagging.where(tag_id: [1, 2, 3]).select(:story_id).arel))
SELECT `stories`.* FROM `stories` WHERE `stories`.`id` NOT IN (SELECT `taggings`.`story_id` FROM `taggings` WHERE `taggings`.`tag_id` IN (1, 2, 3))
same story for Story.filter_tags_for(1):
Story.where(Story.arel_table[:id].not_in(Tagging.joins(tag: :tag_filters).where(tag_filters: { user_id: 1 }).select(:story_id).arel))
SELECT `stories`.* FROM `stories` WHERE `stories`.`id` NOT IN (SELECT `taggings`.`story_id` FROM `taggings` INNER JOIN `tags` ON `tags`.`id` = `taggings`.`tag_id` INNER JOIN `tag_filters` ON `tag_filters`.`tag_id` = `tags`.`id` WHERE `tag_filters`.`user_id` = 78)
So this is a clear improvement... but the EXPLAIN is exactly the same, MariaDB
recognized the opportunity:
+------+--------------+----------+-------+------------------------------------+-----------------+---------+------+-------+--------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+------+--------------+----------+-------+------------------------------------+-----------------+---------+------+-------+--------------------------+
| 1 | PRIMARY | stories | ALL | NULL | NULL | NULL | NULL | 85056 | Using where |
| 2 | MATERIALIZED | taggings | index | story_id_tag_id,taggings_tag_id_fk | story_id_tag_id | 16 | NULL | 1 | Using where; Using index |
+------+--------------+----------+-------+------------------------------------+-----------------+---------+------+-------+--------------------------+
So this is a no-op on MariaDB, but I'm making the change because the
ActiveRecord is easier to read. Credit to Aaron Francis and Colleen Schnettler.
2022-06-08 13:18:11 +00:00
|
|
|
user.nil? ? all : where(
|
|
|
|
Story.arel_table[:id].not_in(
|
|
|
|
Tagging.joins(tag: :tag_filters)
|
|
|
|
.where(tag_filters: {user_id: user})
|
|
|
|
.select(:story_id).arel
|
|
|
|
)
|
2018-04-26 13:00:44 +00:00
|
|
|
)
|
|
|
|
}
|
2018-04-19 16:09:38 +00:00
|
|
|
scope :hidden_by, ->(user) {
|
|
|
|
user.nil? ? none : joins(:hidings).merge(HiddenStory.by(user))
|
|
|
|
}
|
|
|
|
scope :not_hidden_by, ->(user) {
|
|
|
|
user.nil? ? all : where.not(
|
2018-06-19 14:15:41 +00:00
|
|
|
HiddenStory.select("TRUE")
|
|
|
|
.where(Arel.sql("hidden_stories.story_id = stories.id"))
|
|
|
|
.by(user)
|
2018-10-20 21:05:29 +00:00
|
|
|
.arel
|
2018-06-19 14:15:41 +00:00
|
|
|
.exists
|
2018-04-19 16:09:38 +00:00
|
|
|
)
|
|
|
|
}
|
2018-04-25 16:28:07 +00:00
|
|
|
scope :saved_by, ->(user) {
|
|
|
|
user.nil? ? none : joins(:savings).merge(SavedStory.by(user))
|
|
|
|
}
|
2018-04-26 16:19:59 +00:00
|
|
|
scope :to_tweet, -> {
|
|
|
|
hottest(nil, Tag.where(tag: "meta").pluck(:id))
|
|
|
|
.where(twitter_id: nil)
|
2020-05-12 02:52:05 +00:00
|
|
|
.where("score >= 2")
|
2018-04-26 16:19:59 +00:00
|
|
|
.where("created_at >= ?", 2.days.ago)
|
|
|
|
.limit(10)
|
|
|
|
}
|
2014-04-08 22:51:12 +00:00
|
|
|
|
2018-03-19 00:58:05 +00:00
|
|
|
validates :title, length: {in: 3..150}
|
|
|
|
validates :description, length: {maximum: (64 * 1024)}
|
|
|
|
validates :url, length: {maximum: 250, allow_nil: true}
|
2020-02-05 14:54:35 +00:00
|
|
|
validates :short_id, presence: true, length: {maximum: 6}
|
|
|
|
validates :markeddown_description, length: {maximum: 16_777_215, allow_nil: true}
|
|
|
|
validates :twitter_id, length: {maximum: 20, allow_nil: true}
|
2012-06-30 16:18:36 +00:00
|
|
|
|
2018-03-02 13:19:50 +00:00
|
|
|
validates_each :merged_story_id do |record, _attr, value|
|
2016-04-25 18:24:29 +00:00
|
|
|
if value.to_i == record.id
|
|
|
|
record.errors.add(:merge_story_short_id, "id cannot be itself.")
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2022-02-20 03:03:52 +00:00
|
|
|
COMMENTABLE_DAYS = 90
|
2020-05-12 02:52:05 +00:00
|
|
|
FLAGGABLE_DAYS = 14
|
2014-03-18 05:06:43 +00:00
|
|
|
|
2017-05-10 16:30:24 +00:00
|
|
|
# the lowest a score can go
|
2020-05-12 02:52:05 +00:00
|
|
|
FLAGGABLE_MIN_SCORE = -5
|
2017-05-10 16:30:24 +00:00
|
|
|
|
2012-06-30 22:41:00 +00:00
|
|
|
# after this many minutes old, a story cannot be edited
|
2015-12-08 02:08:36 +00:00
|
|
|
MAX_EDIT_MINS = (60 * 6)
|
2012-06-17 01:15:46 +00:00
|
|
|
|
2014-02-17 16:13:08 +00:00
|
|
|
# days a story is considered recent, for resubmitting
|
2014-01-13 06:10:31 +00:00
|
|
|
RECENT_DAYS = 30
|
|
|
|
|
2015-10-15 15:02:26 +00:00
|
|
|
# users needed to make similar suggestions go live
|
2016-04-06 18:22:46 +00:00
|
|
|
SUGGESTION_QUORUM = 2
|
2015-10-15 15:02:26 +00:00
|
|
|
|
2016-04-12 16:48:47 +00:00
|
|
|
# let a hot story linger for this many seconds
|
|
|
|
HOTNESS_WINDOW = 60 * 60 * 22
|
|
|
|
|
2018-03-14 15:47:35 +00:00
|
|
|
# drop these words from titles when making URLs
|
|
|
|
TITLE_DROP_WORDS = ["", "a", "an", "and", "but", "in", "of", "or", "that", "the", "to"].freeze
|
|
|
|
|
2018-04-18 20:13:44 +00:00
|
|
|
# URI.parse is not very lenient, so we can't use it
|
2022-01-04 14:33:32 +00:00
|
|
|
URL_RE = /\A(?<protocol>https?):\/\/(?<domain>(?:[^\.\/]+\.)+[a-z\-]+)(?<port>:\d+)?(?:\/|\z)/i
|
2018-04-18 20:13:44 +00:00
|
|
|
|
2018-07-31 15:32:24 +00:00
|
|
|
# Dingbats, emoji, and other graphics https://www.unicode.org/charts/
|
2022-06-22 13:54:03 +00:00
|
|
|
GRAPHICS_RE = /[\u{0000}-\u{001F}\u{2190}\u{2192}-\u{27BF}\u{1F000}-\u{1F9FF}]/
|
2018-07-31 15:32:24 +00:00
|
|
|
|
2023-09-13 16:10:44 +00:00
|
|
|
attr_accessor :current_vote, :editing_from_suggestions, :editor, :fetching_ip,
|
2022-02-22 01:03:52 +00:00
|
|
|
:is_hidden_by_cur_user, :latest_comment_id,
|
|
|
|
:is_saved_by_cur_user, :moderation_reason, :previewing,
|
2023-09-13 16:10:44 +00:00
|
|
|
:seen_previous
|
2020-03-03 02:34:01 +00:00
|
|
|
attr_writer :fetched_response
|
2012-06-17 01:15:46 +00:00
|
|
|
|
2020-05-12 02:52:05 +00:00
|
|
|
before_validation :assign_short_id_and_score, on: :create
|
2015-01-13 18:41:05 +00:00
|
|
|
before_create :assign_initial_hotness
|
2012-11-12 17:02:18 +00:00
|
|
|
before_save :log_moderation
|
2015-06-19 18:30:32 +00:00
|
|
|
before_save :fix_bogus_chars
|
2014-03-03 23:13:00 +00:00
|
|
|
after_create :mark_submitter, :record_initial_upvote
|
2023-10-07 04:32:31 +00:00
|
|
|
after_save :update_cached_columns, :update_story_text
|
2013-01-23 06:15:05 +00:00
|
|
|
|
2012-06-30 19:14:35 +00:00
|
|
|
validate do
|
|
|
|
if url.present?
|
2018-11-28 14:26:42 +00:00
|
|
|
already_posted_recently?
|
2018-02-02 02:11:25 +00:00
|
|
|
check_not_tracking_domain
|
2020-02-10 19:24:59 +00:00
|
|
|
check_not_new_domain_from_new_user
|
2018-04-18 20:13:44 +00:00
|
|
|
errors.add(:url, "is not valid") unless url.match(URL_RE)
|
2012-06-30 22:41:00 +00:00
|
|
|
elsif description.to_s.strip == ""
|
2012-06-30 23:00:05 +00:00
|
|
|
errors.add(:description, "must contain text if no URL posted")
|
|
|
|
end
|
|
|
|
|
2018-05-04 23:16:55 +00:00
|
|
|
if title.starts_with?("Ask") && tags_a.include?("ask")
|
|
|
|
errors.add(:title, " starting 'Ask #{Rails.application.name}' or similar is redundant " \
|
|
|
|
"with the ask tag.")
|
|
|
|
end
|
2018-07-31 15:32:24 +00:00
|
|
|
if title.match(GRAPHICS_RE)
|
|
|
|
errors.add(:title, " may not contain graphic codepoints")
|
|
|
|
end
|
2018-05-04 23:16:55 +00:00
|
|
|
|
2015-07-30 22:15:48 +00:00
|
|
|
if !errors.any? && url.blank?
|
|
|
|
self.user_is_author = true
|
|
|
|
end
|
|
|
|
|
2012-11-12 17:02:18 +00:00
|
|
|
check_tags
|
2012-06-30 19:14:35 +00:00
|
|
|
end
|
2012-06-17 01:15:46 +00:00
|
|
|
|
2022-02-20 03:03:52 +00:00
|
|
|
def accepting_comments?
|
|
|
|
!is_gone? &&
|
|
|
|
!previewing &&
|
2022-02-22 01:03:52 +00:00
|
|
|
(new_record? || created_at.after?(COMMENTABLE_DAYS.days.ago))
|
2022-02-20 03:03:52 +00:00
|
|
|
end
|
|
|
|
|
2018-11-28 14:26:42 +00:00
|
|
|
def already_posted_recently?
|
|
|
|
return false unless url.present? && new_record?
|
2017-10-06 17:33:55 +00:00
|
|
|
|
2018-11-28 14:26:42 +00:00
|
|
|
if most_recent_similar&.is_recent?
|
2018-11-07 17:42:40 +00:00
|
|
|
errors.add(:url, "has already been submitted within the past #{RECENT_DAYS} days")
|
2018-11-28 14:26:42 +00:00
|
|
|
true
|
2023-09-13 23:26:17 +00:00
|
|
|
elsif most_recent_similar && user&.is_new?
|
2020-02-10 19:24:59 +00:00
|
|
|
errors.add(:url, "cannot be resubmitted by new users")
|
|
|
|
true
|
2018-11-28 14:26:42 +00:00
|
|
|
else
|
|
|
|
false
|
2017-10-05 07:19:36 +00:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-02-10 19:24:59 +00:00
|
|
|
def check_not_new_domain_from_new_user
|
|
|
|
return unless url.present? && new_record? && domain
|
|
|
|
|
2023-08-30 22:20:21 +00:00
|
|
|
if user&.is_new? && domain.stories.not_deleted(nil).count == 0
|
2020-02-10 19:24:59 +00:00
|
|
|
ModNote.tattle_on_story_domain!(self, "new user with new")
|
2022-01-29 16:53:31 +00:00
|
|
|
errors.add :url, <<-EXPLANATION
|
|
|
|
is an unseen domain from a new user. We restrict this to discourage
|
|
|
|
self-promotion and give you time to learn about topicality. Skirting
|
|
|
|
this with a URL shortener or tweet or something will probably earn a ban.
|
|
|
|
EXPLANATION
|
2020-02-10 19:24:59 +00:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-02-02 02:11:25 +00:00
|
|
|
def check_not_tracking_domain
|
2020-02-10 13:29:02 +00:00
|
|
|
return unless url.present? && new_record? && domain
|
2019-06-17 05:04:11 +00:00
|
|
|
|
2020-02-12 12:07:27 +00:00
|
|
|
if domain.banned?
|
2020-02-10 13:29:02 +00:00
|
|
|
ModNote.tattle_on_story_domain!(self, "banned")
|
2020-02-12 12:07:27 +00:00
|
|
|
errors.add(:url, "is from banned domain #{domain.domain}: #{domain.banned_reason}")
|
2018-02-02 02:11:25 +00:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2023-09-13 16:10:44 +00:00
|
|
|
# current_vote is the vote loaded for the currently-viewing user
|
|
|
|
def current_flagged?
|
|
|
|
current_vote.try(:[], :vote) == -1
|
|
|
|
end
|
|
|
|
|
|
|
|
def current_upvoted?
|
|
|
|
current_vote.try(:[], :vote) == 1
|
|
|
|
end
|
|
|
|
|
2018-11-28 14:26:42 +00:00
|
|
|
# all stories with similar urls
|
2014-01-13 06:10:31 +00:00
|
|
|
def self.find_similar_by_url(url)
|
2016-06-22 20:07:02 +00:00
|
|
|
# if a previous submission was moderated, return it to block it from being
|
|
|
|
# submitted again
|
2023-08-28 22:15:58 +00:00
|
|
|
Story.where(normalized_url: Utils.normalize_url(url))
|
2022-02-07 03:38:50 +00:00
|
|
|
.where("is_deleted = ? OR is_moderated = ?", false, true)
|
2012-07-11 19:22:26 +00:00
|
|
|
end
|
|
|
|
|
2018-11-28 14:26:42 +00:00
|
|
|
# doesn't include deleted/moderated/merged stories
|
2018-11-07 17:42:40 +00:00
|
|
|
def similar_stories
|
2023-09-14 13:29:11 +00:00
|
|
|
return Story.none if url.blank?
|
2018-11-07 17:42:40 +00:00
|
|
|
|
2018-11-28 14:26:42 +00:00
|
|
|
@_similar_stories ||= Story.find_similar_by_url(url).order("id DESC")
|
2018-11-22 03:06:30 +00:00
|
|
|
# do not include this story itself or any story merged into it
|
2018-11-07 17:42:40 +00:00
|
|
|
if id?
|
|
|
|
@_similar_stories = @_similar_stories.where.not(id: id)
|
2018-11-22 03:06:30 +00:00
|
|
|
.where("merged_story_id is null or merged_story_id != ?", id)
|
|
|
|
end
|
|
|
|
# do not include the story this one is merged into
|
|
|
|
if merged_story_id?
|
2023-09-14 13:29:11 +00:00
|
|
|
@_similar_stories = @_similar_stories.where.not(id: merged_story_id)
|
2018-11-07 17:42:40 +00:00
|
|
|
end
|
|
|
|
@_similar_stories
|
|
|
|
end
|
|
|
|
|
2022-02-06 22:36:57 +00:00
|
|
|
def public_similar_stories(user)
|
2023-08-28 22:40:13 +00:00
|
|
|
@_public_similar_stories ||= similar_stories.base(user)
|
2018-11-07 17:42:40 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def most_recent_similar
|
|
|
|
similar_stories.first
|
|
|
|
end
|
|
|
|
|
2012-10-09 16:06:43 +00:00
|
|
|
def self.recalculate_all_hotnesses!
|
2017-05-04 15:28:41 +00:00
|
|
|
# do the front page first, since find_each can't take an order
|
2023-10-07 04:32:31 +00:00
|
|
|
Story.order("id DESC").limit(100).each(&:update_cached_columns)
|
|
|
|
Story.find_each(&:update_cached_columns)
|
2014-07-02 15:09:03 +00:00
|
|
|
true
|
2012-10-09 16:06:43 +00:00
|
|
|
end
|
|
|
|
|
2022-01-22 15:18:16 +00:00
|
|
|
def archiveorg_url
|
|
|
|
# This will redirect to the latest version they have
|
|
|
|
"https://web.archive.org/web/3/#{CGI.escape(url)}"
|
|
|
|
end
|
|
|
|
|
2021-10-19 01:26:02 +00:00
|
|
|
def archivetoday_url
|
|
|
|
"https://archive.today/#{CGI.escape(url)}"
|
|
|
|
end
|
2022-01-22 15:18:16 +00:00
|
|
|
|
2021-10-19 01:26:02 +00:00
|
|
|
def ghost_url
|
|
|
|
"https://ghostarchive.org/search?term=#{CGI.escape(url)}"
|
2017-04-11 21:49:26 +00:00
|
|
|
end
|
|
|
|
|
2012-12-17 02:00:41 +00:00
|
|
|
def as_json(options = {})
|
2015-08-04 00:12:39 +00:00
|
|
|
h = [
|
2012-12-17 02:00:41 +00:00
|
|
|
:short_id,
|
2015-08-04 00:12:39 +00:00
|
|
|
:short_id_url,
|
2012-12-18 23:16:02 +00:00
|
|
|
:created_at,
|
2012-12-17 02:00:41 +00:00
|
|
|
:title,
|
|
|
|
:url,
|
2015-08-04 00:12:39 +00:00
|
|
|
:score,
|
2020-05-12 02:52:05 +00:00
|
|
|
:score,
|
|
|
|
:flags,
|
2015-08-04 00:12:39 +00:00
|
|
|
{comment_count: :comments_count},
|
|
|
|
{description: :markeddown_description},
|
2022-05-11 13:09:03 +00:00
|
|
|
{description_plain: :description},
|
2015-08-04 00:12:39 +00:00
|
|
|
:comments_url,
|
|
|
|
{submitter_user: :user},
|
2018-03-18 05:51:58 +00:00
|
|
|
{tags: tags.map(&:tag).sort}
|
2015-08-04 00:12:39 +00:00
|
|
|
]
|
2012-12-30 18:13:19 +00:00
|
|
|
|
|
|
|
if options && options[:with_comments]
|
2018-03-18 00:31:06 +00:00
|
|
|
h.push(comments: options[:with_comments])
|
2012-12-30 18:13:19 +00:00
|
|
|
end
|
|
|
|
|
2015-08-04 00:12:39 +00:00
|
|
|
js = {}
|
|
|
|
h.each do |k|
|
|
|
|
if k.is_a?(Symbol)
|
|
|
|
js[k] = send(k)
|
|
|
|
elsif k.is_a?(Hash)
|
|
|
|
js[k.keys.first] = if k.values.first.is_a?(Symbol)
|
|
|
|
send(k.values.first)
|
|
|
|
else
|
|
|
|
k.values.first
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
js
|
2012-12-17 02:00:41 +00:00
|
|
|
end
|
|
|
|
|
2015-01-13 18:41:05 +00:00
|
|
|
def assign_initial_hotness
|
|
|
|
self.hotness = calculated_hotness
|
|
|
|
end
|
|
|
|
|
2020-05-12 02:52:05 +00:00
|
|
|
def assign_short_id_and_score
|
2013-01-23 06:15:05 +00:00
|
|
|
self.short_id = ShortId.new(self.class).generate
|
2020-05-12 02:52:05 +00:00
|
|
|
self.score ||= 1 # tests are allowed to fake out the score
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def calculated_hotness
|
2017-05-04 15:28:41 +00:00
|
|
|
# take each tag's hotness modifier into effect, and give a slight bump to
|
|
|
|
# stories submitted by the author
|
2018-06-28 15:38:16 +00:00
|
|
|
base = tags.sum(:hotness_mod) + ((user_is_author? && url.present?) ? 0.25 : 0.0)
|
2014-08-25 23:11:53 +00:00
|
|
|
|
2017-02-27 04:37:06 +00:00
|
|
|
# give a story's comment votes some weight, ignoring submitter's comments
|
2023-08-24 01:11:09 +00:00
|
|
|
sum_expression = (base < 0) ? "comments.flags * -0.5" : "comments.score + 1"
|
2019-05-08 14:02:32 +00:00
|
|
|
cpoints = merged_comments.where.not(user_id: user_id).sum(sum_expression).to_f * 0.5
|
2014-09-16 15:53:25 +00:00
|
|
|
|
2015-12-31 15:14:09 +00:00
|
|
|
# mix in any stories this one cannibalized
|
2018-03-18 05:51:58 +00:00
|
|
|
cpoints += merged_stories.map(&:score).inject(&:+).to_f
|
2015-12-31 15:14:09 +00:00
|
|
|
|
2017-05-04 15:28:41 +00:00
|
|
|
# if a story has many comments but few votes, it's probably a bad story, so
|
|
|
|
# cap the comment points at the number of upvotes
|
2020-05-12 02:52:05 +00:00
|
|
|
upvotes = self.score + flags
|
|
|
|
if cpoints > upvotes
|
|
|
|
cpoints = upvotes
|
2017-05-04 15:28:41 +00:00
|
|
|
end
|
|
|
|
|
2014-03-18 05:06:43 +00:00
|
|
|
# don't immediately kill stories at 0 by bumping up score by one
|
2018-03-14 14:11:03 +00:00
|
|
|
order = Math.log([(score + 1).abs + cpoints, 1].max, 10)
|
2014-02-17 16:07:36 +00:00
|
|
|
sign = if score > 0
|
|
|
|
1
|
|
|
|
elsif score < 0
|
|
|
|
-1
|
2012-09-02 14:50:07 +00:00
|
|
|
else
|
2014-02-17 16:07:36 +00:00
|
|
|
0
|
2012-09-02 14:50:07 +00:00
|
|
|
end
|
|
|
|
|
2016-07-20 01:05:50 +00:00
|
|
|
-((order * sign) + base +
|
2018-03-19 00:07:16 +00:00
|
|
|
((created_at || Time.current).to_f / HOTNESS_WINDOW)).round(7)
|
2012-09-02 14:50:07 +00:00
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def can_be_seen_by_user?(user)
|
2020-08-26 01:35:03 +00:00
|
|
|
!is_gone? || (user && (user.is_moderator? || user.id == user_id))
|
2012-07-01 18:38:01 +00:00
|
|
|
end
|
|
|
|
|
2019-03-05 05:11:38 +00:00
|
|
|
def can_have_images?
|
|
|
|
# doesn't test self.editor so a user can't trick a mod into editing a
|
|
|
|
# story to enable an image
|
|
|
|
user.try(:is_moderator?)
|
|
|
|
end
|
|
|
|
|
2015-10-15 14:38:26 +00:00
|
|
|
def can_have_suggestions_from_user?(user)
|
2016-05-02 22:57:19 +00:00
|
|
|
if !user || (user.id == user_id) || !user.can_offer_suggestions?
|
2015-10-15 14:38:26 +00:00
|
|
|
return false
|
|
|
|
end
|
2022-02-07 03:54:54 +00:00
|
|
|
return false if is_moderated?
|
2015-10-17 15:38:48 +00:00
|
|
|
|
2020-08-28 13:40:11 +00:00
|
|
|
tags.each { |t| return false if t.privileged? }
|
2015-10-17 15:38:48 +00:00
|
|
|
true
|
2015-10-15 14:38:26 +00:00
|
|
|
end
|
|
|
|
|
2012-11-12 17:02:18 +00:00
|
|
|
# this has to happen just before save rather than in tags_a= because we need
|
2022-01-29 16:53:31 +00:00
|
|
|
# to have a valid user_id; remember it fills .taggings, not .tags
|
2012-09-20 02:13:20 +00:00
|
|
|
def check_tags
|
2014-03-06 19:54:30 +00:00
|
|
|
u = editor || user
|
|
|
|
|
2022-01-29 16:53:31 +00:00
|
|
|
if u&.is_new? &&
|
|
|
|
(unpermitted = taggings.filter { |t| !t.tag.permit_by_new_users? }).any?
|
|
|
|
tags = unpermitted.map { |t| t.tag.tag }.to_sentence
|
|
|
|
errors.add :base, <<-EXPLANATION
|
|
|
|
New users can't submit stories with the tag(s) #{tags}
|
|
|
|
because they're for meta discussion or prone to off-topic stories.
|
|
|
|
If a tag is appropriate for the story, leaving it off to skirt this
|
|
|
|
restriction can earn a ban.
|
|
|
|
EXPLANATION
|
|
|
|
ModNote.tattle_on_new_user_tagging!(self)
|
|
|
|
return
|
|
|
|
end
|
|
|
|
|
2012-11-26 19:39:16 +00:00
|
|
|
taggings.each do |t|
|
2014-03-06 19:54:30 +00:00
|
|
|
if !t.tag.valid_for?(u)
|
2018-03-14 15:15:35 +00:00
|
|
|
raise "#{u.username} does not have permission to use privileged tag #{t.tag.tag}"
|
2020-08-09 04:46:42 +00:00
|
|
|
elsif !t.tag.active? && t.new_record? && !t.marked_for_destruction?
|
2014-02-21 16:51:48 +00:00
|
|
|
# stories can have inactive tags as long as they existed before
|
2014-03-06 19:54:30 +00:00
|
|
|
raise "#{u.username} cannot add inactive tag #{t.tag.tag}"
|
2012-09-20 02:13:20 +00:00
|
|
|
end
|
|
|
|
end
|
2012-11-12 17:02:18 +00:00
|
|
|
|
2018-03-19 01:21:55 +00:00
|
|
|
if taggings.reject { |t| t.marked_for_destruction? || t.tag.is_media? }.empty?
|
2013-02-14 00:50:51 +00:00
|
|
|
errors.add(:base, "Must have at least one non-media (PDF, video) " \
|
|
|
|
"tag. If no tags apply to your content, it probably doesn't " \
|
|
|
|
"belong here.")
|
2012-11-12 17:02:18 +00:00
|
|
|
end
|
2012-09-20 02:13:20 +00:00
|
|
|
end
|
|
|
|
|
2015-01-02 23:02:55 +00:00
|
|
|
def comments_path
|
|
|
|
"#{short_id_path}/#{title_as_url}"
|
|
|
|
end
|
|
|
|
|
2012-07-03 16:59:50 +00:00
|
|
|
def comments_url
|
|
|
|
"#{short_id_url}/#{title_as_url}"
|
2012-07-02 17:13:12 +00:00
|
|
|
end
|
2013-01-23 06:15:05 +00:00
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def description=(desc)
|
|
|
|
self[:description] = desc.to_s.rstrip
|
|
|
|
self.markeddown_description = generated_markeddown_description
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|
|
|
|
|
2020-08-25 12:51:09 +00:00
|
|
|
def description_or_story_text(chars = 0)
|
2015-07-30 23:12:57 +00:00
|
|
|
s = if description.present?
|
2015-07-30 22:56:09 +00:00
|
|
|
markeddown_description.gsub(/<[^>]*>/, "")
|
|
|
|
else
|
2020-08-25 14:39:05 +00:00
|
|
|
story_text&.body
|
2015-07-30 22:56:09 +00:00
|
|
|
end
|
2015-07-30 23:12:57 +00:00
|
|
|
|
2017-06-27 18:14:43 +00:00
|
|
|
if chars > 0 && s.to_s.length > chars
|
2015-07-30 23:12:57 +00:00
|
|
|
# remove last truncated word
|
2015-07-31 13:03:10 +00:00
|
|
|
s = s.to_s[0, chars].gsub(/ [^ ]*\z/, "")
|
2015-07-30 23:12:57 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
HTMLEntities.new.decode(s.to_s)
|
2015-07-30 22:56:09 +00:00
|
|
|
end
|
|
|
|
|
2015-01-29 16:04:02 +00:00
|
|
|
def domain_search_url
|
2018-04-18 20:13:44 +00:00
|
|
|
"/search?order=newest&q=domain:#{domain}"
|
2015-01-29 16:04:02 +00:00
|
|
|
end
|
|
|
|
|
2017-04-11 21:46:38 +00:00
|
|
|
def fix_bogus_chars
|
|
|
|
# this is needlessly complicated to work around character encoding issues
|
|
|
|
# that arise when doing just self.title.to_s.gsub(160.chr, "")
|
2018-03-13 02:47:24 +00:00
|
|
|
self.title = title.to_s.chars.map { |chr|
|
2017-04-11 21:46:38 +00:00
|
|
|
if chr.ord == 160
|
|
|
|
" "
|
|
|
|
else
|
|
|
|
chr
|
|
|
|
end
|
|
|
|
}.join("")
|
|
|
|
|
|
|
|
true
|
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def generated_markeddown_description
|
2019-03-05 05:11:38 +00:00
|
|
|
Markdowner.to_html(description, allow_images: can_have_images?)
|
2014-02-17 16:07:36 +00:00
|
|
|
end
|
|
|
|
|
2020-05-12 02:52:05 +00:00
|
|
|
# TODO: race condition: if two votes arrive at the same time, the second one
|
|
|
|
# won't take the first's score change into effect for calculated_hotness
|
|
|
|
def update_score_and_recalculate!(score_delta, flag_delta)
|
|
|
|
self.score += score_delta
|
|
|
|
self.flags += flag_delta
|
|
|
|
Story.connection.execute <<~SQL
|
|
|
|
UPDATE stories SET
|
2020-09-14 13:31:09 +00:00
|
|
|
score = (select coalesce(sum(vote), 0) from votes where story_id = stories.id and comment_id is null),
|
2020-05-12 02:52:05 +00:00
|
|
|
flags = (select count(*) from votes where story_id = stories.id and comment_id is null and vote = -1),
|
|
|
|
hotness = #{calculated_hotness}
|
|
|
|
WHERE id = #{id.to_i}
|
|
|
|
SQL
|
2014-02-17 16:07:36 +00:00
|
|
|
end
|
|
|
|
|
2017-03-09 17:21:38 +00:00
|
|
|
def has_suggestions?
|
2022-03-04 15:18:53 +00:00
|
|
|
suggested_taggings.any? || suggested_titles.any?
|
2017-03-09 17:21:38 +00:00
|
|
|
end
|
|
|
|
|
2014-03-13 15:51:12 +00:00
|
|
|
def hider_count
|
2015-02-11 17:37:03 +00:00
|
|
|
@hider_count ||= HiddenStory.where(story_id: id).count
|
2014-03-13 15:51:12 +00:00
|
|
|
end
|
|
|
|
|
2020-05-12 02:52:05 +00:00
|
|
|
def is_flaggable?
|
|
|
|
if created_at && self.score > FLAGGABLE_MIN_SCORE
|
|
|
|
Time.current - created_at <= FLAGGABLE_DAYS.days
|
2014-03-18 05:06:43 +00:00
|
|
|
else
|
|
|
|
false
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def is_editable_by_user?(user)
|
|
|
|
if user&.is_moderator?
|
|
|
|
true
|
|
|
|
elsif user && user.id == user_id
|
|
|
|
if is_moderated?
|
|
|
|
false
|
|
|
|
else
|
2018-03-19 00:07:16 +00:00
|
|
|
(Time.current.to_i - created_at.to_i < (60 * MAX_EDIT_MINS))
|
2014-02-17 16:07:36 +00:00
|
|
|
end
|
2012-06-17 01:15:46 +00:00
|
|
|
else
|
2014-02-17 16:07:36 +00:00
|
|
|
false
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|
2014-02-17 16:07:36 +00:00
|
|
|
end
|
2012-06-17 01:15:46 +00:00
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def is_gone?
|
2022-02-07 03:38:50 +00:00
|
|
|
is_deleted? || (user.is_banned? && score < 0)
|
2014-02-17 16:07:36 +00:00
|
|
|
end
|
2012-07-10 16:53:05 +00:00
|
|
|
|
2015-02-11 17:37:03 +00:00
|
|
|
def is_hidden_by_user?(user)
|
2018-03-17 19:00:46 +00:00
|
|
|
!!HiddenStory.find_by(user_id: user.id, story_id: id)
|
2015-02-11 17:37:03 +00:00
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def is_recent?
|
|
|
|
created_at >= RECENT_DAYS.days.ago
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|
2012-12-17 02:00:41 +00:00
|
|
|
|
2017-07-13 20:38:33 +00:00
|
|
|
def is_saved_by_user?(user)
|
2018-03-17 19:00:46 +00:00
|
|
|
!!SavedStory.find_by(user_id: user.id, story_id: id)
|
2017-07-13 20:38:33 +00:00
|
|
|
end
|
|
|
|
|
2015-01-06 20:22:18 +00:00
|
|
|
def is_unavailable
|
|
|
|
!unavailable_at.nil?
|
|
|
|
end
|
2018-03-02 04:50:11 +00:00
|
|
|
|
2015-01-06 20:22:18 +00:00
|
|
|
def is_unavailable=(what)
|
2018-03-19 00:07:16 +00:00
|
|
|
self.unavailable_at = ((what.to_i == 1 && !is_unavailable) ? Time.current : nil)
|
2015-01-06 20:22:18 +00:00
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def is_undeletable_by_user?(user)
|
|
|
|
if user&.is_moderator?
|
|
|
|
true
|
|
|
|
elsif user && user.id == user_id && !is_moderated?
|
|
|
|
true
|
|
|
|
else
|
|
|
|
false
|
|
|
|
end
|
2012-12-17 02:00:41 +00:00
|
|
|
end
|
2013-01-23 06:15:05 +00:00
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def log_moderation
|
2015-10-15 15:02:26 +00:00
|
|
|
if new_record? ||
|
2018-03-14 15:15:35 +00:00
|
|
|
(!editing_from_suggestions && (!editor || editor.id == user_id))
|
2014-02-17 16:07:36 +00:00
|
|
|
return
|
2012-12-09 04:37:30 +00:00
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
all_changes = changes.merge(tagging_changes)
|
2023-08-30 16:10:59 +00:00
|
|
|
all_changes.delete("normalized_url")
|
2015-01-06 20:22:18 +00:00
|
|
|
all_changes.delete("unavailable_at")
|
|
|
|
|
|
|
|
if !all_changes.any?
|
|
|
|
return
|
|
|
|
end
|
2012-06-17 01:15:46 +00:00
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
m = Moderation.new
|
2015-10-15 15:02:26 +00:00
|
|
|
if editing_from_suggestions
|
|
|
|
m.is_from_suggestions = true
|
|
|
|
else
|
|
|
|
m.moderator_user_id = editor.try(:id)
|
|
|
|
end
|
2014-02-17 16:07:36 +00:00
|
|
|
m.story_id = id
|
2012-06-17 01:15:46 +00:00
|
|
|
|
2021-12-06 18:15:15 +00:00
|
|
|
m.action = all_changes.map { |k, v|
|
2022-02-07 03:38:50 +00:00
|
|
|
if k == "is_deleted" && is_deleted?
|
2021-12-06 18:15:15 +00:00
|
|
|
"deleted story"
|
2022-02-07 03:38:50 +00:00
|
|
|
elsif k == "is_deleted" && !is_deleted?
|
2021-12-06 18:15:15 +00:00
|
|
|
"undeleted story"
|
|
|
|
elsif k == "merged_story_id"
|
|
|
|
if v[1]
|
|
|
|
"merged into #{merged_into_story.short_id} " \
|
|
|
|
"(#{merged_into_story.title})"
|
2014-04-08 22:51:12 +00:00
|
|
|
else
|
2021-12-06 18:15:15 +00:00
|
|
|
"unmerged from another story"
|
2014-04-08 22:51:12 +00:00
|
|
|
end
|
2021-12-06 18:15:15 +00:00
|
|
|
else
|
|
|
|
"changed #{k} from #{v[0].inspect} to #{v[1].inspect}"
|
|
|
|
end
|
|
|
|
}.join(", ")
|
2014-02-17 16:07:36 +00:00
|
|
|
|
|
|
|
m.reason = moderation_reason
|
|
|
|
m.save
|
|
|
|
|
|
|
|
self.is_moderated = true
|
2012-07-05 15:08:14 +00:00
|
|
|
end
|
|
|
|
|
2013-06-25 18:58:52 +00:00
|
|
|
def mailing_list_message_id
|
2013-06-30 06:29:51 +00:00
|
|
|
"story.#{short_id}.#{created_at.to_i}@#{Rails.application.domain}"
|
2013-06-25 18:58:52 +00:00
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def mark_submitter
|
|
|
|
Keystore.increment_value_for("user:#{user_id}:stories_submitted")
|
|
|
|
end
|
|
|
|
|
2023-08-24 01:11:09 +00:00
|
|
|
# unordered, use Comment.thread_sorted_comments for presenting threads
|
2014-04-08 22:51:12 +00:00
|
|
|
def merged_comments
|
2023-08-23 16:17:48 +00:00
|
|
|
return Comment.none unless id # unsaved Stories have no comments
|
|
|
|
|
2023-08-24 01:11:09 +00:00
|
|
|
Comment.joins(:story)
|
|
|
|
.where(story: {merged_story_id: id})
|
|
|
|
.or(Comment.where(story_id: id))
|
2014-04-08 22:51:12 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def merge_story_short_id=(sid)
|
2018-03-17 19:00:46 +00:00
|
|
|
self.merged_story_id = sid.present? ? Story.where(short_id: sid).pluck(:id).first : nil
|
2014-04-08 22:51:12 +00:00
|
|
|
end
|
|
|
|
|
2017-06-18 16:31:16 +00:00
|
|
|
def merge_story_short_id
|
|
|
|
merged_story_id ? merged_into_story.try(:short_id) : nil
|
|
|
|
end
|
|
|
|
|
2014-03-03 23:13:00 +00:00
|
|
|
def record_initial_upvote
|
2018-03-14 16:44:26 +00:00
|
|
|
Vote.vote_thusly_on_story_or_comment_for_user_because(1, id, nil, user_id, nil, false)
|
2014-03-03 23:13:00 +00:00
|
|
|
end
|
|
|
|
|
2015-01-02 23:02:55 +00:00
|
|
|
def short_id_path
|
|
|
|
Rails.application.routes.url_helpers.root_path + "s/#{short_id}"
|
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def short_id_url
|
2015-01-02 23:02:55 +00:00
|
|
|
Rails.application.root_url + "s/#{short_id}"
|
2014-02-17 16:07:36 +00:00
|
|
|
end
|
|
|
|
|
2019-08-14 14:09:15 +00:00
|
|
|
def show_score_to_user?(u)
|
2023-09-13 23:26:17 +00:00
|
|
|
u&.is_moderator? || !current_flagged?
|
2019-08-14 14:09:15 +00:00
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def tagging_changes
|
2018-03-19 19:20:24 +00:00
|
|
|
old_tags_a = taggings.reject(&:new_record?).map { |tg| tg.tag.tag }.join(" ")
|
2018-03-18 05:51:58 +00:00
|
|
|
new_tags_a = taggings.reject(&:marked_for_destruction?).map { |tg| tg.tag.tag }.join(" ")
|
2014-02-17 16:07:36 +00:00
|
|
|
|
|
|
|
if old_tags_a == new_tags_a
|
|
|
|
{}
|
|
|
|
else
|
2018-03-14 14:11:03 +00:00
|
|
|
{"tags" => [old_tags_a, new_tags_a]}
|
2014-02-17 16:07:36 +00:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2012-06-17 01:15:46 +00:00
|
|
|
def tags_a
|
2018-03-18 05:51:58 +00:00
|
|
|
@_tags_a ||= taggings.reject(&:marked_for_destruction?).map { |t| t.tag.tag }
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|
2012-06-30 22:41:00 +00:00
|
|
|
|
2012-11-26 19:39:16 +00:00
|
|
|
def tags_a=(new_tag_names_a)
|
|
|
|
taggings.each do |tagging|
|
|
|
|
if !new_tag_names_a.include?(tagging.tag.tag)
|
|
|
|
tagging.mark_for_destruction
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-06-29 02:51:12 +00:00
|
|
|
new_tag_names_a.uniq.each do |tag_name|
|
2013-12-24 03:20:06 +00:00
|
|
|
if tag_name.to_s != "" && !tags.exists?(tag: tag_name)
|
2018-03-17 19:00:46 +00:00
|
|
|
if (t = Tag.active.find_by(tag: tag_name))
|
2012-11-26 19:39:16 +00:00
|
|
|
# we can't lookup whether the user is allowed to use this tag yet
|
|
|
|
# because we aren't assured to have a user_id by now; we'll do it in
|
|
|
|
# the validation with check_tags
|
2019-02-16 21:25:41 +00:00
|
|
|
taggings.build(tag_id: t.id)
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2013-01-23 06:15:05 +00:00
|
|
|
|
2015-10-15 01:32:24 +00:00
|
|
|
def save_suggested_tags_a_for_user!(new_tag_names_a, user)
|
|
|
|
st = suggested_taggings.where(user_id: user.id)
|
|
|
|
|
|
|
|
st.each do |tagging|
|
|
|
|
if !new_tag_names_a.include?(tagging.tag.tag)
|
|
|
|
tagging.destroy
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
st.reload
|
|
|
|
|
|
|
|
new_tag_names_a.each do |tag_name|
|
|
|
|
# XXX: AR bug? st.exists?(:tag => tag_name) does not work
|
2018-03-13 02:47:24 +00:00
|
|
|
if tag_name.to_s != "" && !st.map { |x| x.tag.tag }.include?(tag_name)
|
2018-03-17 19:00:46 +00:00
|
|
|
if (t = Tag.active.find_by(tag: tag_name)) &&
|
2018-03-14 15:15:35 +00:00
|
|
|
t.valid_for?(user)
|
2015-10-15 01:32:24 +00:00
|
|
|
tg = suggested_taggings.build
|
|
|
|
tg.user_id = user.id
|
|
|
|
tg.tag_id = t.id
|
|
|
|
tg.save!
|
|
|
|
|
|
|
|
st.reload
|
|
|
|
else
|
|
|
|
next
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-10-15 15:02:26 +00:00
|
|
|
# if enough users voted on the same set of replacement tags, do it
|
|
|
|
tag_votes = {}
|
2018-03-02 13:19:50 +00:00
|
|
|
suggested_taggings.group_by(&:user_id).each do |_u, stg|
|
2018-03-19 20:55:42 +00:00
|
|
|
stg.each do |s|
|
|
|
|
tag_votes[s.tag.tag] ||= 0
|
|
|
|
tag_votes[s.tag.tag] += 1
|
2015-10-15 15:02:26 +00:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
final_tags = []
|
2018-03-19 01:15:01 +00:00
|
|
|
tag_votes.each do |k, v|
|
2015-10-15 15:02:26 +00:00
|
|
|
if v >= SUGGESTION_QUORUM
|
|
|
|
final_tags.push k
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
if final_tags.any? && (final_tags.sort != tags_a.sort)
|
|
|
|
Rails.logger.info "[s#{id}] promoting suggested tags " \
|
2018-03-14 15:15:35 +00:00
|
|
|
"#{final_tags.inspect} instead of #{tags_a.inspect}"
|
2015-10-15 15:02:26 +00:00
|
|
|
self.editor = nil
|
|
|
|
self.editing_from_suggestions = true
|
|
|
|
self.moderation_reason = "Automatically changed from user suggestions"
|
|
|
|
self.tags_a = final_tags.sort
|
|
|
|
if !save
|
|
|
|
Rails.logger.error "[s#{id}] failed auto promoting: " <<
|
2018-03-14 15:15:35 +00:00
|
|
|
errors.inspect
|
2015-10-15 15:02:26 +00:00
|
|
|
end
|
|
|
|
end
|
2015-10-15 01:32:24 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def save_suggested_title_for_user!(title, user)
|
2018-03-17 19:00:46 +00:00
|
|
|
st = suggested_titles.find_by(user_id: user.id)
|
2015-10-15 01:32:24 +00:00
|
|
|
if !st
|
|
|
|
st = suggested_titles.build
|
|
|
|
st.user_id = user.id
|
|
|
|
end
|
|
|
|
st.title = title
|
|
|
|
st.save!
|
2015-10-16 23:28:57 +00:00
|
|
|
|
|
|
|
# if enough users voted on the same exact title, save it
|
|
|
|
title_votes = {}
|
2018-03-19 20:55:42 +00:00
|
|
|
suggested_titles.each do |s|
|
|
|
|
title_votes[s.title] ||= 0
|
|
|
|
title_votes[s.title] += 1
|
2015-10-16 23:28:57 +00:00
|
|
|
end
|
|
|
|
|
2018-08-15 14:44:11 +00:00
|
|
|
title_votes.sort_by { |_k, v| v }.reverse_each do |kv|
|
2015-10-16 23:28:57 +00:00
|
|
|
if kv[1] >= SUGGESTION_QUORUM
|
|
|
|
Rails.logger.info "[s#{id}] promoting suggested title " \
|
2018-03-14 15:15:35 +00:00
|
|
|
"#{kv[0].inspect} instead of #{self.title.inspect}"
|
2015-10-16 23:28:57 +00:00
|
|
|
self.editor = nil
|
|
|
|
self.editing_from_suggestions = true
|
|
|
|
self.moderation_reason = "Automatically changed from user suggestions"
|
|
|
|
self.title = kv[0]
|
|
|
|
if !save
|
|
|
|
Rails.logger.error "[s#{id}] failed auto promoting: " <<
|
2018-03-14 15:15:35 +00:00
|
|
|
errors.inspect
|
2015-10-16 23:28:57 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
break
|
|
|
|
end
|
|
|
|
end
|
2015-10-15 01:32:24 +00:00
|
|
|
end
|
|
|
|
|
2012-06-30 16:18:36 +00:00
|
|
|
def title=(t)
|
|
|
|
# change unicode whitespace characters into real spaces
|
2020-09-11 03:33:14 +00:00
|
|
|
self[:title] = t.to_s.strip.gsub(/[\.,;:!]*$/, "")
|
2012-06-30 16:18:36 +00:00
|
|
|
end
|
|
|
|
|
2012-06-30 22:41:00 +00:00
|
|
|
def title_as_url
|
2016-05-04 19:40:06 +00:00
|
|
|
max_len = 35
|
|
|
|
wl = 0
|
|
|
|
words = []
|
|
|
|
|
2018-03-14 14:11:03 +00:00
|
|
|
title
|
|
|
|
.parameterize
|
|
|
|
.gsub(/[^a-z0-9]/, "_")
|
|
|
|
.split("_")
|
2018-03-14 16:44:26 +00:00
|
|
|
.reject { |z| TITLE_DROP_WORDS.include?(z) }
|
2018-03-14 14:11:03 +00:00
|
|
|
.each do |w|
|
2016-05-04 19:40:06 +00:00
|
|
|
if wl + w.length <= max_len
|
|
|
|
words.push w
|
|
|
|
wl += w.length
|
|
|
|
else
|
|
|
|
if wl == 0
|
|
|
|
words.push w[0, max_len]
|
|
|
|
end
|
|
|
|
break
|
|
|
|
end
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|
2016-05-04 19:40:06 +00:00
|
|
|
|
2018-03-14 13:01:14 +00:00
|
|
|
if words.empty?
|
2016-05-04 19:40:06 +00:00
|
|
|
words.push "_"
|
|
|
|
end
|
|
|
|
|
|
|
|
words.join("_").gsub("_-_", "-")
|
2012-06-30 22:41:00 +00:00
|
|
|
end
|
2012-06-17 01:15:46 +00:00
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def to_param
|
|
|
|
short_id
|
|
|
|
end
|
|
|
|
|
2015-01-06 20:22:18 +00:00
|
|
|
def update_availability
|
|
|
|
if is_unavailable && !unavailable_at
|
2018-03-19 00:07:16 +00:00
|
|
|
self.unavailable_at = Time.current
|
2015-01-06 20:22:18 +00:00
|
|
|
elsif unavailable_at && !is_unavailable
|
|
|
|
self.unavailable_at = nil
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2023-10-07 04:32:31 +00:00
|
|
|
def update_cached_columns
|
|
|
|
update_column :comments_count, merged_comments.active.count
|
|
|
|
merged_into_story&.update_cached_columns
|
2014-02-17 16:07:36 +00:00
|
|
|
|
2023-10-07 04:32:31 +00:00
|
|
|
update_column :hotness, calculated_hotness
|
2014-04-08 22:51:12 +00:00
|
|
|
end
|
|
|
|
|
2023-09-06 15:49:24 +00:00
|
|
|
def update_story_text
|
|
|
|
return unless saved_change_to_attribute?(:title) || saved_change_to_attribute?(:description)
|
|
|
|
|
|
|
|
# story_text created by cron job, so ignore missing story_text
|
|
|
|
story_text.try(:update!, title: title, description: description)
|
|
|
|
end
|
|
|
|
|
2020-05-16 19:19:22 +00:00
|
|
|
# disincentivize content marketers by not appearing to be a source of
|
|
|
|
# significant traffic, but do show referrer a few times so authors can find
|
|
|
|
# their way back
|
|
|
|
def send_referrer?
|
2020-05-18 01:52:55 +00:00
|
|
|
created_at && created_at <= 1.hour && merged_story_id.nil?
|
2020-05-16 19:19:22 +00:00
|
|
|
end
|
|
|
|
|
2020-01-22 14:30:31 +00:00
|
|
|
def set_domain(match)
|
|
|
|
name = match ? match[:domain].sub(/^www\d*\./, "") : nil
|
2020-02-12 12:07:27 +00:00
|
|
|
self.domain = name ? Domain.where(domain: name).first_or_initialize : nil
|
2018-04-19 17:37:01 +00:00
|
|
|
end
|
|
|
|
|
2013-03-26 17:22:23 +00:00
|
|
|
def url=(u)
|
2018-07-11 03:26:06 +00:00
|
|
|
super(u.try(:strip)) or return if u.blank?
|
2018-04-18 20:13:44 +00:00
|
|
|
|
|
|
|
if (match = u.match(URL_RE))
|
2018-04-18 22:38:18 +00:00
|
|
|
# remove well-known port for http and https if present
|
2018-04-18 20:13:44 +00:00
|
|
|
@url_port = match[:port]
|
|
|
|
if match[:protocol] == "http" && match[:port] == ":80" ||
|
|
|
|
match[:protocol] == "https" && match[:port] == ":443"
|
|
|
|
u = u[0...match.begin(3)] + u[match.end(3)..]
|
|
|
|
@url_port = nil
|
|
|
|
end
|
|
|
|
end
|
2020-01-22 14:30:31 +00:00
|
|
|
set_domain(match)
|
2018-04-18 20:13:44 +00:00
|
|
|
|
2018-10-18 19:15:34 +00:00
|
|
|
# strip out tracking query params
|
2018-04-18 20:13:44 +00:00
|
|
|
if (match = u.match(/\A([^\?]+)\?(.+)\z/))
|
|
|
|
params = match[2].split(/[&\?]/)
|
2018-10-18 19:15:34 +00:00
|
|
|
# utm_ is google and many others; sk is medium
|
2022-01-19 03:52:59 +00:00
|
|
|
params.reject! { |p|
|
|
|
|
p.match(/^utm_(source|medium|campaign|term|content|referrer)=|^sk=|^gclid=|^fbclid=/x)
|
|
|
|
}
|
2018-04-18 20:13:44 +00:00
|
|
|
u = match[1] << (params.any? ? "?" << params.join("&") : "")
|
2013-03-26 17:22:23 +00:00
|
|
|
end
|
|
|
|
|
2023-08-28 22:15:58 +00:00
|
|
|
self.normalized_url = Utils.normalize_url(u)
|
2018-04-18 20:13:44 +00:00
|
|
|
super(u)
|
2013-03-26 17:22:23 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def url_is_editable_by_user?(user)
|
|
|
|
if new_record?
|
|
|
|
true
|
|
|
|
else
|
|
|
|
user&.is_moderator?
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-01-02 23:02:55 +00:00
|
|
|
def url_or_comments_path
|
2018-03-19 00:43:57 +00:00
|
|
|
url.presence || comments_path
|
2015-01-02 23:02:55 +00:00
|
|
|
end
|
|
|
|
|
2014-02-17 16:07:36 +00:00
|
|
|
def url_or_comments_url
|
2018-03-19 00:43:57 +00:00
|
|
|
url.presence || comments_url
|
2012-07-05 00:33:12 +00:00
|
|
|
end
|
2014-03-18 05:06:43 +00:00
|
|
|
|
|
|
|
def vote_summary_for(user)
|
|
|
|
r_counts = {}
|
|
|
|
r_whos = {}
|
2018-09-11 19:44:16 +00:00
|
|
|
votes.includes((user && user.is_moderator?) ? :user : nil).find_each do |v|
|
2018-06-23 03:48:32 +00:00
|
|
|
next if v.vote == 0
|
2014-03-18 05:06:43 +00:00
|
|
|
r_counts[v.reason.to_s] ||= 0
|
|
|
|
r_counts[v.reason.to_s] += v.vote
|
|
|
|
if user&.is_moderator?
|
|
|
|
r_whos[v.reason.to_s] ||= []
|
|
|
|
r_whos[v.reason.to_s].push v.user.username
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2018-03-13 02:47:24 +00:00
|
|
|
r_counts.keys.sort.map { |k|
|
2014-03-18 05:06:43 +00:00
|
|
|
if k == ""
|
|
|
|
"+#{r_counts[k]}"
|
|
|
|
else
|
|
|
|
"#{r_counts[k]} " +
|
2020-08-09 00:45:55 +00:00
|
|
|
(Vote::ALL_STORY_REASONS[k] || k) +
|
2018-03-18 00:00:47 +00:00
|
|
|
((user && user.is_moderator?) ? " (#{r_whos[k].join(", ")})" : "")
|
2014-03-18 05:06:43 +00:00
|
|
|
end
|
|
|
|
}.join(", ")
|
|
|
|
end
|
2015-03-10 22:41:40 +00:00
|
|
|
|
2020-03-03 02:34:01 +00:00
|
|
|
def fetched_attributes_html
|
|
|
|
converted = @fetched_response.body.force_encoding("utf-8")
|
|
|
|
parsed = Nokogiri::HTML(converted.to_s)
|
2015-03-10 22:41:40 +00:00
|
|
|
|
2015-12-02 17:01:27 +00:00
|
|
|
# parse best title from html tags
|
2015-03-10 22:41:40 +00:00
|
|
|
# try <meta property="og:title"> first, it probably won't have the site
|
|
|
|
# name
|
2015-12-02 17:01:27 +00:00
|
|
|
title = ""
|
2015-03-10 22:41:40 +00:00
|
|
|
begin
|
2018-03-02 05:01:39 +00:00
|
|
|
title = parsed.at_css("meta[property='og:title']")
|
|
|
|
.attributes["content"].text
|
2015-03-10 22:41:40 +00:00
|
|
|
rescue
|
|
|
|
end
|
|
|
|
|
|
|
|
# then try <meta name="title">
|
|
|
|
if title.to_s == ""
|
|
|
|
begin
|
2015-12-02 17:01:27 +00:00
|
|
|
title = parsed.at_css("meta[name='title']").attributes["content"].text
|
2015-03-10 22:41:40 +00:00
|
|
|
rescue
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# then try plain old <title>
|
|
|
|
if title.to_s == ""
|
2015-12-02 17:01:27 +00:00
|
|
|
title = parsed.at_css("title").try(:text).to_s
|
2015-03-10 22:41:40 +00:00
|
|
|
end
|
|
|
|
|
2017-04-26 19:09:35 +00:00
|
|
|
# see if the site name is available, so we can strip it out in case it was
|
|
|
|
# present in the fetched title
|
|
|
|
begin
|
2018-03-02 05:01:39 +00:00
|
|
|
site_name = parsed.at_css("meta[property='og:site_name']")
|
|
|
|
.attributes["content"].text
|
2017-04-26 19:09:35 +00:00
|
|
|
|
2018-03-14 15:15:35 +00:00
|
|
|
if site_name.present? &&
|
|
|
|
site_name.length < title.length &&
|
|
|
|
title[-site_name.length, site_name.length] == site_name
|
2017-04-26 19:09:35 +00:00
|
|
|
title = title[0, title.length - site_name.length]
|
|
|
|
|
|
|
|
# remove title/site name separator
|
|
|
|
if / [ \-\|\u2013] $/.match?(title)
|
|
|
|
title = title[0, title.length - 3]
|
|
|
|
end
|
|
|
|
end
|
|
|
|
rescue
|
|
|
|
end
|
|
|
|
|
2015-12-02 17:01:27 +00:00
|
|
|
@fetched_attributes[:title] = title
|
2015-03-10 22:41:40 +00:00
|
|
|
|
2023-02-21 15:54:01 +00:00
|
|
|
# strip off common GitHub site + repo owner
|
|
|
|
@fetched_attributes[:title].sub!(/GitHub - [a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}\//i, "")
|
|
|
|
|
2019-10-28 00:59:28 +00:00
|
|
|
# attempt to get the canonical url if it can be parsed,
|
|
|
|
# if it is not the domain root path, and if it
|
|
|
|
# responds to GET with a 200-level code
|
2015-12-02 17:01:27 +00:00
|
|
|
begin
|
2019-10-28 00:59:28 +00:00
|
|
|
cu = canonical_target(parsed)
|
|
|
|
@fetched_attributes[:url] = cu if valid_canonical_uri?(cu)
|
2015-12-02 17:01:27 +00:00
|
|
|
rescue
|
2015-03-10 22:41:40 +00:00
|
|
|
end
|
|
|
|
|
2015-12-02 17:01:27 +00:00
|
|
|
@fetched_attributes
|
2015-03-10 22:41:40 +00:00
|
|
|
end
|
2019-10-28 00:59:28 +00:00
|
|
|
|
2020-03-03 02:34:01 +00:00
|
|
|
def fetched_attributes_pdf
|
2020-03-04 13:58:17 +00:00
|
|
|
return @fetched_attributes = {} if @fetched_response.body.length >= 5.megabytes
|
2020-03-03 02:34:01 +00:00
|
|
|
|
|
|
|
# pdf-reader only accepts a stream or filename
|
|
|
|
pdf_stream = StringIO.new(@fetched_response.body)
|
|
|
|
pdf = PDF::Reader.new(pdf_stream)
|
|
|
|
|
|
|
|
title = pdf.info[:Title]
|
|
|
|
|
|
|
|
@fetched_attributes[:title] = title
|
|
|
|
@fetched_attributes
|
|
|
|
end
|
|
|
|
|
|
|
|
def fetched_attributes
|
|
|
|
return @fetched_attributes if @fetched_attributes
|
|
|
|
|
|
|
|
@fetched_attributes = {
|
|
|
|
url: url,
|
|
|
|
title: ""
|
|
|
|
}
|
|
|
|
|
|
|
|
# security: do not connect to arbitrary user-submitted ports
|
|
|
|
return @fetched_attributes if @url_port
|
|
|
|
|
|
|
|
begin
|
|
|
|
# if we haven't had a test inject a response into us
|
|
|
|
if !@fetched_response
|
|
|
|
s = Sponge.new
|
|
|
|
s.timeout = 3
|
|
|
|
# User submitted URLs may have an incorrect https certificate, but we
|
|
|
|
# don't want to fail the retrieval for this. Security risk is minimal.
|
|
|
|
s.ssl_verify = false
|
2020-05-16 19:19:22 +00:00
|
|
|
headers = {
|
|
|
|
"User-agent" => "#{Rails.application.domain} for #{fetching_ip}",
|
|
|
|
"Referer" => Rails.application.domain
|
|
|
|
}
|
|
|
|
res = s.fetch(url, :get, nil, nil, headers, 3)
|
2020-03-03 02:34:01 +00:00
|
|
|
@fetched_response = res
|
|
|
|
end
|
|
|
|
|
|
|
|
case @fetched_response["content-type"]
|
|
|
|
when /pdf/
|
|
|
|
fetched_attributes_pdf
|
|
|
|
else
|
|
|
|
fetched_attributes_html
|
|
|
|
end
|
|
|
|
rescue
|
|
|
|
@fetched_attributes
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2019-10-28 00:59:28 +00:00
|
|
|
private
|
|
|
|
|
|
|
|
def valid_canonical_uri?(url)
|
|
|
|
ucu = URI.parse(url)
|
|
|
|
new_page = ucu &&
|
|
|
|
ucu.scheme.present? &&
|
|
|
|
ucu.host.present? &&
|
|
|
|
ucu.path != "/"
|
|
|
|
|
|
|
|
return false unless new_page
|
|
|
|
|
|
|
|
res = Sponge.new.fetch(url)
|
|
|
|
|
|
|
|
res.code.to_s =~ /\A2.*\Z/
|
|
|
|
end
|
|
|
|
|
|
|
|
def canonical_target(parsed)
|
|
|
|
parsed.at_css("link[rel='canonical']").attributes["href"].text
|
|
|
|
end
|
2012-06-17 01:15:46 +00:00
|
|
|
end
|