diff --git a/phpgwapi/inc/class.ischedule_client.inc.php b/phpgwapi/inc/class.ischedule_client.inc.php new file mode 100644 index 0000000000..aabbd94d1d --- /dev/null +++ b/phpgwapi/inc/class.ischedule_client.inc.php @@ -0,0 +1,292 @@ + + * @copyright (c) 2012 by Ralf Becker + * @version $Id$ + */ + +/** + * iSchedule client: clientside of iSchedule + * + * @link https://tools.ietf.org/html/draft-desruisseaux-ischedule-01 iSchedule draft from 2010 + */ +class ischedule_client +{ + /** + * Own iSchedule version + */ + const VERSION = '1.0'; + + private $url; + + private $recipient; + + private $originator; + + /** + * Private key of originators domain + */ + private $dkim_private_key; + + /** + * Constructor + * + * @param string $recipient=null recipient email-address + * @param string $url=null ischedule url, if it should NOT be discovered + * @throws Exception in case of an error or discovery failure + */ + public function __construct($recipient, $url=null) + { + $this->recipient = $recipient; + $this->originator = $GLOBALS['egw_info']['user']['account_email']; + + if (is_null($url)) + { + list(,$domain) = explode('@', $recipient); + $this->url = self::discover($domain); + } + else + { + $this->url = $url; + } + } + + const EMAIL_PREG = '/^([a-z0-9][a-z0-9._-]*)?[a-z0-9]@([a-z0-9](|[a-z0-9_-]*[a-z0-9])\.)+[a-z]{2,6}$/i'; + + /** + * Set originator and (optional) DKIM private key + * + * @param string $originator + * @param string $dkim_private_key=null + * @throws Exception for invalid / not an email originator + */ + public function setOriginator($originator, $dkim_private_key=null) + { + if (!preg_match(self::EMAIL_PREG, $originator)) + { + throw new Exception("Invalid orginator '$originator'!"); + } + $this->originator = $originator; + + if (!is_null($dkim_private_key)) + { + $this->dkim_private_key = $dkim_private_key; + } + } + + /** + * Discover iSchedule url of a given domain + * + * @param string $domain + * @return string discovered ischedule url + * @throws Exception in case of an error or discovery failure + */ + public static function discover($domain) + { + static $scheme2port = array( + 'https' => 443, + 'http' => 80, + ); + + $d = $domain; + for($n = 0; $n < 3; ++$n) + { + if (!($records = dns_get_record($host='_ischedules._tcp.'.$d, DNS_SRV)) && + !($records = dns_get_record($host='_ischedule._tcp.'.$d, DNS_SRV))) + { + // try without subdomain(s) + $parts = explode('.', $d); + if (count($parts) < 3) break; + array_shift($parts); + $d = implode('.', $parts); + } + } + if (!$records) throw new Exception("Could not discover iSchedule service for domain '$domain'!"); + + // ToDo: do we need to use priority and weight + $record = $records[0]; + + $url = strpos($host, '_ischedules') === 0 ? 'https' : 'http'; + if ($scheme2port[$url] == $record['port']) + { + $url .= '://'.$record['target']; + } + else + { + $url .= '://'.$record['target'].':'.$record['port']; + } + $url .= '/.well-known/ischedule'; + + return $url; + } + + /** + * Post dkim signed message to recipients iSchedule server + * + * @param string $content + * @param string $content_type + * @return string + * @throws Exception with http status code and message, if server responds other then 2xx + */ + public function post_msg($content, $content_type) + { + $url_parts = parse_url($this->url); + $headers = array( + 'Host' => $url_parts['host'].($url_parts['port'] ? ':'.$url_parts['port'] : ''), + 'iSchedule-Version' => self::VERSION, + 'Content-Type' => $content_type, + 'Originator' => $this->originator, + 'Recipient' => $this->recipient, + 'Content-Length' => bytes($content), + ); + $headers['DKIM-Signature'] = $this->dkim_sign($headers, $content); + $header_string = ''; + foreach($headers as $name => $value) + { + $header_string .= $name.': '.$value."\r\n"; + } + $opts = array('http' => + array( + 'method' => 'POST', + 'header' => $header_string, + //'timeout' => $timeout, // max timeout in seconds + 'content' => $content, + ) + ); + + // need to suppress warning, if http-status not 2xx + if (($response = @file_get_contents($this->url, false, stream_context_create($opts))) === false) + { + list(, $code, $message) = explode(' ', $http_response_header[0], 3); + throw new Exception($message, $code); + } + return $response; + } + + /** + * Calculate DKIM signature for headers and body using originators domains private key + * + * @param array $headers + * @param string $body + * @param string $type dkim-type + */ + public function dkim_sign(array $headers, $body, $type='calendar') + { + return 'dummy'; + } + + /** + * Capabilities + * + * @var array + */ + private $capabilities; + + /** + * Query capabilities of iSchedule server + * + * @param string $name=null name of capability to return, default null to return internal array with all capabilities + * @return mixed + * @throws Exception in case of an error or discovery failure + */ + public function capabilities($name=null) + { + if (!isset($this->capabilities)) + { + $reader = new XMLReader(); + if (!$reader->open($this->url.'?query=capabilities')) + { + throw new Exception("Could not read iSchedule server capabilities $this->url!"); + } + + $this->capabilities = self::xml2assoc($reader); + $reader->close(); + + if (!isset($this->capabilities['query-result']) || !isset($this->capabilities['query-result']['capability-set'])) + { + throw new Exception("Server returned invalid capabilities!"); + } + $this->capabilities = $this->capabilities['query-result']['capability-set']; + print_r($this->capabilities); + } + return $name ? $this->capabilities[$name] : $this->capabilities; + } + + /** + * Parse capabilities xml into an associativ array + * + * @param XMLReader $xml + * @param &$target=array() + * @return mixed + */ + private static function xml2assoc(XMLReader $xml, &$target = array()) + { + while ($xml->read()) + { + switch ($xml->nodeType) { + case XMLReader::END_ELEMENT: + return $target; + case XMLReader::ELEMENT: + $name = $xml->name; + $empty = $xml->isEmptyElement; + $attr_name = $xml->getAttribute('name'); + if (($name_attr = $xml->getAttribute('name'))) + { + $name = $attr_name; + } + if (isset($target[$name])) + { + if (!is_array($target[$name])) + { + $target[$name] = array($target[$name]); + } + $t = &$target[$name][count($target[$name])]; + } + else + { + $t = &$target[$name]; + } + if ($xml->isEmptyElement) + { + $t = ''; + } + else + { + self::xml2assoc($xml, $t); + } + if ($xml->hasAttributes) + { + while($xml->moveToNextAttribute()) + { + if ($xml->name != 'name') + { + $t['@'.$xml->name] = $xml->value; + } + } + } + break; + case XMLReader::TEXT: + case XMLReader::CDATA: + $target = $xml->value; + } + } + return $target; + } + + /** + * Make private vars readable + * + * @param string $name + * @return mixed + */ + public function __get($name) + { + return $this->$name; + } +} diff --git a/phpgwapi/inc/class.ischedule_server.inc.php b/phpgwapi/inc/class.ischedule_server.inc.php new file mode 100644 index 0000000000..307c0b7b36 --- /dev/null +++ b/phpgwapi/inc/class.ischedule_server.inc.php @@ -0,0 +1,260 @@ + + * @copyright (c) 2012 by Ralf Becker + * @version $Id$ + */ + +/** + * iSchedule server: serverside of iSchedule + * + * @link https://tools.ietf.org/html/draft-desruisseaux-ischedule-01 iSchedule draft from 2010 + */ +class ischedule_server +{ + /** + * iSchedule xml namespace + */ + const ISCHEDULE = 'urn:ietf:params:xml:ns:ischedule'; + + /** + * Own iSchedule version + */ + const VERSION = '1.0'; + + /** + * Serve an iSchedule request + */ + public function ServeRequest() + { + // install our own exception handler sending exceptions as http status + set_exception_handler(array(__CLASS__, 'exception_handler')); + + switch($_SERVER['REQUEST_METHOD']) + { + case 'GET': + $this->get(); + break; + + case 'POST': + $this->post(); + break; + + default: + error_log(__METHOD__."() invalid iSchedule request using {$_SERVER['REQUEST_METHOD']}!"); + header("HTTP/1.1 400 Bad Request"); + } + } + + /** + * Serve an iSchedule POST request + */ + protected function post() + { + static $required_headers = array('Host','Recipient','Originator','Content-Type','DKIM-Signature'); + $headers = array(); + foreach($required_headers as $header) + { + $server_name = strtoupper(str_replace('-', '_', $header)); + if (strpos($server_name, 'CONTENT_') !== 0) $server_name = 'HTTP_'.$server_name; + if (!empty($_SERVER[$server_name])) $headers[$header] = $_SERVER[$server_name]; + } + if (($missing = array_diff($required_headers, array_keys($headers)))) + { + throw new Exception ('Bad Request: missing '.implode(', ', $missing).' header(s)', 403); + } + if (!$this->dkim_validate($headers)) + { + throw new Exception('Bad Request: DKIM signature invalid', 403); + } + // parse iCal + + // validate originator matches organizer or attendee + throw new exception('Not yet implemented!'); + } + + /** + * Validate DKIM signature + * + * @param array $headers + * @return boolean true if signature could be validated, false otherwise + * @todo + */ + public function dkim_validate(array $headers) + { + return isset($headers['DKIM-Signature']); + } + + /** + * Serve an iSchedule GET request, currently only query=capabilities + * + * GET /.well-known/ischedule?query=capabilities HTTP/1.1 + * Host: cal.example.com + * + * HTTP/1.1 200 OK + * Date: Mon, 15 Dec 2008 09:32:12 GMT + * Content-Type: application/xml; charset=utf-8 + * Content-Length: xxxx + * iSchedule-Version: 1.0 + * ETag: "afasdf-132afds" + * + * + * + * + * + * 1.0 + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * mailto + * + * 102400 + * 19910101T000000Z + * 20381231T000000Z + * 150 + * 250 + * mailto:ischedule-admin@example.com + * + * + */ + protected function get() + { + if (!isset($_GET['query']) || $_GET['query'] !== 'capabilities') + { + error_log(__METHOD__."() invalid iSchedule request using GET without query=capabilities!"); + header("HTTP/1.1 400 Bad Request"); + return; + } + + // generate capabilities + /*$xml = new XMLWriter; + $xml->openMemory(); + $xml->setIndent(true); + $xml->startDocument('1.0', 'UTF-8'); + $xml->startElementNs(null, 'query-result', self::ISCHEDULE); + $xml->startElement('capability-set'); + + foreach(array( + 'supported-version-set' => array('version' => array('1.0')), + 'supported-scheduling-message-set' => array( + 'comp' => array('.name' => array( + 'VEVENT' => array('method' => array('REQUEST', 'ADD', 'REPLY', 'CANCEL')), + 'VTODO' => '', + 'VFREEBUSY' => '', + )), + ) + ) as $name => $data) + { + $xml->writeElement($name, $data); + } + + $xml->endElement(); // capability-set + $xml->endElement(); // query-result + $xml->endDocument(); + $capabilities = $xml->outputMemory();*/ + + $capabilities = ' + + + + 1.0 + + + + + + + + + + + + + + + + + + + + mailto + + 102400 + 19910101T000000Z + 20381231T000000Z + 150 + 250 + mailto:ischedule-admin@example.com + + '; + + // returning capabilities + header('Content-Type: application/xml; charset=utf-8'); + header('iSchedule-Version: '.self::VERSION); + header('Content-Length: '.bytes($capabilites)); + header('ETag: "'.md5($capabilites).'"'); + + echo $capabilities; + common::egw_exit(); + } + + /** + * Exception handler, which additionally logs the request (incl. a trace) + * + * Does NOT return and get installed in constructor. + * + * @param Exception $e + */ + public static function exception_handler(Exception $e) + { + // logging exception as regular egw_execption_hander does + _egw_log_exception($e,$headline); + + // exception handler sending message back to the client as http status + $code = $e->getCode(); + $msg = $e->getMessage(); + if (!in_array($code, array(400, 403, 407, 503))) $code = 500; + header('HTTP/1.1 '.$code.' '.$msg); + + // if our groupdav logging is active, log the request plus a trace, if enabled in server-config + if (self::$request_starttime && isset($GLOBALS['groupdav']) && is_a($GLOBALS['groupdav'],'groupdav')) + { + $GLOBALS['groupdav']->_http_status = '401 Unauthorized'; // to correctly log it + if ($GLOBALS['egw_info']['server']['exception_show_trace']) + { + $GLOBALS['groupdav']->log_request("\n".$e->getTraceAsString()."\n"); + } + else + { + $GLOBALS['groupdav']->log_request(); + } + } + if (is_object($GLOBALS['egw'])) + { + common::egw_exit(); + } + exit; + } +} diff --git a/phpgwapi/ischedule-cli.php b/phpgwapi/ischedule-cli.php new file mode 100755 index 0000000000..943320325c --- /dev/null +++ b/phpgwapi/ischedule-cli.php @@ -0,0 +1,99 @@ +#!/usr/bin/php + + * @copyright (c) 2012 by Ralf Becker + * @version $Id$ + */ + +if (isset($_SERVER['HTTP_HOST'])) die("This is a commandline ONLY tool!\n"); + +/** + * iSchedule command line client, primary for testing and development purpose + * + * @link https://tools.ietf.org/html/draft-desruisseaux-ischedule-01 iSchedule draft from 2010 + */ +function usage($err=null) +{ + echo basename(__FILE__).": [--url ischedule-url] [--component (VEVENT|VFREEBUSY|VTODO) (-|ical-filename)] [--method (REQUEST(default)|RESPONSE)] recipient-email [originator-email]\n\n"; + if ($err) echo "$err\n\n"; + exit; +} + +$GLOBALS['egw_info'] = array( + 'flags' => array( + 'noheader' => True, + 'currentapp' => 'login', + ) +); +// if you move this file somewhere else, you need to adapt the path to the header! +$egw_dir = dirname(dirname(__FILE__)); +include($egw_dir.'/header.inc.php'); + +$args = $_SERVER['argv']; +array_shift($args); +$method = 'REQUEST'; +while($args[0][0] == '-') +{ + $option = array_shift($args); + if (count($args) < 2) usage("Missing arguments for '$option'!".array2string($args)); + switch($option) + { + case '--url': + $url = array_shift($args); + break; + + case '--component': + if (count($args) < 3) usage('Missing arguments for --component'); + $component = strtoupper(array_shift($args)); + if (!in_array($component, array('VEVENT','VFREEBUSY','VTODO'))) + { + usage ("Invalid component name '$component'!"); + } + if (($filename = array_shift($args)) == '-') $filename = 'php://stdin'; + if (($content = file_get_contents($filename)) === false) + { + usage("Could not open '$filename'!"); + } + break; + + case '--method': + $method = strtoupper(array_shift($args)); + if (!in_array($method, array('REQUEST','REPLY','CANCEL','ADD'))) + { + usage ("Invalid method name '$method'!"); + } + break; + + default: + usage("Unknown option '$option'!"); + } +} +if (!count($args)) usage(); + +$recipient = array_shift($args); +if ($args) $originator = array_shift($args); + +try { + $client = new ischedule_client($recipient, $url); + echo "\nUsing iSchedule URL: $client->url\n\n"; + if ($originator) $client->setOriginator($originator); + if ($component) + { + $content_type = 'text/calendar; component='.$component.'; method='.$method; + echo $client->post_msg($content, $content_type); + } + else + { + $client->capabilities(); + } +} +catch(Exception $e) { + echo "\n".($e->getCode() ? $e->getCode().' ' : '').$e->getMessage()."\n\n"; +} \ No newline at end of file diff --git a/phpgwapi/ischedule.php b/phpgwapi/ischedule.php new file mode 100644 index 0000000000..27f7352e39 --- /dev/null +++ b/phpgwapi/ischedule.php @@ -0,0 +1,32 @@ + + * @copyright (c) 2012 by Ralf Becker + * @version $Id$ + */ + +/** + * iSchedule server: serverside of iSchedule + * + * @link https://tools.ietf.org/html/draft-desruisseaux-ischedule-01 iSchedule draft from 2010 + */ + +$GLOBALS['egw_info'] = array( + 'flags' => array( + 'noheader' => True, + 'currentapp' => 'login', + 'no_exception_handler' => true, + ) +); +// if you move this file somewhere else, you need to adapt the path to the header! +$egw_dir = dirname(dirname(__FILE__)); +include($egw_dir.'/header.inc.php'); + +$ischedule = new ischedule_server(); +$ischedule->ServeRequest();