landline/lib/hyde/util/multipart.rb

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