<?php

/**
 * @file
 * Fake Mollom REST Testing server implementation.
 *
 * This is a simplified re-implementation of Mollom's REST Testing API, which
 * is used by functional Drupal module tests only.
 *
 * While the duplication means additional engineering work, the fake server
 * provides unique testing facilities that are not possible to achieve with
 * Mollom's official REST Testing API server:
 *
 * - All requests and request parameters are stored, which enables tests to
 *   retrieve previously sent request information and assert that the client
 *   sent the expected parameters and values (and no unexpected parameters).
 *   This is important since most of the business logic for request parameters
 *   depends on external user input and lives in form processing code; i.e.,
 *   the Mollom module's primary functionality is not unit-testable.
 * - Special test server variables allow tests to trigger special and unexpected
 *   service conditions in between requests, such as 404s or service downtimes,
 *   without having to manipulate the regular request parameters (and thus,
 *   without having to add testing related code and logic to the regular runtime
 *   code).
 * - The module's behavior and communication can be tested against this fake
 *   server implementation without having the Testing mode enabled; i.e., this
 *   fake server can also act as production server.
 *
 * @see MollomWebTestCase::getServerRecord()
 *
 * In addition, this fake server implementation allows to double-check whether
 * Mollom's API follows the expectations of Mollom client developers, and it
 * successfully helped to discover inconsistencies as well as unexpected API
 * behaviors in the past.
 *
 * The module communicates with this fake server when the 'mollom_class'
 * variable is set to 'MollomDrupalTestLocal'. The fake test server module is
 * automatically enabled for tests when needed.
 *
 * @see MollomWebTestCase::setUp()
 * @see MollomDrupalTestLocal
 *
 * The fake server architecture is kept as simple as possible:
 *
 * - Menu router definitions are registering a controller/handler for each main
 *   API endpoint.
 * - The controller callback validates OAuth parameters (when applicable) and
 *   optionally calls into dedicated functions to handle the request-specific
 *   logic (e.g., Content API) or performs the logic directly (e.g., Site API).
 * - The callback result, i.e. response parameters (or error code), is processed
 *   by the delivery callback and transformed into an HTTP response following
 *   the Mollom API format.
 *
 * @see mollom_test_server_menu()
 * @see mollom_test_server_rest_deliver()
 *
 * Clean URLs must be enabled.
 */

/**
 * Implements hook_menu().
 */
function mollom_test_server_menu() {
  // Base path and argument count for all registered routes.
  $path = 'mollom-test/rest/v1';
  $base_args = count(explode('/', $path)) - 1;
  // @todo Consider to use a generic page callback, passing arg(3), the resource
  //   type, and optionally arg(4), the resource, as argument. This would allow
  //   us to use PHP Exceptions to throw different status codes and errors. Make
  //   that page callback dynamically switch the delivery callback (for JSON).
  $base = array(
    // Access depends on whether OAuth parameters have been sent, and in any
    // case, error responses must be in the requested/accepted Content-Type
    // passed by the client (as opposed to a 403 HTML page generated by Drupal).
    'access callback' => TRUE,
    'type' => MENU_CALLBACK,
    'delivery callback' => 'mollom_test_server_rest_deliver',
  );

  $items[$path . '/site'] = $base + array(
    'page callback' => 'mollom_test_server_rest_site',
  );
  $items[$path . '/content'] = $base + array(
    'page callback' => 'mollom_test_server_rest_content',
  );
  $items[$path . '/captcha'] = $base + array(
    'page callback' => 'mollom_test_server_rest_captcha',
  );
  $items[$path . '/feedback'] = $base + array(
    'page callback' => 'mollom_test_server_rest_send_feedback',
  );
  $items[$path . '/blacklist/%'] = $base + array(
    'page callback' => 'mollom_test_server_rest_blacklist',
    'page arguments' => array($base_args + 2),
  );
  // @todo Whitelist endpoints.

  return $items;
}

/**
 * Returns HTTP request query parameters for the current request.
 *
 * @see Mollom::httpBuildQuery()
 * @see http://php.net/manual/en/wrappers.php.php
 */
function mollom_test_server_rest_get_parameters() {
  $data = &drupal_static(__FUNCTION__);

  if (isset($data)) {
    return $data;
  }

  // @todo Replace with Mollom::getServerParameters()
  if ($_SERVER['REQUEST_METHOD'] == 'GET' || $_SERVER['REQUEST_METHOD'] == 'HEAD') {
    $data = Mollom::httpParseQuery($_SERVER['QUERY_STRING']);
    // Remove $_GET['q'].
    // @see .htaccess
    unset($data['q']);
  }
  elseif ($_SERVER['REQUEST_METHOD'] == 'POST' || $_SERVER['REQUEST_METHOD'] == 'PUT') {
    $data = Mollom::httpParseQuery(file_get_contents('php://input'));
  }
  return $data;
}

/**
 * Returns the parsed HTTP Authorization request header as an array.
 *
 * @todo Replace with Mollom::getServerAuthentication()
 */
function mollom_test_server_rest_get_auth_header() {
  $header = &drupal_static(__FUNCTION__);

  if (isset($header)) {
    return $header;
  }

  $header = array();
  if (function_exists('apache_request_headers')) {
    $headers = apache_request_headers();
    if (isset($headers['Authorization'])) {
      $input = $headers['Authorization'];
    }
  }
  elseif (isset($_SERVER['HTTP_AUTHORIZATION'])) {
    $input = $_SERVER['HTTP_AUTHORIZATION'];
  }
  if (isset($input)) {
    preg_match_all('@([^, =]+)="([^"]*)"@', $input, $header);
    $header = array_combine($header[1], $header[2]);
  }
  return $header;
}

/**
 * Delivery callback for REST API endpoints.
 */
function mollom_test_server_rest_deliver($page_callback_result) {
  // All fake-server responses are not cached.
  drupal_page_is_cacheable(FALSE);

  drupal_add_http_header('Content-Type', 'application/xml; charset=utf-8');

  $xml = new DOMDocument('1.0', 'utf-8');
  $element = $xml->createElement('response');

  // Append status response parameters.
  // @todo Add support for custom codes (redirect/refresh) + error messages.
  $code = 200;
  if (!is_array($page_callback_result) && $page_callback_result !== TRUE) {
    switch ($page_callback_result) {
      case MENU_NOT_FOUND:
        $code = 404;
        $message = 'Not found';
        break;

      case Mollom::AUTH_ERROR:
        $code = 401;
        $message = 'Unauthorized';
        break;

      default:
        $code = 400;
        $message = 'Bad request';
        break;
    }
  }
  $status = array(
    'code' => $code,
  );
  if (isset($message)) {
    $status['message'] = $message;
  }
  mollom_test_server_rest_add_xml($xml, $element, $status);

  // Append other response parameters.
  if (is_array($page_callback_result)) {
    mollom_test_server_rest_add_xml($xml, $element, $page_callback_result);
  }

  $xml->appendChild($element);
  print $xml->saveXML();

  // Perform end-of-request tasks.
  drupal_page_footer();
}

function mollom_test_server_rest_add_xml(DOMDocument $doc, DOMNode $parent, $data, $key = NULL) {
  if (is_scalar($data)) {
    // Mollom REST API always uses integers instead of Booleans due to varying
    // implementations of JSON protocol across client platforms/frameworks.
    if (is_bool($data)) {
      $data = (int) $data;
    }

    $element = $doc->createTextNode($data);
    $parent->appendChild($element);
  }
  else {
    foreach ($data as $property => $value) {
      $key = (is_numeric($property) ? 'item' : $property);
      $element = $doc->createElement($key);
      $parent->appendChild($element);
      mollom_test_server_rest_add_xml($doc, $element, $value, $key);
    }
  }
}

/**
 * Returns whether the OAuth request signature is valid.
 */
function mollom_test_server_rest_validate_auth() {
  $data = mollom_test_server_rest_get_parameters();
  $header = mollom_test_server_rest_get_auth_header();
  $sites = variable_get('mollom_test_server_site', array());

  // Validate the timestamp.
  $client_time = $header['oauth_timestamp'];
  $time = REQUEST_TIME;
  $offset = abs($time - $client_time);
  if ($offset > Mollom::TIME_OFFSET_MAX) {
    return FALSE;
  }

  $sent_signature = $header['oauth_signature'];
  unset($header['oauth_signature']);

  $base_string = implode('&', array(
    $_SERVER['REQUEST_METHOD'],
    Mollom::rawurlencode($GLOBALS['base_url'] . '/' . $_GET['q']),
    Mollom::rawurlencode(Mollom::httpBuildQuery($data + $header)),
  ));
  $nonce = $header['oauth_nonce'];

  if (!isset($sites[$header['oauth_consumer_key']]['privateKey'])) {
    return FALSE;
  }
  $privateKey = $sites[$header['oauth_consumer_key']]['privateKey'];
  $key = Mollom::rawurlencode($privateKey) . '&' . '';

  $signature = rawurlencode(base64_encode(hash_hmac('sha1', $base_string, $key, TRUE)));

  return $signature === $sent_signature;
}

/**
 * REST callback for CRUD site operations.
 *
 * @param $publicKey
 *   (optional) The public key of a site.
 * @param $delete
 *   (optional) Whether to delete the site with $publicKey.
 */
function mollom_test_server_rest_site($publicKey = NULL, $delete = FALSE) {
  $data = mollom_test_server_rest_get_parameters();

  $bin = 'mollom_test_server_site';
  $sites = variable_get($bin, array());

  if (isset($publicKey)) {
    // Validate authentication.
    if (!mollom_test_server_rest_validate_auth()) {
      return Mollom::AUTH_ERROR;
    }
    // Check whether publicKey exists.
    if (!isset($sites[$publicKey])) {
      return MENU_NOT_FOUND;
    }
  }

  if ($_SERVER['REQUEST_METHOD'] == 'GET') {
    // Return existing site.
    if (isset($publicKey)) {
      $response = $sites[$publicKey];
    }
    // Return list of existing sites.
    else {
      $response = array(
        'list' => array_values($sites),
        'listCount' => count($sites),
        'listOffset' => 0,
        'listTotal' => count($sites),
      );
      return $response;
    }
  }
  else {
    // Update site.
    if (isset($publicKey) && !$delete) {
      $sites[$publicKey] = $data + $sites[$publicKey];
      variable_set($bin, $sites);
      $response = $sites[$publicKey];
    }
    // Create new site.
    // Authentication is ignored in this case.
    elseif (!$delete) {
      $data['publicKey'] = $publicKey = md5(rand() . REQUEST_TIME);
      $data['privateKey'] = $privateKey = md5(rand() . REQUEST_TIME);
      // Apply default values.
      $data += array(
        'url' => '',
        'email' => '',
        'expectedLanguages' => array(),
        'subscriptionType' => '',
        // Client version info is not defined by default.
      );
      $sites[$publicKey] = $data;
      variable_set($bin, $sites);
      $response = $data;
    }
    // Delete site.
    else {
      unset($sites[$publicKey]);
      variable_set($bin, $sites);
      return TRUE;
    }
  }
  return array('site' => $response);
}

/**
 * REST callback for mollom.checkContent to perform textual analysis.
 */
function mollom_test_server_rest_content($contentId = NULL) {
  $data = mollom_test_server_rest_get_parameters();
  if ($_SERVER['REQUEST_METHOD'] == 'GET') {
    // @todo List/read content.
    if (empty($contentId)) {
      return FALSE;
    }
    return FALSE;
  }
  else {
    // Content ID in request parameters must match the one in path.
    if (isset($data['id']) && $data['id'] != $contentId) {
      return FALSE;
    }
    if (isset($contentId)) {
      $data['id'] = $contentId;
    }
  }

  // Default POST: Create or update content and check it.
  return array('content' => mollom_test_server_check_content($data));
}

/**
 * REST callback to for CAPTCHAs.
 */
function mollom_test_server_rest_captcha($captchaId = NULL) {
  $data = mollom_test_server_rest_get_parameters();
  if ($_SERVER['REQUEST_METHOD'] == 'GET') {
    // There is no GET /captcha[/{captchaId}].
    return FALSE;
  }
  else {
    // CAPTCHA ID in request parameters must match the one in path.
    if (isset($data['id']) && $data['id'] != $captchaId) {
      return FALSE;
    }
    // Verify CAPTCHA.
    if (isset($captchaId)) {
      $data['id'] = $captchaId;
      $response = mollom_test_server_check_captcha($data);
      if (!is_array($response)) {
        return $response;
      }
      return array('captcha' => $response);
    }
  }
  // Create a new CAPTCHA resource.
  return array('captcha' => mollom_test_server_get_captcha($data));
}

/**
 * REST callback for Blacklist API.
 *
 * @param $public_key
 *   The public key of a site.
 *
 * @todo Abstract actual functionality like other REST handlers.
 */
function mollom_test_server_rest_blacklist($public_key, $entryId = NULL, $delete = FALSE) {
  if (empty($public_key)) {
    return FALSE;
  }
  $data = mollom_test_server_rest_get_parameters();

  // Prepare text value.
  if (isset($data['value'])) {
    $data['value'] = drupal_strtolower(trim($data['value']));
  }

  $bin = 'mollom_test_server_blacklist_' . $public_key;
  $entries = variable_get($bin, array());

  if ($_SERVER['REQUEST_METHOD'] == 'GET') {
    // List blacklist entries.
    if (empty($entryId)) {
      $response = array();
      // Remove deleted entries (== FALSE).
      $entries = array_filter($entries);
      $response['list'] = $entries;
      // @todo Not required yet.
      $response['listCount'] = count($entries);
      $response['listOffset'] = 0;
      $response['listTotal'] = count($entries);
      return $response;
    }
    // Read a single entry.
    else {
      // Check whether the entry exists and was not deleted.
      if (!empty($entries[$entryId])) {
        return array('entry' => $entries[$entryId]);
      }
      else {
        return MENU_NOT_FOUND;
      }
    }
  }
  else {
    // Update an existing entry.
    if (isset($entryId)) {
      // Entry ID must match.
      if (isset($data['id']) && $data['id'] != $entryId) {
        return FALSE;
      }
      // Check that the entry was not deleted.
      if (empty($entries[$entryId])) {
        return MENU_NOT_FOUND;
      }
      // Entry ID cannot be updated.
      unset($data['id']);
      $entries[$entryId] = $data;
      variable_set($bin, $entries);
      $response = $data;
      $response['id'] = $entryId;
      return array('entry' => $response);
    }
    // Create a new entry.
    elseif (!$delete) {
      $entryId = max(array_keys($entries)) + 1;
      $data['id'] = $entryId;
      $entries[$entryId] = $data;
      variable_set($bin, $entries);

      $response = $data;
      return array('entry' => $response);
    }
    // Delete an existing entry.
    else {
      // Check that the entry was not deleted already.
      if (!empty($entries[$entryId])) {
        $entries[$entryId] = FALSE;
        variable_set($bin, $entries);
        return TRUE;
      }
      else {
        return MENU_NOT_FOUND;
      }
    }
  }
}

/**
 * REST callback for mollom.sendFeedback to send feedback for a moderated post.
 */
function mollom_test_server_rest_send_feedback() {
  $data = mollom_test_server_rest_get_parameters();
  // A resource ID is required.
  if (empty($data['contentId']) && empty($data['captchaId'])) {
    return 400;
  }

  // The feedback is valid if the supplied reason is one of the supported
  // strings. Otherwise, it's a bad request.
  $storage = variable_get('mollom_test_server_feedback', array());
  $storage[] = $data;
  variable_set('mollom_test_server_feedback', $storage);

  // Default value assumed in the API for feedback type is "moderate".
  if (empty($data['type'])) {
    $data['type'] = 'moderate';
  }

  $reason_result = in_array($data['reason'], array('spam', 'profanity', 'quality', 'unwanted', 'approve', 'delete'));
  $feedback_result = in_array($data['type'], array('flag', 'moderate'));
  return $reason_result && $feedback_result ? TRUE : 400;
}

/**
 * API callback for mollom.checkContent to perform textual analysis.
 *
 * @todo Add support for 'redirect' and 'refresh' values.
 */
function mollom_test_server_check_content($data) {
  $response = array();

  // If only a single value for checks is passed, it is a string.
  if (isset($data['checks']) && is_string($data['checks'])) {
    $data['checks'] = array($data['checks']);
  }

  $header = mollom_test_server_rest_get_auth_header();
  $publicKey = $header['oauth_consumer_key'];

  // Fetch blacklist.
  $blacklist = variable_get('mollom_test_server_blacklist_' . $publicKey, array());

  // Determine content keys to analyze.
  $post_keys = array('postTitle' => 1, 'postBody' => 1);
  $type = FALSE;
  if (isset($data['type']) && in_array($data['type'], array('user'))) {
    $type = $data['type'];
    if ($type == 'user') {
      $post_keys += array('authorName' => 1, 'authorMail' => 1);
    }
  }
  $post = implode('\n', array_intersect_key($data, $post_keys));

  $update = isset($data['stored']);

  // Spam filter: Check post_title and post_body for ham, spam, or unsure.
  if (!$update && (!isset($data['checks']) || in_array('spam', $data['checks']))) {
    $spam = FALSE;
    $ham = FALSE;
    // 'spam' always has precedence.
    if (strpos($post, 'spam') !== FALSE) {
      $spam = TRUE;
    }
    // Otherwise, check for 'ham'.
    elseif (strpos($post, 'ham') !== FALSE) {
      $ham = TRUE;
    }
    // Lastly, take a forced 'unsure' into account.
    elseif (strpos($post, 'unsure') !== FALSE) {
      // Enabled unsure mode.
      if (!isset($data['unsure']) || $data['unsure']) {
        $spam = TRUE;
        $ham = TRUE;
      }
      // Binary mode.
      else {
        $spam = FALSE;
        $ham = TRUE;
      }
    }
    // Check blacklist.
    if ($matches = mollom_test_server_check_content_blacklist($post, $blacklist, 'spam')) {
      $spam = TRUE;
      $ham = FALSE;
      $response['reason'] = 'blacklist';
      $response['blacklistSpam'] = $matches;
    }

    if ($spam && $ham) {
      $response['spamScore'] = 0.5;
      $response['spamClassification'] = 'unsure';
      $qualityScore = 0.5;
    }
    elseif ($spam) {
      $response['spamScore'] = 1.0;
      $response['spamClassification'] = 'spam';
      $qualityScore = 0.0;
    }
    elseif ($ham) {
      $response['spamScore'] = 0.0;
      $response['spamClassification'] = 'ham';
      $qualityScore = 1.0;
    }
    else {
      $response['spamScore'] = 0.5;
      $response['spamClassification'] = 'unsure';
      $qualityScore = NULL;
    }
    // In case a previous spam check was unsure and a CAPTCHA was solved, the
    // result is supposed to be ham - unless the new content is spam.
    if (!empty($data['id']) && $response['spamClassification'] == 'unsure') {
      $content_captchas = variable_get('mollom_test_server_content_captcha', array());
      if (!empty($content_captchas[$data['id']])) {
        $response['spamScore'] = 0.0;
        $response['spamClassification'] = 'ham';
      }
    }
  }

  // Quality filter.
  if (isset($data['checks']) && in_array('quality', $data['checks'])) {
    if (isset($qualityScore)) {
      $response['qualityScore'] = $qualityScore;
    }
    else {
      $response['qualityScore'] = 0;
    }
  }

  // Profanity filter.
  if (isset($data['checks']) && in_array('profanity', $data['checks'])) {
    $profanityScore = 0.0;
    if (strpos($post, 'profanity') !== FALSE) {
      $profanityScore = 1.0;
    }
    // Check blacklist.
    if ($matches = mollom_test_server_check_content_blacklist($post, $blacklist, 'profanity')) {
      $profanityScore = 1.0;
      $response['blacklistProfanity'] = $matches;
    }
    $response['profanityScore'] = $profanityScore;
  }

  // Language detection.
  if (isset($data['checks']) && in_array('language', $data['checks'])) {
    $languages = array();
    if (stripos($post, 'ist seit der Mitte')) {
      $languages[] = array(
        'languageCode' => 'de',
      );
    }
    if (stripos($post, 'it is the most populous city')) {
      $languages[] = array(
        'languageCode' => 'en',
      );
    }
    if (count($languages) == 0) {
      $languages[] = array(
        'languageCode' => 'zxx',
      );
    }
    $score = 1/count($languages);
    foreach($languages as $id => $langObj) {
      $languages[$id]['languageScore'] = $score;
    }
    $response['languages'] = $languages;
  }

  $storage = variable_get('mollom_test_server_content', array());
  $contentId = (!empty($data['id']) ? $data['id'] : md5(mt_rand()));
  if (isset($storage[$contentId])) {
    $storage[$contentId] = array_merge($storage[$contentId], $data);
  }
  else {
    $storage[$contentId] = $data;
  }
  if ($update) {
    $response = array_merge($storage[$contentId], $response);
  }
  $response['id'] = $contentId;
  variable_set('mollom_test_server_content', $storage);

  return $response;
}

/**
 * Checks a string against blacklisted terms.
 */
function mollom_test_server_check_content_blacklist($string, $blacklist, $reason) {
  $terms = array();
  foreach ($blacklist as $entry) {
    if ($entry['reason'] == $reason) {
      $term = preg_quote($entry['value']);
      if ($entry['match'] == 'exact') {
        $term = '\b' . $term . '\b';
      }
      $terms[] = $term;
    }
  }
  if (!empty($terms)) {
    $terms = '/(' . implode('|', $terms) . ')/';
    preg_match_all($terms, strtolower($string), $matches);
    return $matches[1];
  }
  return array();
}

/**
 * API callback for mollom.getImageCaptcha to fetch a CAPTCHA image.
 */
function mollom_test_server_get_captcha($data) {
  $captchaId = (!empty($data['id']) ? $data['id'] : md5(mt_rand()));
  $response = array(
    'id' => $captchaId,
  );

  // Return a HTTPS URL if 'ssl' parameter was passed.
  $base_url = $GLOBALS['base_url'];
  if (!empty($data['ssl'])) {
    $base_url = str_replace('http', 'https', $base_url);
  }
  $response['url'] = $base_url . '/' . drupal_get_path('module', 'mollom') . '/images/powered-by-mollom-2.gif?captchaId=' . $captchaId;

  $storage = variable_get('mollom_test_server_captcha', array());
  $storage[$captchaId] = $data;
  variable_set('mollom_test_server_captcha', $storage);

  return $response;
}

/**
 * API callback for mollom.checkCaptcha to validate a CAPTCHA response.
 */
function mollom_test_server_check_captcha($data) {
  $response = array();

  if (isset($data['solution']) && $data['solution'] == 'correct') {
    $response['solved'] = TRUE;
  }
  else {
    $response['solved'] = FALSE;
    $response['reason'] = '';
  }

  $storage = variable_get('mollom_test_server_captcha', array());
  $captchaId = $data['id'];
  if (!isset($storage[$captchaId])) {
    return MENU_NOT_FOUND;
  }
  $storage[$captchaId] = array_merge($storage[$captchaId], $data);
  $response['id'] = $captchaId;
  variable_set('mollom_test_server_captcha', $storage);

  if (isset($storage[$captchaId]['contentId'])) {
    $contentId = $storage[$captchaId]['contentId'];
    $content_captchas = variable_get('mollom_test_server_content_captcha', array());
    $content_captchas[$contentId] = $response['solved'];
    variable_set('mollom_test_server_content_captcha', $content_captchas);
  }

  return $response;
}

