/*
     This file is part of GNUnet.
     Copyright (C) 2026 Taler Systems SA

     Paivana is free software; you can redistribute it and/or
     modify it under the terms of the GNU Affero General Public License
     as published by the Free Software Foundation; either version
     3, or (at your option) any later version.

     Paivana is distributed in the hope that it will be useful,
     but WITHOUT ANY WARRANTY; without even the implied warranty
     of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See
     the GNU Affero General Public License for more details.

     You should have received a copy of the GNU Affero General Public
     License along with Paivana; see the file COPYING.  If not,
     write to the Free Software Foundation, Inc., 51 Franklin
     Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

/**
 * @author Christian Grothoff
 * @file paivana-httpd_pay.c
 * @brief payment processing logic
 */
#include <microhttpd.h>
#include <gnunet/gnunet_util_lib.h>
#include <taler/taler_mhd_lib.h>
#include <taler/taler_json_lib.h>
#include <taler/taler_error_codes.h>
#include "paivana-httpd_cookie.h"
#include "paivana-httpd_helper.h"
#include "paivana-httpd_pay.h"

struct PayRequest;
#define TALER_MERCHANT_GET_PRIVATE_ORDER_RESULT_CLOSURE struct PayRequest
#include "taler/merchant/get-private-orders-ORDER_ID.h"

/**
 * How long we give the merchant backend to answer the
 * `GET /private/orders/$ORDER_ID' behind one client's redemption.
 *
 * A bound is needed at all because the endpoint is unauthenticated:
 * every syntactically valid POST suspends an MHD connection and issues
 * a backend query before any payment has been shown to exist, so
 * without one, a wedged backend pins a suspended connection per
 * request forever.  MHD_OPTION_CONNECTION_TIMEOUT does not apply to
 * suspended connections, so the deadline has to come from us.
 *
 * It is passed as TALER_MERCHANT_get_private_order_option_timeout(),
 * which does two things at once: it caps the request client-side
 * (CURLOPT_TIMEOUT_MS, so an unreachable or hung backend is bounded
 * here too) and it sets `timeout_ms' on the URL, asking the backend to
 * long-poll for that long before reporting an order as unpaid.
 *
 * The long poll is wanted, not merely tolerated.  By the time the
 * client posts here it has already seen the backend confirm the
 * payment, so an order that still reads "unpaid" means either a client
 * that is lying -- replaying an order ID it never paid -- or one that
 * raced a state change that is about to land.  Waiting a few seconds
 * settles the race in the honest client's favour, and the dishonest
 * one pays for it with a bounded wait and then a 409.  Keep it short:
 * this is the interval an attacker can pin a connection for.
 */
#define MERCHANT_ORDER_TIMEOUT \
        GNUNET_TIME_relative_multiply (GNUNET_TIME_UNIT_SECONDS, 5)


/**
 * Handle for processing actual payment.
 */
struct PayRequest
{

  /**
   * Kept in a DLL while suspended.
   */
  struct PayRequest *next;

  /**
   * Kept in a DLL while suspended.
   */
  struct PayRequest *prev;

  /**
   * Connection we are handling.
   */
  struct MHD_Connection *connection;

  /**
   * Buffer for TALER_MHD_parse_post_json().
   */
  void *buffer;

  /**
   * Uploaded JSON body, NULL if none yet.
   */
  json_t *body;

  /**
   * Handle for our request to the merchant backend. This
   * struct is in the #ph_head DLL as long as @e co is non-NULL.
   */
  struct TALER_MERCHANT_GetPrivateOrderHandle *co;

  /**
   * Response to return, NULL if not yet determined.
   */
  struct MHD_Response *response;

  /**
   * ID of the order the client claims to have paid. Aliased
   * from @e body.
   */
  const char *order_id;

  /**
   * Website the order is supposed to have paid for. Aliased
   * from @e body.
   */
  const char *website;

  /**
   * Client-side nonce.
   */
  struct PAIVANA_Nonce nonce;

  /**
   * End of the access the client is redeeming: the expiration of the
   * cookie we mint, and one of the three inputs the client hashed into
   * the paivana_id the order was created under.  Chosen by the client
   * and bounded above by the contract's `max_pickup_time'.
   */
  struct GNUNET_TIME_Timestamp expiration;

  /**
   * HTTP status to return in combination with @e response to the
   * client.
   */
  unsigned int response_status;

};


/**
 * Head of DLL of suspended requests.
 */
static struct PayRequest *ph_head;

/**
 * Tail of DLL of suspended requests.
 */
static struct PayRequest *ph_tail;


void
PAIVANA_HTTPD_payment_shutdown ()
{
  while (NULL != ph_head)
  {
    struct PayRequest *ph = ph_head;

    if (NULL != ph->co)
    {
      TALER_MERCHANT_get_private_order_cancel (ph->co);
      ph->co = NULL;
    }
    GNUNET_CONTAINER_DLL_remove (ph_head,
                                 ph_tail,
                                 ph);
    MHD_resume_connection (ph->connection);
    /* Note: PAIVANA_HTTPD_payment_destroy()
       will be called by the owner of 'ph',
       no need to do it here! */
  }
}


struct PayRequest *
PAIVANA_HTTPD_payment_create (struct MHD_Connection *connection)
{
  struct PayRequest *ph;

  ph = GNUNET_new (struct PayRequest);
  ph->connection = connection;
  return ph;
}


/**
 * Is @a website a URL below our own base URL?
 *
 * Used to bound where a client may send itself once it has paid for
 * an order that carries no fulfillment URL of its own: without this
 * the client picks the redirect target and the site the access cookie
 * is minted for.
 *
 * The comparison is on whole path segments.  A bare prefix test would
 * accept "https://example.com.evil.net/" for a base URL of
 * "https://example.com", because strip_trailing_slashes() has removed
 * the '/' that used to terminate it.
 *
 * @param website candidate URL, from the client
 * @return true if @a website is our base URL or something below it
 */
static bool
under_our_base_url (const char *website)
{
  size_t blen;

  if (NULL == PH_base_url)
  {
    /* BASE_URL is optional; without it we have nothing to compare
       against and must not guess.  Note that dereferencing it here
       used to be an unconditional crash. */
    GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
                "Cannot check the target of an order without a"
                " fulfillment URL: BASE_URL is not configured\n");
    return false;
  }
  blen = strlen (PH_base_url);
  if (0 != strncmp (website,
                    PH_base_url,
                    blen))
    return false;
  /* PH_base_url has no trailing '/' (strip_trailing_slashes()), so
     require the boundary here rather than inheriting it. */
  return ('\0' == website[blen]) ||
         ('/' == website[blen]);
}


/**
 * Check that the @a contract that was paid is reasonable for the
 * request in @a ph, that is that we would indeed consider this
 * contract to apply for the website and duration indicated
 * in @a ph. If it does not apply, a response must be set in
 * @a ph.
 *
 * @param[in,out] ph request to check
 * @param contract contract to check
 * @return true if the contract is good for the request,
 *   false if not and thus a response object was created in @a ph
 */
static bool
check_contract (struct PayRequest *ph,
                const json_t *contract)
{
  struct GNUNET_TIME_Timestamp max_time
    = GNUNET_TIME_UNIT_FOREVER_TS;
  const char *target = NULL;
  struct GNUNET_JSON_Specification spec[] = {
    GNUNET_JSON_spec_mark_optional (
      TALER_JSON_spec_web_url ("fulfillment_url",
                               &target),
      NULL),
    GNUNET_JSON_spec_mark_optional (
      GNUNET_JSON_spec_timestamp ("max_pickup_time",
                                  &max_time),
      NULL),
    GNUNET_JSON_spec_end ()
  };
  enum GNUNET_GenericReturnValue ret;
  const char *ename;
  unsigned int eline;

  ret = GNUNET_JSON_parse (contract,
                           spec,
                           &ename,
                           &eline);
  if (GNUNET_OK != ret)
  {
    GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
                "Encountered contract with unexpected fields: %s@%u\n",
                ename,
                eline);
    /* Fail closed: returning true here would skip every check below --
       the fulfillment_url binding, the base-URL containment test and
       the max_pickup_time deadline -- and mint an access cookie for
       whatever website the client named.  Tolerating unknown *extra*
       fields is already what GNUNET_JSON_parse() does; a failure here
       means a field we do look at was malformed. */
    GNUNET_break_op (0);
    ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_WRONG_ORDER,
                                         ph->order_id);
    ph->response_status = MHD_HTTP_CONFLICT;
    return false;
  }
  if ( (NULL != target) &&
       (0 != strcmp (target,
                     ph->website)) )
  {
    GNUNET_break_op (0);
    ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_WRONG_ORDER,
                                         ph->order_id);
    ph->response_status = MHD_HTTP_CONFLICT;
    return false;
  }
  if ( ( (NULL == target) &&
         (! under_our_base_url (ph->website)) ) ||
       (! TALER_is_web_url (ph->website)) )
  {
    /* Bad: the order has no fulfillment URL, and on top of that
       the target given is not from our domain or not a well-formed
       URL. Reject hard. */
    GNUNET_break_op (0);
    ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_INVALID_TARGET,
                                         ph->website);
    ph->response_status = MHD_HTTP_CONFLICT;
    return false;
  }
  if (GNUNET_TIME_timestamp_cmp (ph->expiration,
                                 >,
                                 max_time))
  {
    GNUNET_break_op (0);
    ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_TOO_LATE,
                                         ph->order_id);
    ph->response_status = MHD_HTTP_GONE;
    return false;
  }
  return true;
}


/**
 * Handle response from the GET /private/orders/$ORDER_ID request.
 *
 * @param ph the payment request we are processing
 * @param osr response details
 */
static void
order_status_cb (struct PayRequest *ph,
                 const struct TALER_MERCHANT_GetPrivateOrderResponse *osr)
{
  ph->co = NULL;
  GNUNET_CONTAINER_DLL_remove (ph_head,
                               ph_tail,
                               ph);
  MHD_resume_connection (ph->connection);
  TALER_MHD_daemon_trigger ();
  switch (osr->hr.http_status)
  {
  case MHD_HTTP_OK:
    /* "paid" survives a refund -- the merchant reports the refund in
       separate fields (api-merchant.rst, CheckPaymentPaidResponse) --
       so testing the status alone would hand a fresh cookie to someone
       who has had their money back. */
    if ( (TALER_MERCHANT_OSC_PAID != osr->details.ok.status) ||
         (osr->details.ok.details.paid.refunded) ||
         (osr->details.ok.details.paid.refund_pending) )
    {
      GNUNET_break_op (0);
      ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_PAYMENT_MISSING,
                                           ph->order_id);
      ph->response_status = MHD_HTTP_CONFLICT;
    }
    else
    {
      void *ca = NULL;
      size_t ca_len = 0;
      char *cookie;
      struct MHD_Response *resp;

      if (! check_contract (ph,
                            osr->details.ok.details.paid.contract_terms))
        return;
      /* The client address is bound into the cookie MAC; computing
         the cookie over an empty address would produce a cookie that
         PAIVANA_HTTPD_check_cookie can never match, silently denying
         the access the client just paid for.  Treat failure to obtain
         it as a hard error instead.

         Note: This should become conditional once we add a
         configuration option to not include the client address in the
         cookie hash to allow one payment to be used from any IP
         address. */
      if (! PAIVANA_HTTPD_get_client_address (ph->connection,
                                              &ca,
                                              &ca_len))
      {
        GNUNET_break (0);
        ph->response = TALER_MHD_make_error (
          TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
          ph->order_id);
        ph->response_status = MHD_HTTP_INTERNAL_SERVER_ERROR;
        break;
      }
      cookie = PAIVANA_HTTPD_compute_cookie (ph->expiration,
                                             ph->website,
                                             ca_len,
                                             ca);
      /* The cookie is the bearer credential proving payment; anyone
         who can read the log could replay it from the same address. */
      GNUNET_log (GNUNET_ERROR_TYPE_INFO,
                  "Client paid for `%s', setting access cookie\n",
                  ph->website);
      GNUNET_free (ca);
      resp = MHD_create_response_from_buffer (0,
                                              NULL,
                                              MHD_RESPMEM_PERSISTENT);
      GNUNET_assert (NULL != resp);
      if ( (MHD_YES !=
            MHD_add_response_header (resp,
                                     MHD_HTTP_HEADER_SET_COOKIE,
                                     cookie)) ||
           (MHD_YES !=
            MHD_add_response_header (resp,
                                     MHD_HTTP_HEADER_LOCATION,
                                     ph->website)) )
      {
        /* Neither header is optional: without the `Set-Cookie' the
           client has paid and been sent back to a page that will
           paywall it again, and without the `Location' the 303 has no
           target at all.  Answering 500 at least says so, and leaves
           the order paid and the redemption repeatable; sending the
           303 anyway does not. */
        GNUNET_break (0);
        MHD_destroy_response (resp);
        GNUNET_free (cookie);
        ph->response = TALER_MHD_make_error (
          TALER_EC_GENERIC_INTERNAL_INVARIANT_FAILURE,
          ph->website);
        ph->response_status = MHD_HTTP_INTERNAL_SERVER_ERROR;
        break;
      }
      GNUNET_free (cookie);
      TALER_MHD_add_global_headers (resp,
                                    false);
      ph->response = resp;
      ph->response_status = MHD_HTTP_SEE_OTHER;
    }
    break;
  case MHD_HTTP_UNAUTHORIZED:
  case MHD_HTTP_FORBIDDEN:
    /* Our `MERCHANT_ACCESS_TOKEN' is wrong: the operator's problem, not
       the client's, hence 500 and not a 4xx.  UNAUTHORIZED is the case
       that actually fires -- taler-merchant-httpd_auth.c answers a bad
       bearer token with 401 -- and without it this landed in the
       default branch below, telling the operator that a protocol
       incompatibility should be reported to us.

       Note that GANA has 9801 documented as a 502 and 9803 as a 500,
       i.e. the two the other way round from what is sent here and
       below.  The statuses are right: RFC 9110 section 15.6.3 gives
       502 for "an invalid response from an inbound server", which is
       the unexpected-status case (9803), while a bearer token of ours
       that the backend will not take is our own misconfiguration and
       not the upstream misbehaving (9801).  Fixing the registry is a
       change in gana, a different repository. */
    GNUNET_log (GNUNET_ERROR_TYPE_ERROR,
                "Merchant backend at `%s' rejected our credentials (HTTP"
                " %u); check MERCHANT_ACCESS_TOKEN\n",
                PH_merchant_base_url,
                osr->hr.http_status);
    ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_BACKEND_REFUSED,
                                         NULL);
    ph->response_status = MHD_HTTP_INTERNAL_SERVER_ERROR;
    break;
  case MHD_HTTP_NOT_FOUND:
    ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_ORDER_UNKNOWN,
                                         ph->order_id);
    ph->response_status = MHD_HTTP_NOT_FOUND;
    break;
  case 0:
    /* No HTTP status at all.  The merchant client library reports this
       both for a request that never completed and for one whose reply
       it could not make sense of, with the same error code; @e reply is
       what tells them apart, being NULL only in the former case. */
    if (NULL != osr->hr.reply)
    {
      GNUNET_break_op (0);
      GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
                  "Merchant backend at `%s' sent an unusable reply for"
                  " order `%s'\n",
                  PH_merchant_base_url,
                  ph->order_id);
      ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_BACKEND_ERROR,
                                           ph->order_id);
      ph->response_status = MHD_HTTP_BAD_GATEWAY;
      break;
    }
    /* Nothing came back: the request hit #MERCHANT_ORDER_TIMEOUT, or
       the backend was unreachable.  Either way the client has waited
       for us rather than been answered, which is what separates 504
       from the 502 above. */
    GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
                "Merchant backend at `%s' did not answer for order `%s'"
                " within %s; giving up on this redemption\n",
                PH_merchant_base_url,
                ph->order_id,
                GNUNET_STRINGS_relative_time_to_string (MERCHANT_ORDER_TIMEOUT,
                                                        true));
    /* GENERIC_TIMEOUT's hint ("trying again might help") is the one
       that is true for the client here; GET_ORDER_FAILED says "this
       should never happen, consult the logs", which is wrong advice
       for a backend that was merely slow.  A dedicated
       PAIVANA_BACKEND_TIMEOUT would be better still, but that is a
       GANA registration and so its own change in another
       repository. */
    ph->response = TALER_MHD_make_error (TALER_EC_GENERIC_TIMEOUT,
                                         ph->order_id);
    ph->response_status = MHD_HTTP_GATEWAY_TIMEOUT;
    break;
  default:
    {
      char code[20];

      GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
                  "Unexpected status code %u from backend\n",
                  osr->hr.http_status);
      GNUNET_snprintf (code,
                       sizeof (code),
                       "%u",
                       osr->hr.http_status);
      ph->response = TALER_MHD_make_error (TALER_EC_PAIVANA_BACKEND_ERROR,
                                           code);
      ph->response_status = MHD_HTTP_BAD_GATEWAY;
    }
    break;
  }
}


enum MHD_Result
PAIVANA_HTTPD_payment_handle (struct PayRequest *ph,
                              const char *upload_data,
                              size_t *upload_data_size)
{
  if (NULL == ph->body)
  {
    enum GNUNET_GenericReturnValue ret;

    ret = TALER_MHD_parse_post_json (ph->connection,
                                     &ph->buffer,
                                     upload_data,
                                     upload_data_size,
                                     &ph->body);
    if (GNUNET_OK != ret)
      return (GNUNET_NO == ret) ? MHD_YES : MHD_NO;
    if (NULL == ph->body)
      return MHD_YES;
  }
  if (NULL != ph->response)
  {
    return MHD_queue_response (ph->connection,
                               ph->response_status,
                               ph->response);
  }
  if (NULL == ph->order_id)
  {
    struct GNUNET_JSON_Specification spec[] = {
      TALER_JSON_spec_slug ("order_id",
                            &ph->order_id),
      TALER_JSON_spec_web_url ("website",
                               &ph->website),
      GNUNET_JSON_spec_timestamp ("expiration",
                                  &ph->expiration),
      GNUNET_JSON_spec_fixed_auto ("nonce",
                                   &ph->nonce),
      GNUNET_JSON_spec_end ()
    };
    enum GNUNET_GenericReturnValue ret;

    ret = TALER_MHD_parse_json_data (ph->connection,
                                     ph->body,
                                     spec);
    if (GNUNET_YES != ret)
      return (GNUNET_NO == ret) ? MHD_YES : MHD_NO;
    /* `website' is a URL like any other paivana handles, and every
       other one is refused past #PH_MAX_URL_LENGTH before anything
       looks at it.  This one arrives in a JSON body instead of a
       request line, which is the only reason it escaped that: from
       here it goes into an HKDF, into a `Location' and into the
       `Path' of a `Set-Cookie', where three bytes of header are spent
       per byte of path.  Bound it in the same place and at the same
       length. */
    if (PH_MAX_URL_LENGTH < strlen (ph->website))
    {
      GNUNET_break_op (0);
      GNUNET_log (GNUNET_ERROR_TYPE_WARNING,
                  "Refusing %llu byte `website' in payment redemption\n",
                  (unsigned long long) strlen (ph->website));
      return TALER_MHD_reply_with_error (
        ph->connection,
        MHD_HTTP_BAD_REQUEST,
        TALER_EC_GENERIC_PARAMETER_MALFORMED,
        "website");
    }
    /* `expiration' is the end of the access being bought, not a
       statement about the client's clock: it is what the cookie's
       Max-Age is computed from, what check_cookie() enforces, and what
       is hashed into the paivana_id the order was created under, so we
       cannot re-derive it here even if we wanted to.  An expiration in
       the past would mint a cookie that is dead on arrival; the upper
       bound is the contract's `max_pickup_time', enforced in
       check_contract() once we have the contract to compare against. */
    if (GNUNET_TIME_absolute_is_past (ph->expiration.abs_time))
    {
      GNUNET_break_op (0);
      return TALER_MHD_reply_with_error (
        ph->connection,
        MHD_HTTP_BAD_REQUEST,
        TALER_EC_GENERIC_PARAMETER_MALFORMED,
        "expiration");
    }
  }
  GNUNET_assert (NULL == ph->co);
  ph->co = TALER_MERCHANT_get_private_order_create (PH_merchant_ctx,
                                                    PH_merchant_base_url,
                                                    ph->order_id);
  if (NULL == ph->co)
  {
    GNUNET_break (0);
    return TALER_MHD_reply_with_error (ph->connection,
                                       MHD_HTTP_INTERNAL_SERVER_ERROR,
                                       TALER_EC_PAIVANA_GET_ORDER_FAILED,
                                       ph->order_id);
  }
  {
    char *paivana_id;

    paivana_id = PAIVANA_HTTPD_compute_paivana_id (ph->expiration,
                                                   ph->website,
                                                   &ph->nonce);
    GNUNET_assert (
      GNUNET_OK ==
      TALER_MERCHANT_get_private_order_set_options (
        ph->co,
        TALER_MERCHANT_get_private_order_option_session_id (
          paivana_id),
        TALER_MERCHANT_get_private_order_option_timeout (
          MERCHANT_ORDER_TIMEOUT)));
    GNUNET_free (paivana_id);
  }
  GNUNET_CONTAINER_DLL_insert (ph_head,
                               ph_tail,
                               ph);
  MHD_suspend_connection (ph->connection);
  {
    enum TALER_ErrorCode ec;

    ec = TALER_MERCHANT_get_private_order_start (ph->co,
                                                 &order_status_cb,
                                                 ph);
    if (TALER_EC_NONE != ec)
    {
      /* Everything the callee can fail on here is a resource failure
         it recovers from by telling us: curl_easy_init() or
         curl_multi_add_handle() came back empty.  That is one client's
         redemption going wrong, and asserting on it took the daemon
         down with every other request in flight, paid ones included. */
      GNUNET_break (0);
      GNUNET_CONTAINER_DLL_remove (ph_head,
                                   ph_tail,
                                   ph);
      MHD_resume_connection (ph->connection);
      TALER_MERCHANT_get_private_order_cancel (ph->co);
      ph->co = NULL;
      return TALER_MHD_reply_with_error (ph->connection,
                                         MHD_HTTP_INTERNAL_SERVER_ERROR,
                                         TALER_EC_PAIVANA_GET_ORDER_FAILED,
                                         ph->order_id);
    }
  }
  return MHD_YES;
}


void
PAIVANA_HTTPD_payment_destroy (struct PayRequest *ph)
{
  TALER_MHD_parse_post_cleanup_callback (ph->buffer);
  if (NULL != ph->co)
  {
    TALER_MERCHANT_get_private_order_cancel (ph->co);
    GNUNET_CONTAINER_DLL_remove (ph_head,
                                 ph_tail,
                                 ph);
    ph->co = NULL;
  }
  if (NULL != ph->response)
  {
    MHD_destroy_response (ph->response);
    ph->response = NULL;
  }
  json_decref (ph->body);
  GNUNET_free (ph);
}
