Module: Cloudflare::Multipart

Defined in:
lib/cloudflare_workers/multipart.rb

Constant Summary collapse

CRLF =
"\r\n"

Class Method Summary collapse

Class Method Details

.decode_rfc5987(s) ⇒ Object



266
267
268
269
270
# File 'lib/cloudflare_workers/multipart.rb', line 266

def self.decode_rfc5987(s)
  `decodeURIComponent(#{s.to_s})`
rescue StandardError
  s
end

.extract_disposition_param(disposition, key) ⇒ Object

Extract a quoted or bare parameter from a Content-Disposition value. Handles ‘name=“file”; filename=“pic.png”` and RFC 5987 `filename*=UTF-8”pic.png` (best-effort URL decoding).

The ‘(^|[;s])` prefix is load-bearing: without it, looking up `name` would also match inside `filename*=…` (substring “name*=”) and mis-attribute the filename to the form-field name. RFC 7578 places each parameter after `;` (with optional whitespace), so the prefix is free.



243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/cloudflare_workers/multipart.rb', line 243

def self.extract_disposition_param(disposition, key)
  k = Regexp.escape(key)
  # filename*=charset'lang'encoded  (RFC 5987)
  star_re = /(?:^|[;\s])#{k}\*\s*=\s*([^;]+)/i
  if (m = disposition.match(star_re))
    raw = m[1].strip
    parts = raw.split("'", 3)
    encoded = parts[2] || parts[0]
    return decode_rfc5987(encoded)
  end
  # Quoted `key="value"`
  q_re = /(?:^|[;\s])#{k}\s*=\s*"((?:\\"|[^"])*)"/i
  if (m = disposition.match(q_re))
    return m[1].gsub('\\"', '"')
  end
  # Bare `key=value`
  b_re = /(?:^|[;\s])#{k}\s*=\s*([^;]+)/i
  if (m = disposition.match(b_re))
    return m[1].strip
  end
  nil
end

.parse(body_binstr, content_type) ⇒ Hash

Parse a multipart/form-data payload.

Parameters:

  • body_binstr (String)

    latin1 byte string (1 char = 1 byte)

  • content_type (String)

    the request Content-Type header

Returns:

  • (Hash)

    keys are form-field names (strings); values are either UploadedFile (for file parts) or String (for plain text fields).



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/cloudflare_workers/multipart.rb', line 149

def self.parse(body_binstr, content_type)
  boundary = parse_boundary(content_type)
  return {} if boundary.nil?
  return {} if body_binstr.nil? || body_binstr.empty?

  sep       = '--' + boundary
  term      = '--' + boundary + '--'
  sep_line  = sep + CRLF
  sep_last  = sep + CRLF  # the very first boundary may skip the leading CRLF
  body      = body_binstr.to_s

  # Skip any preamble before the first boundary.
  start_idx = body.index(sep)
  return {} if start_idx.nil?
  cursor = start_idx + sep.length
  # consume possible CRLF right after the first boundary
  if body[cursor, 2] == CRLF
    cursor += 2
  end

  parts = {}

  loop do
    # Find the next boundary after cursor.
    # Each part ends with CRLF before the next "--boundary" line,
    # or "--boundary--" for the terminator.
    next_sep = body.index(CRLF + sep, cursor)
    break if next_sep.nil?

    part = body[cursor...next_sep]

    # Split headers / body on the first blank line (CRLF CRLF).
    headers_end = part.index(CRLF + CRLF)
    if headers_end
      raw_headers = part[0...headers_end]
      raw_body    = part[(headers_end + 4)..-1] || ''
    else
      raw_headers = part
      raw_body    = ''
    end

    disposition = nil
    ctype       = nil
    raw_headers.split(CRLF).each do |line|
      name, value = line.split(':', 2)
      next if name.nil? || value.nil?
      name = name.strip.downcase
      value = value.strip
      case name
      when 'content-disposition' then disposition = value
      when 'content-type'        then ctype = value
      end
    end

    if disposition
      field_name = extract_disposition_param(disposition, 'name')
      filename   = extract_disposition_param(disposition, 'filename')
      if field_name
        if filename && !filename.empty?
          parts[field_name] = UploadedFile.new(
            name: field_name,
            filename: filename,
            content_type: ctype,
            head: raw_headers,
            bytes_binstr: raw_body
          )
        else
          parts[field_name] = raw_body
        end
      end
    end

    cursor = next_sep + CRLF.length + sep.length
    # Check whether this is the terminator `--boundary--`
    if body[cursor, 2] == '--'
      break
    end
    if body[cursor, 2] == CRLF
      cursor += 2
    end
  end

  parts
end

.parse_boundary(content_type) ⇒ Object

Extract the multipart boundary from a Content-Type header. Matches ‘boundary=AaB03x`, `boundary=“weird boundary”`, and whitespace/case variants. Quoted forms are preserved as-is so `boundary=“foo bar”` → `foo bar` (internal whitespace kept), while unquoted forms stop at the next delimiter.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/cloudflare_workers/multipart.rb', line 127

def self.parse_boundary(content_type)
  return nil if content_type.nil?
  ct = content_type.to_s
  return nil unless ct.downcase.include?('multipart/')
  # Prefer the quoted form. The quoted value may contain any byte
  # except a literal `"` (RFC 2046 §5.1.1 bans `"` in the value).
  if (m = ct.match(/boundary="([^"]+)"/i))
    return m[1]
  end
  if (m = ct.match(/boundary=([^;,\s]+)/i))
    return m[1]
  end
  nil
end

.rack_params(env) ⇒ Object

Rack::Request integration — parse the multipart body once per request, cache on the env, hydrate Sinatra’s ‘params` Hash.

Called lazily from our patched Rack::Request#POST.



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
301
302
# File 'lib/cloudflare_workers/multipart.rb', line 276

def self.rack_params(env)
  cached = env['cloudflare.multipart']
  return cached if cached

  ct = env['CONTENT_TYPE']
  return ({}) unless ct && ct.to_s.downcase.include?('multipart/')

  io = env['rack.input']
  return ({}) if io.nil?

  # `rack.input` is normally a StringIO wrapping the body_binstr
  # we staged in src/worker.mjs. Read the full body; it's already
  # resolved server-side (Workers doesn't stream request bodies
  # back into Opal).
  if io.respond_to?(:rewind)
    begin
      io.rewind
    rescue
      # some stubs don't support rewind — ignore
    end
  end
  body = io.respond_to?(:read) ? io.read.to_s : ''

  parsed = parse(body, ct)
  env['cloudflare.multipart'] = parsed
  parsed
end