176 lines
5.5 KiB
Ruby
176 lines
5.5 KiB
Ruby
# frozen_string_literal: false
|
|
|
|
require 'uri'
|
|
require 'stringio'
|
|
require 'tempfile'
|
|
require_relative 'parseutils'
|
|
require_relative 'parsesorting'
|
|
require_relative 'html'
|
|
|
|
module Hyde
|
|
module Util
|
|
# Valid element of form data with headers
|
|
# @!attribute headers [Hash] headers recevied from form data
|
|
# @!attribute name [String] name of the form part
|
|
# @!attribute data [String,nil] Data received in the field through form data
|
|
# @!attribute filename [String,nil] Original name of the sent file
|
|
# @!attribute filetype [String,nil] MIME-type of the file
|
|
# @!attribute tempfile [File,nil] Temporary file for storing sent file data.
|
|
FormPart = Struct.new(:data, :name, :filename,
|
|
:filetype, :tempfile, :headers) do
|
|
# Is this form part a file or plain data?
|
|
# @return [Boolean]
|
|
def file?
|
|
!tempfile.nil?
|
|
end
|
|
|
|
# Decode charset parameter
|
|
def decode(data)
|
|
data = Hyde::Util.unescape_html(data)
|
|
return data unless self.headers['charset']
|
|
|
|
data.force_encoding(self.headers['charset']).encode("UTF-8")
|
|
end
|
|
|
|
# If FormPart is not a file, simplify to string.
|
|
# @return [FormPart, String]
|
|
def simplify
|
|
file? ? self : decode(self.data)
|
|
end
|
|
end
|
|
|
|
# A very naive implementation of a Multipart form parser.
|
|
class MultipartParser
|
|
include Hyde::Util::ParserSorting
|
|
def initialize(io, boundary)
|
|
@input = io.is_a?(String) ? StringIO.new(io) : io
|
|
@boundary = boundary
|
|
@state = :idle
|
|
@data = []
|
|
end
|
|
|
|
# lord forgive me for what i'm about to do
|
|
# TODO: replace the god method with a state machine object
|
|
# rubocop:disable Metrics/*
|
|
|
|
# Parse multipart formdata
|
|
# @return [Array<FormPart, FormFile>]
|
|
def parse
|
|
return @data unless @data.empty?
|
|
|
|
while (line = @input.gets)
|
|
case @state
|
|
when :idle # waiting for valid boundary
|
|
if line == "--#{@boundary}\r\n"
|
|
# transition to :headers on valid boundary
|
|
@state = :headers
|
|
@data.append(FormPart.new(*([nil] * 5), {}))
|
|
end
|
|
when :headers # after valid boundary - checking for headers
|
|
if line == "\r\n"
|
|
# prepare form field and transition to :data or :file
|
|
@state = file?(@data[-1].headers) ? :file : :data
|
|
if @state == :data
|
|
setup_data_meta(@data[-1])
|
|
else
|
|
setup_file_meta(@data[-1])
|
|
end
|
|
next
|
|
end
|
|
push_header(line, @data[-1].headers)
|
|
when :data, :file # after headers - processing form data
|
|
if @data[-1].headers.empty?
|
|
# transition to :idle on empty headers
|
|
@state = :idle
|
|
next
|
|
end
|
|
if ["--#{@boundary}\r\n", "--#{@boundary}--\r\n"].include? line
|
|
# finalize and transition to either :headers or :idle
|
|
if @state == :file
|
|
@data[-1].tempfile.truncate(@data[-1].tempfile.size - 2)
|
|
@data[-1].tempfile.close
|
|
else
|
|
@data[-1].data.delete_suffix! "\r\n"
|
|
end
|
|
@state = line == "--#{@boundary}\r\n" ? :headers : :idle
|
|
@data.append(FormPart.new(*([nil] * 5), {}))
|
|
next
|
|
end
|
|
if @state == :data
|
|
@data[-1].data ||= ""
|
|
@data[-1].data << line
|
|
else
|
|
@data[-1].tempfile << line
|
|
end
|
|
end
|
|
end
|
|
@state = :idle
|
|
@data.pop
|
|
@data.freeze
|
|
end
|
|
# rubocop:enable Metrics/*
|
|
|
|
# Return a hash of current form.
|
|
# (equivalent to Query.parse but for multipart/form-data)
|
|
# @return [Hash]
|
|
def to_h
|
|
flatten(sort(gen_hash(parse)))
|
|
end
|
|
|
|
private
|
|
|
|
def gen_hash(array)
|
|
hash = {}
|
|
array.each do |formpart|
|
|
key = formpart.name.to_s
|
|
if key.match?(/.*\[\d*\]\Z/)
|
|
new_key, index = key.match(/(.*)\[(\d*)\]\Z/).to_a[1..]
|
|
hash[new_key] = [] unless hash[new_key]
|
|
hash[new_key].append([index, formpart.simplify])
|
|
else
|
|
hash[key] = formpart.simplify
|
|
end
|
|
end
|
|
hash
|
|
end
|
|
|
|
# Setup file metadata
|
|
# @part part [FormPart]
|
|
def setup_file_meta(part)
|
|
part.name = part.headers.dig("content-disposition", 1, "name")
|
|
part.filename = part.headers.dig("content-disposition", 1, "filename")
|
|
part.filetype = part.headers["content-type"]
|
|
part.tempfile = Tempfile.new
|
|
end
|
|
|
|
# Setup plain metadata
|
|
# @part part [FormPart]
|
|
def setup_data_meta(part)
|
|
part.name = part.headers.dig("content-disposition", 1, "name")
|
|
end
|
|
|
|
# Analyze headers to check if current data part is a file.
|
|
# @param headers_hash [Hash]
|
|
# @return [Boolean]
|
|
def file?(headers_hash)
|
|
if headers_hash.dig("content-disposition", 1, "filename") and
|
|
headers_hash['content-type']
|
|
return true
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
# Parse a header and append it to headers_hash
|
|
# @param line [String]
|
|
# @param headers_hash [Hash]
|
|
def push_header(line, headers_hash)
|
|
return unless line.match(/^[\w!#$%&'*+-.^_`|~]+:.*\r\n$/)
|
|
|
|
k, v = line.match(/^([\w!#$%&'*+-.^_`|~]+):(.*)\r\n$/).to_a[1..]
|
|
headers_hash[k.downcase] = Hyde::Util::ParserCommon.parse_value(v)
|
|
end
|
|
end
|
|
end
|
|
end
|