<?php
// $Id: services.module,v 1.3.2.9 2008/05/08 00:22:46 marcingy Exp $

/**
 * @file
 * services.module
 */

/**
 * Implementation of hook_help().
 */
function services_help($section) {
  switch ($section) {
    case 'admin/help#services':
      return '<p>'. t('Visit the <a href="@handbook_url">Services Handbook</a> for help and information.', array('@handbook_url' => 'http://drupal.org/node/109782')) .'</p>';
    
    case 'admin/build/services':
    case 'admin/build/services/browse':
      $output = '<p>'. t('Services are collections of methods available to remote applications. They are defined in modules, and may be accessed in a number of ways through server modules. Visit the <a href="@handbook_url">Services Handbook</a> for help and information.', array('@handbook_url' => 'http://drupal.org/node/109782')) .'</p>';
      $output .= '<p>'. t('All enabled services and methods are shown. Click on any method to view information or test.') .'</p>';
      return $output;
      
    case 'admin/build/services/keys':
      return t('An API key is required to allow an application to access Drupal remotely.');
      
  }
}

/**
 * Implementation of hook_perm().
 */
function services_perm() { 
  return array('access services', 'administer services'); 
}

/**
 * Implementation of hook_menu.
 */
function services_menu($may_cache) {
  $items = array();
  $access = user_access('access services');
  $admin_access = user_access('administer services');
  $path = drupal_get_path('module', 'services');
  
  if ($may_cache) {
    // admin
    $items[] = array(
      'path' => 'admin/build/services', 
      'title' => t('Services'),
      'access' => $admin_access,
      'callback' => 'services_admin_browse_index',
      'description' => t('Allows external applications to communicate with Drupal.'),
    );
    
    // browse
    $items[] = array(
      'path' => 'admin/build/services/browse', 
      'title' => t('Browse'),
      'access' => $admin_access,
      'callback' => 'services_admin_browse_index',
      'description' => t('Browse and test available remote services.'),
      'type' => MENU_DEFAULT_LOCAL_TASK
    );
    
    // API Keys
    if (variable_get('services_use_key', TRUE)) {
      $items[] = array(
        'path' => 'admin/build/services/keys', 
        'title' => t('Keys'),
        'access' => $admin_access,
        'callback' => 'services_admin_keys_list',
        'description' => t('Manage application access to site services.'),
        'type' => MENU_LOCAL_TASK,
      );
      $items[] = array(
        'path' => 'admin/build/services/keys/list', 
        'title' => t('List'),
        'access' => $admin_access,
        'type' => MENU_DEFAULT_LOCAL_TASK,
        'weight' => -10,
      );
      $items[] = array(
        'path' => 'admin/build/services/keys/add', 
        'title' => t('Create key'),
        'access' => $admin_access,
        'callback' => 'drupal_get_form',
        'callback arguments' => array('services_admin_keys_form'),
        'type' => MENU_LOCAL_TASK,
      );
    }
    
    // Settings
    $items[] = array(
      'path' => 'admin/build/services/settings', 
      'title' => t('Settings'),
      'access' => $admin_access,
      'callback' => 'drupal_get_form',
      'callback arguments' => 'services_admin_settings',
      'description' => t('Configure service settings.'),
      'type' => MENU_LOCAL_TASK,
    );
    $items[] = array(
      'path' => 'admin/build/services/settings/general', 
      'title' => t('General'),
      'access' => $admin_access,
      'callback' => 'drupal_get_form',
      'callback arguments' => 'services_admin_settings',
      'description' => t('Configure service settings.'),
      'type' => MENU_DEFAULT_LOCAL_TASK,
      'weight' => -10,
    );
    
    // crossdomain.xml
    $items[] = array(
      'path' => 'crossdomain.xml',
      'access' => $access,
      'callback' => 'services_crossdomain_xml',
      'type' => MENU_CALLBACK,
    );
  }
  else {
    
    if (arg(0) == 'services') {
      // server
      foreach (module_implements('server_info') as $module) {
        $info = module_invoke($module, 'server_info');
        if ($info['#path'] == arg(1)) {
          $items[] = array(
            'path' => 'services/'. $info['#path'], 
            'title' => t('Services'),
            'access' => $access,
            'callback' => 'services_server',
            'callback arguments' => array($module),
            'type' => MENU_CALLBACK,
          );
        }
      }
    }
    
    
    // admin
    if (arg(0) == 'admin' && arg(1) == 'build' && arg(2) == 'services') {
      
      // browse
      if (arg(3) == 'browse' || !arg(3)) {
        
        require_once "$path/services_admin_browse.inc";
        
        if (arg(4)) {
          $items[] = array(
            'path' => 'admin/build/services/browse/'. arg(4), 
            'title' => arg(4),
            'access' => $admin_access,
            'callback' => 'services_admin_browse_method',
            'type' => MENU_LOCAL_TASK
          );
        }

        drupal_add_css("$path/services.css", 'module');
      }
      
      // keys
      if (arg(3) == 'keys' && variable_get('services_use_key', TRUE)) {
        
        require_once "$path/services_admin_keys.inc";

        if ($key = services_get_key(arg(4))) {

          if (!empty($key)) {
            $items[] = array(
              'path' => 'admin/build/services/keys/'. $key->kid,
              'title' => t('Edit key'),
              'access' => $admin_access,
              'callback' => 'drupal_get_form',
              'callback arguments' => array('services_admin_keys_form', $key),
              'type' => MENU_CALLBACK,
            );
            $items[] = array(
              'path' => 'admin/build/services/keys/'. $key->kid .'/delete',
              'title' => t($type->name),
              'access' => $admin_access,
              'callback' => 'drupal_get_form',
              'callback arguments' => array('services_admin_keys_delete_confirm', $key),
              'type' => MENU_CALLBACK,
            );
          }
        }
      }
    }
  }
  
  return $items;
}

/*
 * Callback for admin page.
 */
function services_admin_settings() {
  $node_types = node_get_types('names');
  $defaults = isset($node_types['blog']) ? array('blog' => 1) : array();
  $form['security'] = array(
    '#title' => t('Security'),
    '#type' => 'fieldset',
    '#description' => t('Changing security settings will require you to adjust all method calls. This will affect all applications using site services.'),
  );
  $form['security']['services_use_key'] = array(
    '#type' => 'checkbox',
    '#title' => t('Use keys'),
    '#default_value' => variable_get('services_use_key', TRUE),
    '#description' => t('When enabled, all method calls must include a valid key.')
  );
  $form['security']['services_domain_strict'] = array(
    '#type' => 'checkbox',
    '#title' => t('Strict domain checking'),
    '#default_value' => variable_get('services_domain_strict', FALSE),
    '#description' => t('When enabled, all method calls must include their domain to validate against the key.')
  );
  $form['security']['services_use_sessid'] = array(
    '#type' => 'checkbox',
    '#title' => t('Use sessid'),
    '#default_value' => variable_get('services_use_sessid', TRUE),
    '#description' => t('When enabled, all method calls must include a valid sessid. Only disable this setting if the application will user browser-based cookies.')
  );
  
  $form['debug'] = array(
    '#title' => t('Debugging'),
    '#type' => 'fieldset',
  );
  $form['debug']['services_debug'] = array(
    '#type' => 'checkbox',
    '#title' => t('Enable debug mode'),
    '#default_value' => variable_get('services_debug', TRUE),
    '#description' => t('When enabled, debugging features will be enabled.')
  );
  

  return system_settings_form($form);
}

/**
 * Callback for server endpoint.
 */
function services_server($module = null) {
  services_set_server_info($module);
  print module_invoke($module, 'server');
  
  // Do not let this output.
  exit;
}

/*
 * Callback for crossdomain.xml.
 */
function services_crossdomain_xml() {
  global $base_url;
  $output = '<!DOCTYPE cross-domain-policy SYSTEM "http://www.macromedia.com/xml/dtds/cross-domain-policy.dtd">'."\n";
  $output .= '<cross-domain-policy>'."\n";
  $output .= '  <allow-access-from domain="'. $_SERVER['HTTP_HOST'] .'" />'."\n";
  $output .= '  <allow-access-from domain="*.'. $_SERVER['HTTP_HOST'] .'" />'."\n";
  
  $keys = services_get_keys();
  
  foreach ($keys as $key) {
    if (!empty($key->domain)) {
      $output .= '  <allow-access-from domain="'. $key->domain .'" />'."\n";
      $output .= '  <allow-access-from domain="*.'. $key->domain .'" />'."\n";
    }
  }
  
  $output .= '</cross-domain-policy>';
  
  services_xml_output($output);
}

function services_xml_output($xml) {
  $xml = '<?xml version="1.0"?>'."\n". $xml;
  header('Connection: close');
  header('Content-Length: '. strlen($xml));
  header('Content-Type: text/xml');
  header('Date: '. date('r'));
  echo $xml;
  exit;
}

function services_set_server_info($module) {
  $server_info = new stdClass();
  $server_info->module = $module;
  $server_info->drupal_path = getcwd();
  return services_get_server_info($server_info);
}

function services_get_server_info($server_info = null) {
  static $info;
  if (!$info && $server_info) {
    $info = $server_info;
  }
  return $info;
}

/**
 * Prepare an error message for returning to the XMLRPC caller.
 */
function services_error($message) {
  $server_info = services_get_server_info();
    
  // Look for custom error handling function.
  // Should be defined in each server module.
  if (module_hook($server_info->module, 'server_error')) {
    return module_invoke($server_info->module, 'server_error', $message);
  }
  
  // No custom error handling function found.
  return $message;
}

/**
 * This is the magic function through which all remote method calls must pass.
 */
function services_method_call($method_name, $args = array()) {
  $method = services_method_get($method_name);
  
  // Check that method exists.
  if (empty($method)) {
    return services_error(t('Method %name does not exist.', array('%name' => $method_name)));
  }
  
  // Check for missing args.
  foreach ($method['#args'] as $key => $arg) {
    if (!$arg['#optional']) {
      if (empty($args[$key])) {
        return services_error(t('Missing required arguments.'));
      }
    }
  }
  
  // Add additonal processing for methods requiring api key.
  if (variable_get('services_use_key', TRUE)) {
    $api_key = array_shift($args);
    $domain = null;
    $timestamp = null;
    $hash =  null;
    // Check strict domain matching.
    if (variable_get('services_domain_strict', false)) {
      $domain = array_shift($args);
      $timestamp = array_shift($args);
      $hash = $api_key;
      $api_key = db_result(db_query("SELECT kid FROM {services_keys} WHERE domain = '%s'", $domain));
    }
    if (!services_validate_key($api_key, $timestamp, $domain, $hash)) {
      return services_error(t('Invalid API key.'));
    }
  }
  
  // Add additonal processing for methods requiring authentication.
  $session_backup = NULL;
  if ($method['#auth'] && variable_get('services_use_sessid', TRUE)) {
    $sessid = array_shift($args);
    if (empty($sessid)) {
      return services_error(t('Invalid sessid.'));
    }
    $session_backup = services_session_load($sessid);
  }
  
  // Check access
  $access_arguments = isset($method['#access arguments']) ? $method['#access arguments'] : $args;
  // Call default or custom access callback
  if (call_user_func_array($method['#access callback'], $access_arguments) != true) {
    return services_error(t('Access denied.'));
  }
  
  // Change working directory to drupal root to call drupal function,
  // then change it back to server module root to handle return.
  $server_root = getcwd();
  $server_info = services_get_server_info();
  if ($server_info) {
    chdir($server_info->drupal_path);
  }
  $result = call_user_func_array($method['#callback'], $args);
  if ($server_info) {
    chdir($server_root);
  }

  // Add additonal processing for methods requiring authentication.
  if ($session_backup !== NULL) {
    services_session_unload($session_backup);
  }

  return $result;
}

/**
   * This should probably be cached in drupal cache.
 */
function services_get_all() {
  static $methods_cache;
  if (!isset($methods_cache)) {
    $methods = module_invoke_all('service');
    
    // api_key arg
    $arg_api_key = array(
      '#name' => 'api_key',
      '#type' => 'string',
      '#description' => t('A valid API key.'),
    );
    
    // sessid arg
    $arg_sessid = array(
      '#name' => 'sessid',
      '#type' => 'string',
      '#description' => t('A valid sessid.'),
    );
    
    // domain arg
    $arg_domain_check = array(
      '#name' => 'domain_check',
      '#type' => 'string',
      '#description' => t('A valid domain for the API key.'),
    );
    
    $arg_domain_time_stamp = array(
      '#name' => 'domain_time_stamp',
      '#type' => 'string',
      '#description' => t('Time stamp used to hash key.'),
    );
    
    foreach ($methods as $key => $method) {
      
      // set method defaults
      if (!isset($methods[$key]['#auth'])) {
        $methods[$key]['#auth'] = true;
      }
      
      if (!isset($methods[$key]['#access callback'])) {
        $methods[$key]['#access callback'] = 'user_access';
        if (!isset($methods[$key]['#access arguments'])) {
          $methods[$key]['#access arguments'] = array('access services');
        }
      }

      if (!isset($methods[$key]['#args'])) {
        $methods[$key]['#args'] = array();
      }
      
      if ($methods[$key]['#auth'] && variable_get('services_use_sessid', TRUE)) {
        $methods[$key]['#args'] = array_merge(array($arg_sessid), $methods[$key]['#args']);
      }
      
      if (variable_get('services_use_key', TRUE)) {
        if (variable_get('services_domain_strict', FALSE)) {
          $methods[$key]['#args'] = array_merge(array($arg_domain_time_stamp), $methods[$key]['#args']);
          $methods[$key]['#args'] = array_merge(array($arg_domain_check), $methods[$key]['#args']);
        }
        $methods[$key]['#args'] = array_merge(array($arg_api_key), $methods[$key]['#args']);
      }
      
      // set defaults for args
      foreach ($methods[$key]['#args'] as $arg_key => $arg) {
        if (is_array($arg)) {
          if (!isset($arg['#optional'])) {
            $methods[$key]['#args'][$arg_key]['#optional'] = false;
          }
        }
        else {
          $arr_arg = array();
          $arr_arg['#name'] = t('unnamed');
          $arr_arg['#type'] = $arg;
          $arr_arg['#description'] = t('No description given.');
          $arr_arg['#optional'] = false;
          $methods[$key]['#args'][$arg_key] = $arr_arg;
        }
      }
      reset($methods[$key]['#args']);
    }
    $methods_cache = $methods;
  }
  return $methods_cache;
}

function services_method_get($method_name) {
  static $method_cache;
  if (!isset($method_cache[$method_name])) {
    foreach (services_get_all() as $method) {
      if ($method_name == $method['#method']) {
        $method_cache[$method_name] = $method;
        break;
      }
    }
  }
  return $method_cache[$method_name];
}

function services_validate_key($kid, $timestamp, $domain, $hash) {
  if (!variable_get('services_domain_strict', false)) {
    $key = services_get_key($kid);
    if ($key) {
      return TRUE;
    }
    // Compare the domain against the key.
    else{
      return FALSE;
    }
    
  }
  else{
    $rehash = md5($kid . $timestamp . $domain);
    if ($rehash == $hash) {
      return true;
    }
    else{
      return false;
    }
  }
}

function services_get_key($kid) {
  $keys = services_get_keys();
  foreach ($keys as $key) {
    if ($key->kid == $kid) {
      return $key;
    }
  }
}

function services_get_keys() {
  static $keys;
  if (!$keys) {
    $keys = array();
    $result = db_query("SELECT * FROM {services_keys}");
    while ($key = db_fetch_object($result)) {
      $keys[$key->kid] = $key;
    }
  }
  return $keys;
}

/**
 * Make any changes we might want to make to node.
 */
function services_node_load($node, $fields = array()) {
  if (!$node->nid) {
    return null;
  }

  // Apply filters to fields.
  $body = $node->body;
  $node->body = new stdClass();
  $node->body_value = $body;
  $node->body = check_markup($body, $node->format, FALSE);

  // Loop through and get only requested fields.
  if (count($fields) > 0) {
    foreach ($fields as $field) {
      $val->{$field} = $node->{$field};
    }
  } 
  else {
    $val = $node;
  }

  return $val;
}

/**
 * Backup current session data and import user session.
 */
function services_session_load($sessid) {
  global $user;

  // If user's session is already loaded, just return current user's data
  if ($user->sid == $sessid) {
    return $user;
  }

  // Make backup of current user and session data
  $backup = $user;
  $backup->session = session_encode();

  // Empty current session data
  foreach ($_SESSION as $key => $value) {
    unset($_SESSION[$key]);
  }

  // Some client/servers, like XMLRPC, do not handle cookies, so imitate it to make sess_read() function try to look for user,
  // instead of just loading anonymous user :).
  if (!isset($_COOKIE[session_name()])) $_COOKIE[session_name()] = $sessid;

  // Load session data
  sess_read($sessid);

  // Check if it really loaded user and, for additional security, if user was logged from the same IP. If not, then revert automatically.
  if ($user->sid != $sessid || $user->hostname != $backup->hostname) {
    services_session_unload($backup);
    return NULL;
  }

  return $backup;
}

/**
 * Revert to previously backuped session.
 */
function services_session_unload($backup) {
  global $user;

  // No point in reverting if it's the same user's data
  if ($user->sid == $backup->sid) {
    return;
  }

  // Some client/servers, like XMLRPC, do not handle cookies, so imitate it to make sess_read() function try to look for user,
  // instead of just loading anonymous user :).
  if (!isset($_COOKIE[session_name()])) $_COOKIE[session_name()] = $sessid;

  // Save current session data
  sess_write($user->sid, session_encode());

  // Empty current session data
  foreach ($_SESSION as $key => $value) {
    unset($_SESSION[$key]);
  }

  // Revert to previous user and session data
  $user = $backup;
  session_decode($user->session);
}

