snippet.rb 9.0 KB
Newer Older
1 2
# frozen_string_literal: true

3
class Snippet < ApplicationRecord
V
Valery Sizov 已提交
4
  include Gitlab::VisibilityLevel
5
  include Redactable
6
  include CacheMarkdownField
7
  include Noteable
8
  include Participable
9
  include Sortable
10
  include Awardable
11
  include Mentionable
S
Sean McGivern 已提交
12
  include Spammable
13
  include Editable
14
  include Gitlab::SQL::Pattern
15
  include FromUnion
16
  include IgnorableColumns
17
  include HasRepository
18
  extend ::Gitlab::Utils::Override
G
gitlabhq 已提交
19

20
  ignore_column :storage_version, remove_with: '12.9', remove_after: '2020-03-22'
21
  ignore_column :repository_storage, remove_with: '12.10', remove_after: '2020-04-22'
22

23
  cache_markdown_field :title, pipeline: :single_line
24
  cache_markdown_field :description
25 26
  cache_markdown_field :content

27 28
  redact_field :description

B
blackst0ne 已提交
29
  # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with snippets.
30
  # See https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/10392/diffs#note_28719102
B
blackst0ne 已提交
31 32 33
  alias_attribute :last_edited_at, :updated_at
  alias_attribute :last_edited_by, :updated_by

34 35 36 37 38 39
  # If file_name changes, it invalidates content
  alias_method :default_content_html_invalidator, :content_html_invalidated?
  def content_html_invalidated?
    default_content_html_invalidator || file_name_changed?
  end

R
Robert Speicher 已提交
40 41
  belongs_to :author, class_name: 'User'
  belongs_to :project
A
Andrew8xx8 已提交
42

43
  has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
44
  has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
45
  has_one :snippet_repository, inverse_of: :snippet
G
gitlabhq 已提交
46

D
Dmitriy Zaporozhets 已提交
47
  delegate :name, :email, to: :author, prefix: true, allow_nil: true
G
gitlabhq 已提交
48

A
Andrey Kumanyaev 已提交
49
  validates :author, presence: true
50
  validates :title, presence: true, length: { maximum: 255 }
51
  validates :file_name,
52
    length: { maximum: 255 }
53

V
Valeriy Sizov 已提交
54
  validates :content, presence: true
55 56 57 58 59 60 61 62 63 64 65 66
  validates :content,
            length: {
              maximum: ->(_) { Gitlab::CurrentSettings.snippet_size_limit },
              message: -> (_, data) do
                current_value = ActiveSupport::NumberHelper.number_to_human_size(data[:value].size)
                max_size = ActiveSupport::NumberHelper.number_to_human_size(Gitlab::CurrentSettings.snippet_size_limit)

                _("is too long (%{current_value}). The maximum size is %{max_size}.") % { current_value: current_value, max_size: max_size }
              end
            },
            if: :content_changed?

V
Valery Sizov 已提交
67
  validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
G
gitlabhq 已提交
68

69 70
  after_save :store_mentions!, if: :any_mentionable_attributes_changed?

A
Andrey Kumanyaev 已提交
71
  # Scopes
72
  scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) }
V
Valery Sizov 已提交
73
  scope :are_private, -> { where(visibility_level: Snippet::PRIVATE) }
74 75
  scope :are_public, -> { public_only }
  scope :are_secret, -> { public_only.where(secret: true) }
76
  scope :fresh, -> { order("created_at DESC") }
77
  scope :inc_author, -> { includes(:author) }
78
  scope :inc_relations_for_view, -> { includes(author: :status) }
N
Nihad Abbasov 已提交
79

80 81
  attr_mentionable :description

Y
Yorick Peterse 已提交
82 83
  participant :author
  participant :notes_with_associations
84

S
Sean McGivern 已提交
85 86 87
  attr_spammable :title, spam_title: true
  attr_spammable :content, spam_description: true

88 89 90 91 92
  attr_encrypted :secret_token,
    key:       Settings.attr_encrypted_db_key_base_truncated,
    mode:      :per_attribute_iv,
    algorithm: 'aes-256-cbc'

93 94 95 96 97 98 99 100
  def self.with_optional_visibility(value = nil)
    if value
      where(visibility_level: value)
    else
      all
    end
  end

101
  def self.only_personal_snippets
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141
    where(project_id: nil)
  end

  def self.only_include_projects_visible_to(current_user = nil)
    levels = Gitlab::VisibilityLevel.levels_for_user(current_user)

    joins(:project).where('projects.visibility_level IN (?)', levels)
  end

  def self.only_include_projects_with_snippets_enabled(include_private: false)
    column = ProjectFeature.access_level_attribute(:snippets)
    levels = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]

    levels << ProjectFeature::PRIVATE if include_private

    joins(project: :project_feature)
      .where(project_features: { column => levels })
  end

  def self.only_include_authorized_projects(current_user)
    where(
      'EXISTS (?)',
      ProjectAuthorization
        .select(1)
        .where('project_id = snippets.project_id')
        .where(user_id: current_user.id)
    )
  end

  def self.for_project_with_user(project, user = nil)
    return none unless project.snippets_visible?(user)

    if user && project.team.member?(user)
      project.snippets
    else
      project.snippets.public_to_user(user)
    end
  end

  def self.visible_to_or_authored_by(user)
142 143
    query = where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(user))
    query.or(where(author_id: user.id))
144 145
  end

146 147 148 149
  def self.reference_prefix
    '$'
  end

150 151 152 153
  # Pattern used to extract `$123` snippet references from text
  #
  # This pattern supports cross-project references.
  def self.reference_pattern
154
    @reference_pattern ||= %r{
155 156
      (#{Project.reference_pattern})?
      #{Regexp.escape(reference_prefix)}(?<snippet>\d+)
157 158 159
    }x
  end

160
  def self.link_reference_pattern
161
    @link_reference_pattern ||= super("snippets", /(?<snippet>\d+)/)
162 163
  end

164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181
  def initialize(attributes = {})
    # We can't use default_value_for because the database has a default
    # value of 0 for visibility_level. If someone attempts to create a
    # private snippet, default_value_for will assume that the
    # visibility_level hasn't changed and will use the application
    # setting default, which could be internal or public.
    #
    # To fix the problem, we assign the actual snippet default if no
    # explicit visibility has been initialized.
    attributes ||= {}

    unless visibility_attribute_present?(attributes)
      attributes[:visibility_level] = Gitlab::CurrentSettings.default_snippet_visibility
    end

    super
  end

J
Jarka Kadlecova 已提交
182
  def to_reference(from = nil, full: false)
183 184
    reference = "#{self.class.reference_prefix}#{id}"

185
    if project.present?
186
      "#{project.to_reference_base(from, full: full)}#{reference}"
187 188
    else
      reference
189 190 191
    end
  end

G
gitlabhq 已提交
192
  def self.content_types
N
Nihad Abbasov 已提交
193
    [
G
gitlabhq 已提交
194 195 196 197 198
      ".rb", ".py", ".pl", ".scala", ".c", ".cpp", ".java",
      ".haml", ".html", ".sass", ".scss", ".xml", ".php", ".erb",
      ".js", ".sh", ".coffee", ".yml", ".md"
    ]
  end
G
gitlabhq 已提交
199

D
Douwe Maan 已提交
200 201
  def blob
    @blob ||= Blob.decorate(SnippetBlob.new(self), nil)
202 203
  end

204 205 206 207
  def hook_attrs
    attributes
  end

208 209 210 211
  def file_name
    super.to_s
  end

212 213 214 215
  def sanitized_file_name
    file_name.gsub(/[^a-zA-Z0-9_\-\.]+/, '')
  end

V
Valery Sizov 已提交
216
  def visibility_level_field
217
    :visibility_level
218
  end
V
Valery Sizov 已提交
219

220
  def embeddable?
221
    Ability.allowed?(nil, :read_snippet, self)
222 223
  end

Y
Yorick Peterse 已提交
224
  def notes_with_associations
225
    notes.includes(:author)
Y
Yorick Peterse 已提交
226 227
  end

S
Sean McGivern 已提交
228
  def check_for_spam?
229 230
    visibility_level_changed?(to: Snippet::PUBLIC) ||
      (public? && (title_changed? || content_changed?))
S
Sean McGivern 已提交
231 232
  end

233
  # snippets are the biggest sources of spam
234 235 236 237 238
  override :allow_possible_spam?
  def allow_possible_spam?
    false
  end

S
Sean McGivern 已提交
239 240 241 242
  def spammable_entity_type
    'snippet'
  end

243
  def to_ability_name
244
    'snippet'
245 246
  end

247 248 249 250 251 252 253 254 255 256 257 258 259
  def valid_secret_token?(token)
    return false unless token && secret_token

    ActiveSupport::SecurityUtils.secure_compare(token.to_s, secret_token.to_s)
  end

  def as_json(options = {})
    options[:except] = Array.wrap(options[:except])
    options[:except] << :secret_token

    super
  end

260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300
  def repository
    @repository ||= Repository.new(full_path, self, disk_path: disk_path, repo_type: Gitlab::GlRepository::SNIPPET)
  end

  def storage
    @storage ||= Storage::Hashed.new(self, prefix: Storage::Hashed::SNIPPET_REPOSITORY_PATH_PREFIX)
  end

  # This is the full_path used to identify the
  # the snippet repository. It will be used mostly
  # for logging purposes.
  def full_path
    return unless persisted?

    @full_path ||= begin
      components = []
      components << project.full_path if project_id?
      components << '@snippets'
      components << self.id
      components.join('/')
    end
  end

  def repository_storage
    snippet_repository&.shard_name ||
      Gitlab::CurrentSettings.pick_repository_storage
  end

  def create_repository
    return if repository_exists?

    repository.create_if_not_exists

    track_snippet_repository if repository_exists?
  end

  def track_snippet_repository
    repository = snippet_repository || build_snippet_repository
    repository.update!(shard_name: repository_storage, disk_path: disk_path)
  end

301
  class << self
302 303 304 305 306 307 308
    # Searches for snippets with a matching title or file name.
    #
    # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
    #
    # query - The search query as a String.
    #
    # Returns an ActiveRecord::Relation.
309
    def search(query)
310
      fuzzy_search(query, [:title, :file_name])
311 312
    end

313 314 315 316 317 318 319
    # Searches for snippets with matching content.
    #
    # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
    #
    # query - The search query as a String.
    #
    # Returns an ActiveRecord::Relation.
320
    def search_code(query)
321
      fuzzy_search(query, [:content])
322
    end
J
Jan Provaznik 已提交
323 324 325 326

    def parent_class
      ::Project
    end
327
  end
G
gitlabhq 已提交
328
end
329 330

Snippet.prepend_if_ee('EE::Snippet')