diff --git a/calendar/inc/class.calendar_groupdav.inc.php b/calendar/inc/class.calendar_groupdav.inc.php index 1835295afe..8106c36e04 100644 --- a/calendar/inc/class.calendar_groupdav.inc.php +++ b/calendar/inc/class.calendar_groupdav.inc.php @@ -7,7 +7,7 @@ * @package calendar * @subpackage groupdav * @author Ralf Becker - * @copyright (c) 2007-12 by Ralf Becker + * @copyright (c) 2007-13 by Ralf Becker * @version $Id$ */ @@ -1019,8 +1019,7 @@ class calendar_groupdav extends groupdav_handler { if ($this->debug) error_log(__METHOD__."() importVCal($eventId) returned false"); } - // we should not return an etag here, as we never store the ical byte-by-byte - //header('ETag: "'.$this->get_etag($eventId).'"'); + header('ETag: "'.$this->get_etag($eventId).'"'); } } return true; @@ -1318,6 +1317,18 @@ class calendar_groupdav extends groupdav_handler return $event; } + /** + * Update etag, ctag and sync-token to reflect changed attachments + * + * @param array|string|int $entry array with entry data from read, or id + */ + public function update_tags($entry) + { + if (!is_array($entry)) $entry = $this->read($entry); + + $this->bo->update($entry, true); + } + /** * Query ctag for calendar * @@ -1453,6 +1464,7 @@ class calendar_groupdav extends groupdav_handler { $handler = new calendar_ical(); $handler->setSupportedFields('GroupDAV',$this->agent); + $handler->supportedFields['attachments'] = true; // enabling attachments if ($this->debug > 1) error_log("ical Handler called: " . $this->agent); return $handler; } diff --git a/calendar/inc/class.calendar_ical.inc.php b/calendar/inc/class.calendar_ical.inc.php index 277e03e9a6..8024757d06 100644 --- a/calendar/inc/class.calendar_ical.inc.php +++ b/calendar/inc/class.calendar_ical.inc.php @@ -221,6 +221,7 @@ class calendar_ical extends calendar_boupdate 'RECURRENCE-ID' => 'recurrence', 'SEQUENCE' => 'etag', 'STATUS' => 'status', + 'ATTACH' => 'attachments', ); if (!is_array($this->supportedFields)) $this->setSupportedFields(); @@ -789,6 +790,31 @@ class calendar_ical extends calendar_boupdate } break; + case 'ATTACH': + static $url_prefix; + if (!isset($url_prefix)) + { + $url_prefix = ''; + if ($GLOBALS['egw_info']['server']['webserver_url'][0] == '/') + { + $url_prefix = ($_SERVER['HTTPS'] ? 'https' : 'http').'://'.$_SERVER['HTTP_HOST']; + } + } + foreach(egw_vfs::find(egw_link::vfs_path('calendar', $event['id'], '', true), array( + 'type' => 'F', + 'need_mime' => true, + ), true) as $path => $stat) + { + $attributes['ATTACH'][] = $url_prefix.egw::link(egw_vfs::download_url($path)); + $parameters['ATTACH'][] = array( + 'MANAGED-ID' => groupdav::path2managed_id($path), + 'FMTTYP' => $stat['mime'], + 'SIZE' => $stat['size'], + 'FILENAME' => egw_vfs::basename($path), + ); + } + break; + default: if (isset($this->clientProperties[$icalFieldName]['Size'])) { diff --git a/infolog/inc/class.infolog_groupdav.inc.php b/infolog/inc/class.infolog_groupdav.inc.php index 203d13a39b..126cb0c105 100644 --- a/infolog/inc/class.infolog_groupdav.inc.php +++ b/infolog/inc/class.infolog_groupdav.inc.php @@ -7,7 +7,7 @@ * @package infolog * @subpackage groupdav * @author Ralf Becker - * @copyright (c) 2007-12 by Ralf Becker + * @copyright (c) 2007-13 by Ralf Becker * @version $Id$ */ @@ -569,6 +569,18 @@ class infolog_groupdav extends groupdav_handler return $retval; } + /** + * Update etag, ctag and sync-token to reflect changed attachments + * + * @param array|string|int $entry array with entry data from read, or id + */ + public function update_tags($entry) + { + if (!is_array($entry)) $entry = $this->read($entry); + + $this->bo->write($entry, true); + } + /** * Callback for infolog_ical::importVTODO to implement infolog-cat-action * @@ -664,6 +676,19 @@ class infolog_groupdav extends groupdav_handler return $this->bo->read(array(self::$path_attr => $id, "info_status!='deleted'"),false,'server'); } + /** + * Get id from entry-array returned by read() + * + * Reimplemented because id uses key 'info_id' + * + * @param int|string|array $entry + * @return int|string + */ + function get_id($entry) + { + return is_array($entry) ? $entry['info_id'] : $entry; + } + /** * Check if user has the neccessary rights on a task / infolog entry * diff --git a/phpgwapi/inc/class.groupdav.inc.php b/phpgwapi/inc/class.groupdav.inc.php index b429c741a1..4fb8700add 100644 --- a/phpgwapi/inc/class.groupdav.inc.php +++ b/phpgwapi/inc/class.groupdav.inc.php @@ -7,7 +7,7 @@ * @package api * @subpackage groupdav * @author Ralf Becker - * @copyright (c) 2007-12 by Ralf Becker + * @copyright (c) 2007-13 by Ralf Becker * @version $Id$ */ @@ -325,6 +325,8 @@ class groupdav extends HTTP_WebDAV_Server $dav[] = 'calendarserver-principal-property-search'; // required by iOS & OS X iCal to show private checkbox (X-CALENDARSERVER-ACCESS: CONFIDENTIAL on VCALENDAR) $dav[] = 'calendarserver-private-events'; + // managed attachments + $dav[] = 'calendar-managed-attachments'; // other capabilities calendarserver announces //$dav[] = 'calendar-schedule'; //$dav[] = 'calendar-availability'; @@ -1185,26 +1187,191 @@ class groupdav extends HTTP_WebDAV_Server $_GET['add-member'] = ''; // otherwise we give no Location header return $this->PUT($options); } - // read the content in a string, if a stream is given - if (isset($options['stream'])) - { - $options['content'] = ''; - while(!feof($options['stream'])) - { - $options['content'] .= fread($options['stream'],8192); - } - } if ($this->debug) error_log(__METHOD__.'('.array2string($options).')'); $this->_parse_path($options['path'],$id,$app,$user); - if (($handler = self::app_handler($app)) && method_exists($handler, 'post')) + if (($handler = self::app_handler($app))) { - return $handler->post($options,$id,$user); + // managed attachments + if (isset($_GET['action']) && substr($_GET['action'], 0, 11) === 'attachment-') + { + return $this->managed_attachements($options, $id, $handler, $_GET['action']); + } + + if (method_exists($handler, 'post')) + { + // read the content in a string, if a stream is given + if (isset($options['stream'])) + { + $options['content'] = ''; + while(!feof($options['stream'])) + { + $options['content'] .= fread($options['stream'],8192); + } + } + return $handler->post($options,$id,$user); + } } return '501 Not Implemented'; } + /** + * HTTP header containing managed id + */ + const MANAGED_ID_HEADER = 'Cal-Managed-ID'; + + /** + * Add, update or remove attachments + * + * @param array &$options + * @param string|int $id + * @param groupdav_handler $handler + * @param string $action 'attachment-add', 'attachment-update', 'attachment-remove' + * @return string http status + * + * @todo support for rid parameter + * @todo managed-id does NOT change on update + * @todo updates of attachments through vfs need to call $handler->update_tags($id) too + * @todo stripping attachments added via PUT direct and make them managed ones (urls are NOT yet supported too) + * @todo update of attachments via PUT on calendar resource (not sure if I want delete), allows to re-user managed-ids ... + */ + protected function managed_attachements(&$options, $id, groupdav_handler $handler, $action) + { + error_log(__METHOD__."(path=$options[path], id=$id, ..., action=$action) _GET=".array2string($_GET)); + $entry = $handler->_common_get_put_delete('GET', $options, $id); + + if (!is_array($entry)) + { + return $entry ? $entry : "404 Not found"; + } + + if (!egw_link::file_access($handler->app, $entry['id'], EGW_ACL_EDIT)) + { + return '403 Forbidden'; + } + + switch($action) + { + case 'attachment-add': + if (isset($this->_SERVER['HTTP_CONTENT_DISPOSITION']) && + substr($this->_SERVER['HTTP_CONTENT_DISPOSITION'], 0, 10) === 'attachment' && + preg_match('/filename="?([^";]+)/', $this->_SERVER['HTTP_CONTENT_DISPOSITION'], $matches)) + { + $filename = $matches[1]; + $parts = explode('.', $filename); + $ext = '.'.array_pop($parts); + $filename = implode('.', $parts); + } + else + { + $filename = 'attachment'; + if (isset($options['content_type']) && ($ext = mime_magic::mime2ext($options['content_type']))) + { + $ext = '.'.$ext; + } + else + { + $ext = ''; + } + } + for($i = 1; $i < 100; ++$i) + { + $path = egw_link::vfs_path($handler->app, $handler->get_id($entry), $filename.($i > 1 ? '-'.$i : '').$ext, true); + if (!egw_vfs::stat($path)) break; + } + if (!($to = egw_vfs::fopen($path, 'w')) || + isset($options['stream']) && ($copied=stream_copy_to_stream($options['stream'], $to)) === false || + isset($options['content']) && ($copied=fwrite($to, $options['content'])) === false) + { + return '403 Forbidden'; + } + fclose($to); + error_log(__METHOD__."() content-type=$options[content_type], filename=$filename, ext=$ext: $path created $copied bytes copied"); + $ret = '201 Created'; + header(self::MANAGED_ID_HEADER.': '.self::path2managed_id($path)); + break; + + case 'attachment-remove': + case 'attachment-update': + if (empty($_GET['managed-id']) || !($path = self::managed_id2path($_GET['managed-id'], $app, $id))) + { + return '404 Not found'; + } + if ($action == 'attachment-remove') + { + if (!egw_vfs::unlink($path)) + { + return '403 Forbidden'; + } + $ret = '204 No content'; + } + else + { + if (!($to = egw_vfs::fopen($path, 'w')) || + isset($options['stream']) && ($copied=stream_copy_to_stream($options['stream'], $to)) === false || + isset($options['content']) && ($copied=fwrite($to, $options['content'])) === false) + { + return '403 Forbidden'; + } + fclose($to); + error_log(__METHOD__."() content-type=$options[content_type], filename=$filename: $path updated $copied bytes copied"); + $ret = '200 Ok'; + header(self::MANAGED_ID_HEADER.': '.self::path2managed_id($path)); + } + break; + + default: + return '501 Unknown action parameter '.$action; + } + // update etag/ctag/sync-token by updating modification time + $handler->update_tags($entry); + + // check/handle Prefer: return-representation + $handler->check_return_representation($options, $id, $user); + + return $ret; + } + + /** + * Return managed-id of a vfs-path + * + * @param string $path "/apps/$app/$id/something" + * @return string + */ + static public function path2managed_id($path) + { + return base64_encode($path); + } + + /** + * Return vfs-path of a managed-id + * + * @param string $managed_id + * @param string $app=null app-name to check against path + * @param string|int $id=null id to check agains path + * @return string|boolean "/apps/$app/$id/something" or false if not found or not belonging to given $app/$id + */ + static public function managed_id2path($managed_id, $app=null, $id=null) + { + $path = base64_decode($managed_id); + + if (!$path || substr($path, 0, 6) != '/apps/' || !egw_vfs::stat($path)) + { + $path = false; + } + elseif (!empty($app) && !empty($id)) + { + list(,,$a,$i) = explode('/', $path); + if ($a !== $app || $i !== (string)$id) + { + $path = false; + } + } + error_log(__METHOD__."('$managed_id', $app, $id) base64_decode('$managed_id')=".array2string(base64_decode($managed_id)).' returning '.array2string($path)); + return $path; + } + /** * Namespaces which need to be eplicitly named in self::$proppatch_props, * because we consider them protected, if not explicitly named @@ -1313,8 +1480,15 @@ class groupdav extends HTTP_WebDAV_Server if (($handler = self::app_handler($app))) { $status = $handler->put($options,$id,$user,$prefix); + // set default stati: true --> 204 No Content, false --> should be already handled if (is_bool($status)) $status = $status ? '204 No Content' : '400 Something went wrong'; + + // check/handle Prefer: return-representation + if ($status[0] === '2') + { + $handler->check_return_representation($options, $id, $user); + } return $status; } return '501 Not Implemented'; @@ -1596,7 +1770,10 @@ class groupdav extends HTTP_WebDAV_Server self::$log_level === 'f' || $this->debug) { self::$request_starttime = microtime(true); - $this->store_request = true; + // do NOT log non-text attachments + $this->store_request = $_SERVER['REQUEST_METHOD'] != 'POST' || !isset($_GET['action']) || + !in_array($_GET['action'], array('attachment-add', 'attachment-update')) || + substr($_SERVER['CONTENT_TYPE'], 0, 5) == 'text/'; ob_start(); } parent::ServeRequest(); diff --git a/phpgwapi/inc/class.groupdav_handler.inc.php b/phpgwapi/inc/class.groupdav_handler.inc.php index d6198ed059..7d261ce615 100644 --- a/phpgwapi/inc/class.groupdav_handler.inc.php +++ b/phpgwapi/inc/class.groupdav_handler.inc.php @@ -7,7 +7,7 @@ * @package api * @subpackage groupdav * @author Ralf Becker - * @copyright (c) 2007-12 by Ralf Becker + * @copyright (c) 2007-13 by Ralf Becker * @version $Id$ */ @@ -103,6 +103,13 @@ abstract class groupdav_handler */ static $path_attr = 'id'; + /** + * New id of put/post stored here by put_response_headers for check_return_representation + * + * @var string + */ + var $new_id; + /** * Constructor * @@ -184,6 +191,17 @@ abstract class groupdav_handler */ abstract function read($id /*,$path=null*/); + /** + * Get id from entry-array returned by read() + * + * @param int|string|array $entry + * @return int|string + */ + function get_id($entry) + { + return is_array($entry) ? $entry['id'] : $entry; + } + /** * Check if user has the neccessary rights on an entry * @@ -316,6 +334,49 @@ abstract class groupdav_handler return $entry; } + /** + * Return representation, if requested by HTTP Prefer header + * + * @param array $options + * @param int $id + * @param int $user=null account_id + * @return string|boolean http status of get or null if no representation was requested + */ + public function check_return_representation($options, $id, $user=null) + { + if (isset($_SERVER['HTTP_PREFER']) && in_array('return-representation', explode(',', $_SERVER['HTTP_PREFER']))) + { + if ($_SERVER['REQUEST_METHOD'] == 'POST') + { + $location = $this->groupdav->base_uri.$options['path']; + if ($location[0] == '/') + { + $location = (@$_SERVER['HTTPS'] === 'on' ? 'https' : 'http').'://'.$_SERVER['HTTP_HOST'].$location; + } + header('Content-Location: '.$location); + } + if (($ret = $this->get($options, $id ? $id : $this->new_id, $user)) && !empty($options['data'])) + { + header('Content-Length: '.$this->groupdav->bytes($options['data'])); + header('Content-Type: '.$options['mimetype']); + echo $options['data']; + } + } + return $ret; + } + + /** + * Update etag, ctag and sync-token to reflect changed attachments + * + * Not abstract, as not need to implement for apps not supporting managed attachments + * + * @param array|string|int $entry array with entry data from read, or id + */ + public function update_tags($entry) + { + + } + /** * Get the handler for the given app * @@ -498,6 +559,10 @@ abstract class groupdav_handler if (is_null($etag)) $etag = $this->get_etag($entry); header('ETag: "'.$etag.'"'); } + + // store (new) id for check_return_representation + $this->new_id = $this->get_path($entry); + // send Location header only on success AND if we dont use caldav_name as path-attribute or if ((is_bool($retval) ? $retval : $retval[0] === '2') && (!$path_attr_is_name || // POST with add-member query parameter