file_uploader.rb 5.33 KB
Newer Older
1
2
# frozen_string_literal: true

3
4
5
6
7
8
9
10
# This class breaks the actual CarrierWave concept.
# Every uploader should use a base_dir that is model agnostic so we can build
# back URLs from base_dir-relative paths saved in the `Upload` model.
#
# As the `.base_dir` is model dependent and **not** saved in the upload model (see #upload_path)
# there is no way to build back the correct file path without the model, which defies
# CarrierWave way of storing files.
#
11
class FileUploader < GitlabUploader
12
  include UploaderHelper
13
14
15
  include RecordsUploads::Concern
  include ObjectStorage::Concern
  prepend ObjectStorage::Extension::RecordsUploads
16

Stan Hu's avatar
Stan Hu committed
17
18
  MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}.freeze
  DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\h{32})/(?<identifier>.*)}.freeze
19
20
21
  VALID_SECRET_PATTERN = %r{\A\h{10,32}\z}.freeze

  InvalidSecret = Class.new(StandardError)
22

23
24
  after :remove, :prune_store_dir

25
26
27
28
  # FileUploader do not run in a model transaction, so we can simply
  # enqueue a job after the :store hook.
  after :store, :schedule_background_upload

29
30
31
  def self.root
    File.join(options.storage_path, 'uploads')
  end
32

33
  def self.absolute_path(upload)
34
    File.join(
35
36
      absolute_base_dir(upload.model),
      upload.path # already contain the dynamic_segment, see #upload_path
37
38
39
    )
  end

40
41
42
43
44
  def self.base_dir(model, store = Store::LOCAL)
    decorated_model = model
    decorated_model = Storage::HashedProject.new(model) if store == Store::REMOTE

    model_path_segment(decorated_model)
45
46
47
48
49
  end

  # used in migrations and import/exports
  def self.absolute_base_dir(model)
    File.join(root, base_dir(model))
50
51
  end

52
53
54
55
56
57
  # Returns the part of `store_dir` that can change based on the model's current
  # path
  #
  # This is used to build Upload paths dynamically based on the model's current
  # namespace and path, allowing us to ignore renames or transfers.
  #
58
  # model - Object that responds to `full_path` and `disk_path`
59
60
  #
  # Returns a String without a trailing slash
61
  def self.model_path_segment(model)
62
63
    case model
    when Storage::HashedProject then model.disk_path
64
    else
65
      model.hashed_storage?(:attachments) ? model.disk_path : model.full_path
66
    end
67
68
  end

69
70
71
  def self.generate_secret
    SecureRandom.hex
  end
72

73
74
75
76
  def self.extract_dynamic_path(path)
    DYNAMIC_PATH_PATTERN.match(path)
  end

77
  def upload_paths(identifier)
78
    [
79
80
      File.join(secret, identifier),
      File.join(base_dir(Store::REMOTE), secret, identifier)
81
82
83
    ]
  end

84
  attr_accessor :model
85

86
87
88
  def initialize(model, mounted_as = nil, **uploader_context)
    super(model, nil, **uploader_context)

89
    @model = model
90
    apply_context!(uploader_context)
91
92
  end

93
94
95
96
97
98
99
  def initialize_copy(from)
    super

    @secret = self.class.generate_secret
    @upload = nil # calling record_upload would delete the old upload if set
  end

100
101
102
103
  # enforce the usage of Hashed storage when storing to
  # remote store as the FileMover doesn't support OS
  def base_dir(store = nil)
    self.class.base_dir(@model, store || object_store)
104
105
  end

106
107
108
109
  # we don't need to know the actual path, an uploader instance should be
  # able to yield the file content on demand, so we should build the digest
  def absolute_path
    self.class.absolute_path(@upload)
110
111
  end

112
  def upload_path
113
114
    if file_storage?
      # Legacy path relative to project.full_path
115
      local_storage_path(identifier)
116
    else
117
      remote_storage_path(identifier)
118
    end
119
  end
120

121
122
123
124
125
126
127
128
  def local_storage_path(file_identifier)
    File.join(dynamic_segment, file_identifier)
  end

  def remote_storage_path(file_identifier)
    File.join(store_dir, file_identifier)
  end

129
130
131
132
133
  def store_dirs
    {
      Store::LOCAL => File.join(base_dir, dynamic_segment),
      Store::REMOTE => File.join(base_dir(ObjectStorage::Store::REMOTE), dynamic_segment)
    }
134
135
136
  end

  def to_h
137
    {
138
      alt:      markdown_name,
139
      url:      secure_url,
140
      markdown: markdown_link
141
142
    }
  end
143

144
  def upload=(value)
145
    super
Micael Bergeron's avatar
Micael Bergeron committed
146
147
148
149
150

    return unless value
    return if apply_context!(value.uploader_context)

    # fallback to the regex based extraction
151
    if matches = self.class.extract_dynamic_path(value.path)
152
153
154
155
156
157
158
      @secret = matches[:secret]
      @identifier = matches[:identifier]
    end
  end

  def secret
    @secret ||= self.class.generate_secret
159
160
161
162

    raise InvalidSecret unless @secret =~ VALID_SECRET_PATTERN

    @secret
163
164
  end

165
166
  # return a new uploader with a file copy on another project
  def self.copy_to(uploader, to_project)
167
168
169
    moved = self.new(to_project)
    moved.object_store = uploader.object_store
    moved.filename = uploader.filename
170
171
172
173
174
175

    moved.copy_file(uploader.file)
    moved
  end

  def copy_file(file)
176
177
178
179
180
181
182
183
    to_path = if file_storage?
                File.join(self.class.root, store_path)
              else
                store_path
              end

    self.file = file.copy_to(to_path)
    record_upload # after_store is not triggered
184
185
  end

186
187
  private

188
189
190
191
192
193
194
195
196
197
198
199
  def apply_context!(uploader_context)
    @secret, @identifier = uploader_context.values_at(:secret, :identifier)

    !!(@secret && @identifier)
  end

  def build_upload
    super.tap do |upload|
      upload.secret = secret
    end
  end

200
201
202
203
  def prune_store_dir
    storage.delete_dir!(store_dir) # only remove when empty
  end

204
205
206
207
208
209
  def identifier
    @identifier ||= filename
  end

  def dynamic_segment
    secret
210
  end
211
212

  def secure_url
213
    File.join('/uploads', @secret, filename)
214
  end
215
end