(ns puppetlabs.services.request-handler.request-handler-core
  (:require [clojure.string :as string]
            [clojure.tools.logging :as log]
            [clojure.walk :as walk]
            [puppetlabs.i18n.core :as i18n]
            [puppetlabs.puppetserver.common :as ps-common]
            [puppetlabs.puppetserver.jruby-request :as jruby-request]
            [puppetlabs.ring-middleware.params :as pl-ring-params]
            [puppetlabs.ring-middleware.utils :as ringutils]
            [puppetlabs.ssl-utils.core :as ssl-utils]
            [puppetlabs.trapperkeeper.authorization.ring :as ring-auth]
            [ring.util.codec :as ring-codec]
            [schema.core :as schema])
  (:import (com.puppetlabs.puppetserver JRubyPuppetResponse)
           (java.io StringReader)
           (java.util HashMap Map)))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Internal

(def header-client-cert-name
  "Name of the HTTP header through which a client certificate can be passed
  for a request"
  "x-client-cert")

(defn unmunge-http-header-name
  "Given the value of a Puppet setting which contains a munged HTTP header name,
  convert it to the actual header name in all lower-case."
  [setting]
  (->> (string/split setting #"_")
       rest
       (string/join "-")
       string/lower-case))

(defn config->request-handler-settings
  "Given an entire Puppet Server configuration map, return only those keys
  which are required by the request handler service."
  [{:keys [puppetserver master]}]
  {:allow-header-cert-info   (true? (:allow-header-cert-info master))
   :ssl-client-verify-header (unmunge-http-header-name
                               (:ssl-client-verify-header puppetserver))
   :ssl-client-header        (unmunge-http-header-name
                               (:ssl-client-header puppetserver))})

(defn response->map
  "Converts a JRubyPuppetResponse instance to a map."
  [response]
  { :pre [(instance? JRubyPuppetResponse response)]
    :post [(map? %)]}
  { :status  (.getStatus response)
    :body    (.getBody response)
    :headers {"Content-Type"     (.getContentType response)
              "X-Puppet-Version" (.getPuppetVersion response)}})


(defn update-body-for-jruby
  "Converts the body from a request into a String if it is a form encoding.
   Otherwise, just returns back the same body InputStream."
  [request]
  (let [content-type (when-let [raw-type (:content-type request)]
                       (string/lower-case raw-type))]
    (if (= content-type "application/x-www-form-urlencoded")
      (update request :body slurp :encoding (or (:character-encoding request)
                                                "UTF-8"))
      request)))
(defn wrap-params-for-jruby
  "Pull parameters from the URL query string and/or urlencoded form POST
   body into the ring request map.  Includes some special processing for
   a request destined for JRubyPuppet."
  [request]
  (-> request
      update-body-for-jruby
      pl-ring-params/params-request))

(def unauthenticated-client-info
  "Return a map with default info for an unauthenticated client"
  {:client-cert-cn nil
   :authenticated  false})

(defn header-auth-info
  "Return a map with authentication info based on header content"
  [header-dn-name header-dn-val header-auth-name header-auth-val]
  (if (ssl-utils/valid-x500-name? header-dn-val)
    (let [cn (ssl-utils/x500-name->CN header-dn-val)
          authenticated (= "SUCCESS" header-auth-val)]
      (log/debug (i18n/trs "CN ''{0}'' provided by HTTP header ''{1}''"
                      cn header-dn-val))
      (log/debug (format "%s %s"
                         (i18n/trs "Verification of client ''{0}'' provided by HTTP header ''{1}'': ''{2}''."
                              cn
                              header-auth-name
                              header-auth-val)
                         (i18n/trs "Authenticated: {0}." authenticated)))
      {:client-cert-cn cn
       :authenticated authenticated})
    (do
      (if (nil? header-dn-val)
        (log/debug (format "%s %s"
                           (i18n/trs "No DN provided by the HTTP header ''{0}''." header-dn-name)
                           (i18n/trs "Treating client as unauthenticated.")))
        (log/error (format "%s %s"
                           (i18n/trs "DN ''{0}'' provided by the HTTP header ''{1}'' is malformed." header-dn-val header-dn-name)
                           (i18n/trs "Treating client as unauthenticated."))))
      unauthenticated-client-info)))

(defn header-cert->pem
  "Convert the header cert value into a PEM string"
  [header-cert]
  (try
    (ring-codec/url-decode header-cert)
    (catch Exception e
      (ringutils/throw-bad-request!
        (i18n/tru "Unable to URL decode the {0} header: {1}"
              header-client-cert-name (.getMessage e))))))

(defn pem->certs
  "Convert a pem string into certificate objects"
  [pem]
  (with-open [reader (StringReader. pem)]
    (try
      (ssl-utils/pem->certs reader)
      (catch Exception e
        (ringutils/throw-bad-request!
          (i18n/tru "Unable to parse {0} into certificate: {1}"
               header-client-cert-name (.getMessage e)))))))

(defn header-cert
  "Return an X509Certificate or nil from a string encoded for transmission
  in an HTTP header."
  [header-cert-val]
  (when header-cert-val
    (let [pem        (header-cert->pem header-cert-val)
          certs      (pem->certs pem)
          cert-count (count certs)]
      (condp = cert-count
        0 (ringutils/throw-bad-request!
            (i18n/tru "No certs found in PEM read from {0}" header-client-cert-name))
        1 (first certs)
        (ringutils/throw-bad-request!
          (i18n/tru "Only 1 PEM should be supplied for {0} but {1} found" header-client-cert-name cert-count))))))

(defn ssl-auth-info
  "Get map of client authentication info from the supplied
   `java.security.cert.X509Certificate` object.  If the supplied object is nil,
   the information returned would represent an 'unauthenticated' client."
  [ssl-client-cert]
  (if ssl-client-cert
    (let [cn (ssl-utils/get-cn-from-x509-certificate ssl-client-cert)
          authenticated (if (seq cn) true false)]
      (log/debug (i18n/trs "CN ''{0}'' provided by SSL certificate.  Authenticated: {1}."
                  cn authenticated))
      {:client-cert-cn cn
       :authenticated  authenticated})
    (do
      (log/debug (i18n/trs "No SSL client certificate provided. Treating client as unauthenticated."))
      unauthenticated-client-info)))

(defn client-auth-info
  "Get map of client authentication info for the client.  Map has the following
  keys:

  * :client-cert - A `java.security.cert.X509Certificate` object or nil
  * :client-cert-cn - The CN (Common Name) of the client, typically associated
                      with the CN attribute from the Distinguished Name
                      in an X.509 certificate's Subject.
  * :authenticated - A boolean representing whether or not the client is
                     considered to have been successfully authenticated.

  Parameters:

  * config - Map of configuration data
  * request - Ring request containing client data"
  [config request]
  (if (:authorization request)
    {:client-cert    (ring-auth/authorized-certificate request)
     :client-cert-cn (ring-auth/authorized-name request)
     :authenticated  (true? (ring-auth/authorized-authenticated request))}
    (let [headers (:headers request)
          header-dn-name (:ssl-client-header config)
          header-dn-val (get headers header-dn-name)
          header-auth-name (:ssl-client-verify-header config)
          header-auth-val (get headers header-auth-name)
          header-cert-val (get headers header-client-cert-name)]
      (if (:allow-header-cert-info config)
        (-> (header-auth-info header-dn-name
                              header-dn-val
                              header-auth-name
                              header-auth-val)
            (assoc :client-cert (header-cert header-cert-val)))
        (do
          (doseq [[header-name header-val]
                  {header-dn-name header-dn-val
                   header-auth-name header-auth-val
                   header-client-cert-name header-cert-val}
                  :when header-val]
            (log/warn (format "%s %s"
                              (i18n/trs "The HTTP header {0} was specified, but the master config option allow-header-cert-info was either not set, or was set to false." header-name)
                              (i18n/trs "This header will be ignored."))))
          (let [ssl-cert (:ssl-client-cert request)]
            (-> (ssl-auth-info ssl-cert)
                (assoc :client-cert ssl-cert))))))))

(defn as-jruby-request
  "Given a ring HTTP request, return a new map that contains all of the data
   needed by the ruby HTTP layer to process it.  This function does a couple
   things that are a bit weird:
      * It reads the entire request body into memory.  This is not ideal for
        performance and memory usage, but we have to ship this thing over to
        JRuby, so I don't think there's any way around this.
      * It also extracts the client DN and certificate and includes that
        in the map it returns, because it's needed by the ruby layer.  It is
        possible that the HTTPS termination has happened external to Puppet
        Server.  If so, then the DN, authentication status, and, optionally, the
        certificate will be provided by HTTP headers."
  [config request]
  (merge
    {:uri            (:uri request)
     :params         (:params request)
     :remote-addr    (:remote-addr request)
     :headers        (:headers request)
     :body           (:body request)
     :request-method (-> (:request-method request)
                         name
                         string/upper-case)}
    (client-auth-info config request)))

(defn make-request-mutable
  "Make the request mutable.  This is required by the ruby layer."
  [request]
  (HashMap. ^Map request))

(defn with-code-id
  "Wraps the given request with the current-code-id, if it contains a
  :include-code-id? key with a truthy value.  current-code-id is passed the
  environment from the request from it is invoked."
  [current-code-id request]
  (if (:include-code-id? request)
    (let [env (jruby-request/get-environment-from-request request)]
      (when-not (nil? (schema/check ps-common/Environment env))
        (ringutils/throw-bad-request! (ps-common/environment-validation-error-msg env)))
      (when-not env
        (ringutils/throw-bad-request! (i18n/tru "Environment is required in a catalog request.")))
      (assoc-in request [:params "code_id"] (current-code-id env)))
    request))

(defn jruby-request-handler
  "Build a request handler fn that processes a request using a JRubyPuppet instance"
  [config current-code-id]
  (fn [request]
    (try
      (->> request
           wrap-params-for-jruby
           (with-code-id current-code-id)
           (as-jruby-request config)
           walk/stringify-keys
           make-request-mutable
           (.handleRequest (:jruby-instance request))
           response->map)
      (finally
        ;; don't let any exceptions escape
        (try
          (let [[_ certname] (re-matches #"^/puppet/v3/catalog/(.*)" (ring-codec/url-decode (:uri request)))]
            (when certname
              (ps-common/record-action {:type    :action
                                        :targets [certname]
                                        :meta    {:type :certificate
                                                  :what :compile-catalog
                                                  :where :v3}})))
          (catch Throwable e
            (log/info e (i18n/trs "Failed to report action"))))))))


;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;; Public

(defn build-request-handler
  "Build the main request handler fn for JRuby requests."
  [jruby-service config current-code-id]
  (-> (jruby-request-handler config current-code-id)
      (jruby-request/wrap-with-jruby-instance jruby-service)
      jruby-request/wrap-with-error-handling))
