onddo/postfixadmin-cookbook

View on GitHub
libraries/api_http.rb

Summary

Maintainability
A
1 hr
Test Coverage
# encoding: UTF-8
#
# Cookbook Name:: postfixadmin
# Library:: api_http
# Author:: Xabier de Zuazo (<xabier@zuazo.org>)
# Copyright:: Copyright (c) 2014-2015 Onddo Labs, SL.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

module PostfixadminCookbook
  class API
    # Internal wrapper to send PostfixAdmin HTTP calls
    class HTTP
      # Internal wrapper to ruby HTTP requests
      class Request
        # rubocop:disable Style/ClassVars

        @@cookie = nil

        def self.cookie
          @@cookie
        end

        def self.cookie=(arg)
          @@cookie = arg
        end

        # rubocop:enable Style/ClassVars

        def default_proto(ssl)
          ssl ? 'https' : 'http'
        end

        def default_port(ssl)
          ssl ? 443 : 80
        end

        def create_uri(path, ssl, port)
          proto = default_proto(ssl)
          port = default_port(ssl) if port.nil?
          URI.parse("#{proto}://127.0.0.1:#{port}#{path}")
        end

        def user_agent
          Chef::HTTP::HTTPRequest.user_agent
        end

        def create_http(uri, ssl)
          http = Net::HTTP.new(uri.host, uri.port)
          if ssl
            require 'net/https'
            http.use_ssl = true
            http.verify_mode = OpenSSL::SSL::VERIFY_NONE
          end
          http.set_debug_output $stderr if Chef::Config[:log_level] == :debug
          http
        end

        def create_request(method, uri)
          case method.downcase
          when 'post'
            request = Net::HTTP::Post.new(uri.request_uri)
            request['Content-Type'] = 'application/x-www-form-urlencoded'
          else
            request = Net::HTTP::Get.new(uri.request_uri)
          end
          request['User-Agent'] = user_agent
          request['Cookie'] = self.class.cookie unless self.class.cookie.nil?
          request
        end

        def parse_cookie(response)
          return unless response['Set-Cookie'].is_a?(String)
          self.class.cookie = response['Set-Cookie'].split(';')[0]
          Chef::Log.debug(
            "#{self.class.name}##{__method__} cookie: #{self.class.cookie}"
          )
        end

        def parse_response(response)
          parse_cookie(response)
          response
        end

        def initialize(method, path, body, ssl, port)
          uri = create_uri(path, ssl, port)
          Chef::Log.debug("#{self.class}: #{method} #{uri}")
          @http = create_http(uri, ssl)
          @request = create_request(method, uri)
          @request.set_form_data(serialize_body(body)) unless body.nil?
        end

        #
        # Returns a key, value pair for processing it with PHP web application.
        #
        # @param key [String, Symbol] Name of the hash key.
        # @param value [Mixed] value of the key.
        # @return [Array<Array>] an array of arrays with the key, value pairs.
        # @example
        #   serialize_php_body('key1', sub1: 'a', sub2: 'b')
        #     #=> [["key1[sub1]", "a"], ["key1[sub2]", "b"]]
        #   serialize_php_body('key1', sub1: 'a', sub2: { subsub3: 'b' })
        #     #=> [["key1[sub1]", "a"], ["key1[sub2][subsub3]", "b"]]
        def serialize_php_body(key, value)
          case value
          when Hash then value.reduce([]) do |m, (k2, v2)|
                           m + serialize_php_body("#{key}[#{k2}]", v2)
                         end
          when Array then value.reduce([]) do |m, v2|
                            # TODO: only one value is accepted for arrays
                            m + serialize_php_body("#{key}[]", v2)
                          end
          else [[key, value]]
          end
        end

        def serialize_body(body)
          return body unless body.is_a?(Hash)
          body.reduce({}) do |hs, (key, value)|
            hs.merge(serialize_php_body(key, value).to_h)
          end
        end

        def response
          response = @http.request(@request)
          parse_response(response)
        end
      end # Request

      unless defined?(::PostfixadminCookbook::API::HTTP::ERROR_REGEXS)
        ERROR_REGEXS = [
          /^.*class=['"]error_msg['"][^>]*>([^<]+)<.*$/m,
          /^.*(Invalid\s+token!).*$/m
        ].freeze
      end
      unless defined?(::PostfixadminCookbook::API::HTTP::SETUP_OK_REGEX)
        SETUP_OK_REGEX = /You +are +done +with +your +basic +setup/
      end
      unless defined?(::PostfixadminCookbook::API::HTTP::SETUP_ERROR_REGEXS)
        SETUP_ERROR_REGEXS = [
          %r{^.*<b>(Error: .+)</b>.*Please fix the errors listed above.*$}m,
          /^.*class=['"]standout['"][^>]*>([^<]+?)<.*$/m,
          %r{^.*<tr>\s*(?:<td>.+?</td>\s*){2}<td>([^<]+?)</td>\s*</tr>.*$}m
        ].freeze
      end
      unless defined?(::PostfixadminCookbook::API::HTTP::DELETE_ERROR_REGEX)
        DELETE_ERROR_REGEX =
          %r{^.*class=['"]flash-error['"][^>]*>(?:<li>)?([^<]+?)(?:</li>)?<.*$}m
      end
      unless defined?(::PostfixadminCookbook::API::HTTP::TOKEN_REGEX)
        TOKEN_REGEX = /^.*<input\s+[^>]*name="token"\s+value="([^"]+)".*$/m
      end

      class TokenError < StandardError; end

      # rubocop:disable Style/ClassVars

      @@token = nil

      def self.token
        @@token
      end

      def self.token=(arg)
        @@token = arg
      end

      # rubocop:enable Style/ClassVars

      def strip_html(html)
        html.gsub(%r{</?[^>]+?>}, ' ')
      end

      def error(error_msg, e_class = RuntimeError)
        Chef::Log.fatal(error_msg) if e_class == RuntimeError
        raise e_class, error_msg
      end

      def parse_setup(body)
        return if body.match(SETUP_OK_REGEX)
        SETUP_ERROR_REGEXS.each do |regexp|
          next unless body.match(regexp)
          error(strip_html(body.gsub(regexp, '\1')))
        end
        error("Unknown error during the setup of Postfix Admin:\n\n#{body}")
      end

      def parse_response(body, regexps = nil)
        (regexps ? [regexps].flatten : ERROR_REGEXS).each do |regexp|
          next unless body.match(regexp)
          error(strip_html(body.gsub(regexp, '\1')))
        end
      end

      def request(method, path, body, ssl, port)
        resp = API::HTTP::Request.new(method, path, body, ssl, port).response
        error("#{resp.code} #{resp.message}") if resp.code.to_i >= 400
        block_given? ? yield(resp.body) : parse_response(resp.body)
      end

      attr_writer :username, :password, :ssl

      def initialize(username = nil, password = nil, ssl = false, port = nil)
        @username = username
        @password = password
        @ssl = ssl
        @port = port
      end

      def setup(username, password, setup_password)
        body = { form: 'createadmin', setup_password: setup_password,
                 username: username, password: password, password2: password,
                 submit: 'Add+Admin' }
        request('post', '/setup.php', body, @ssl, @port) { |x| parse_setup(x) }
      end

      def get_token(url, ssl = false, port = nil)
        request('get', url, nil, ssl, port) do |x|
          error('Token not found.', TokenError) unless x.match(TOKEN_REGEX)
          self.class.token = x.gsub(TOKEN_REGEX, '\1')
        end
      end

      def login
        return unless self.class.token.nil?
        request('get', '/login.php', nil, @ssl, @port) # get cookie
        body = { fUsername: @username, fPassword: @password, lang: 'en',
                 submit: 'Login' }
        request('post', '/login.php', body, @ssl, @port)
        get_token('/edit.php?table=domain', @ssl, @port)
      end

      def get(path, &block)
        login
        request('get', path, nil, @ssl, @port, &block)
      end

      def post(path, body, &block)
        login
        body[:token] = self.class.token unless self.class.token.nil?
        request('post', path, body, @ssl, @port, &block)
      end

      def delete(path)
        get("#{path}&token=#{self.class.token}") do |x|
          parse_response(x, DELETE_ERROR_REGEX)
        end
      end
    end
  end
end