Esempio n. 1
0
    /**
     * Create a new CalDAVRequest object.
     */
    function __construct($options = array())
    {
        global $session, $c, $debugging;
        $this->options = $options;
        if (!isset($this->options['allow_by_email'])) {
            $this->options['allow_by_email'] = false;
        }
        if (isset($_SERVER['HTTP_PREFER'])) {
            $this->prefer = explode(',', $_SERVER['HTTP_PREFER']);
        } else {
            if (isset($_SERVER['HTTP_BRIEF']) && strtoupper($_SERVER['HTTP_BRIEF']) == 'T') {
                $this->prefer = array('return-minimal');
            } else {
                $this->prefer = array();
            }
        }
        /**
         * Our path is /<script name>/<user name>/<user controlled> if it ends in
         * a trailing '/' then it is referring to a DAV 'collection' but otherwise
         * it is referring to a DAV data item.
         *
         * Permissions are controlled as follows:
         *  1. if there is no <user name> component, the request has read privileges
         *  2. if the requester is an admin, the request has read/write priviliges
         *  3. if there is a <user name> component which matches the logged on user
         *     then the request has read/write privileges
         *  4. otherwise we query the defined relationships between users and use
         *     the minimum privileges returned from that analysis.
         */
        if (isset($_SERVER['PATH_INFO'])) {
            $this->path = $_SERVER['PATH_INFO'];
        } else {
            $this->path = '/';
            if (isset($_SERVER['REQUEST_URI'])) {
                if (preg_match('{^(.*?\\.php)(.*)$}', $_SERVER['REQUEST_URI'], $matches)) {
                    $this->path = $matches[2];
                    if (substr($this->path, 0, 1) != '/') {
                        $this->path = '/' . $this->path;
                    }
                } else {
                    if ($_SERVER['REQUEST_URI'] != '/') {
                        dbg_error_log('LOG', 'Server is not supplying PATH_INFO and REQUEST_URI does not include a PHP program.  Wildly guessing "/"!!!');
                    }
                }
            }
        }
        $this->path = rawurldecode($this->path);
        /** Allow a request for .../calendar.ics to translate into the calendar URL */
        if (preg_match('#^(/[^/]+/[^/]+).ics$#', $this->path, $matches)) {
            $this->path = $matches[1] . '/';
        }
        if (isset($c->replace_path) && isset($c->replace_path['from']) && isset($c->replace_path['to'])) {
            $this->path = preg_replace($c->replace_path['from'], $c->replace_path['to'], $this->path);
        }
        // dbg_error_log( "caldav", "Sanitising path '%s'", $this->path );
        $bad_chars_regex = '/[\\^\\[\\(\\\\]/';
        if (preg_match($bad_chars_regex, $this->path)) {
            $this->DoResponse(400, translate("The calendar path contains illegal characters."));
        }
        if (strstr($this->path, '//')) {
            $this->path = preg_replace('#//+#', '/', $this->path);
        }
        if (!isset($c->raw_post)) {
            $c->raw_post = file_get_contents('php://input');
        }
        if (isset($_SERVER['HTTP_CONTENT_ENCODING'])) {
            $encoding = $_SERVER['HTTP_CONTENT_ENCODING'];
            @dbg_error_log('caldav', 'Content-Encoding: %s', $encoding);
            $encoding = preg_replace('{[^a-z0-9-]}i', '', $encoding);
            if (!ini_get('open_basedir') && (isset($c->dbg['ALL']) || isset($c->dbg['caldav']))) {
                $fh = fopen('/var/log/davical/encoded_data.debug' . $encoding, 'w');
                if ($fh) {
                    fwrite($fh, $c->raw_post);
                    fclose($fh);
                }
            }
            switch ($encoding) {
                case 'gzip':
                    $this->raw_post = @gzdecode($c->raw_post);
                    break;
                case 'deflate':
                    $this->raw_post = @gzinflate($c->raw_post);
                    break;
                case 'compress':
                    $this->raw_post = @gzuncompress($c->raw_post);
                    break;
                default:
            }
            if (empty($this->raw_post) && !empty($c->raw_post)) {
                $this->PreconditionFailed(415, 'content-encoding', sprintf('Unable to decode "%s" content encoding.', $_SERVER['HTTP_CONTENT_ENCODING']));
            }
            $c->raw_post = $this->raw_post;
        } else {
            $this->raw_post = $c->raw_post;
        }
        if (isset($debugging) && isset($_GET['method'])) {
            $_SERVER['REQUEST_METHOD'] = $_GET['method'];
        } else {
            if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
                $_SERVER['REQUEST_METHOD'] = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
            }
        }
        $this->method = $_SERVER['REQUEST_METHOD'];
        $this->content_type = isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : null;
        if (preg_match('{^(\\S+/\\S+)\\s*(;.*)?$}', $this->content_type, $matches)) {
            $this->content_type = $matches[1];
        }
        if (strlen($c->raw_post) > 0) {
            if ($this->method == 'PROPFIND' || $this->method == 'REPORT' || $this->method == 'PROPPATCH' || $this->method == 'BIND' || $this->method == 'MKTICKET' || $this->method == 'ACL') {
                if (!preg_match('{^(text|application)/xml$}', $this->content_type)) {
                    @dbg_error_log("LOG request", 'Request is "%s" but client set content-type to "%s". Assuming they meant XML!', $this->method, $this->content_type);
                    $this->content_type = 'text/xml';
                }
            } else {
                if ($this->method == 'PUT' || $this->method == 'POST') {
                    $this->CoerceContentType();
                }
            }
        } else {
            $this->content_type = 'text/plain';
        }
        $this->user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : "Probably Mulberry";
        /**
         * A variety of requests may set the "Depth" header to control recursion
         */
        if (isset($_SERVER['HTTP_DEPTH'])) {
            $this->depth = $_SERVER['HTTP_DEPTH'];
        } else {
            /**
             * Per rfc2518, section 9.2, 'Depth' might not always be present, and if it
             * is not present then a reasonable request-type-dependent default should be
             * chosen.
             */
            switch ($this->method) {
                case 'DELETE':
                case 'MOVE':
                case 'COPY':
                case 'LOCK':
                    $this->depth = 'infinity';
                    break;
                case 'REPORT':
                    $this->depth = 0;
                    break;
                case 'PROPFIND':
                default:
                    $this->depth = 0;
            }
        }
        if (!is_int($this->depth) && "infinity" == $this->depth) {
            $this->depth = DEPTH_INFINITY;
        }
        $this->depth = intval($this->depth);
        /**
         * MOVE/COPY use a "Destination" header and (optionally) an "Overwrite" one.
         */
        if (isset($_SERVER['HTTP_DESTINATION'])) {
            $this->destination = $_SERVER['HTTP_DESTINATION'];
            if (preg_match('{^(https?)://([a-z.-]+)(:[0-9]+)?(/.*)$}', $this->destination, $matches)) {
                $this->destination = $matches[4];
            }
        }
        $this->overwrite = isset($_SERVER['HTTP_OVERWRITE']) && $_SERVER['HTTP_OVERWRITE'] == 'F' ? false : true;
        // RFC4918, 9.8.4 says default True.
        /**
         * LOCK things use an "If" header to hold the lock in some cases, and "Lock-token" in others
         */
        if (isset($_SERVER['HTTP_IF'])) {
            $this->if_clause = $_SERVER['HTTP_IF'];
        }
        if (isset($_SERVER['HTTP_LOCK_TOKEN']) && preg_match('#[<]opaquelocktoken:(.*)[>]#', $_SERVER['HTTP_LOCK_TOKEN'], $matches)) {
            $this->lock_token = $matches[1];
        }
        /**
         * Check for an access ticket.
         */
        if (isset($_GET['ticket'])) {
            $this->ticket = new DAVTicket($_GET['ticket']);
        } else {
            if (isset($_SERVER['HTTP_TICKET'])) {
                $this->ticket = new DAVTicket($_SERVER['HTTP_TICKET']);
            }
        }
        /**
         * LOCK things use a "Timeout" header to set a series of reducing alternative values
         */
        if (isset($_SERVER['HTTP_TIMEOUT'])) {
            $timeouts = explode(',', $_SERVER['HTTP_TIMEOUT']);
            foreach ($timeouts as $k => $v) {
                if (strtolower($v) == 'infinite') {
                    $this->timeout = isset($c->maximum_lock_timeout) ? $c->maximum_lock_timeout : 86400 * 100;
                    break;
                } elseif (strtolower(substr($v, 0, 7)) == 'second-') {
                    $this->timeout = min(intval(substr($v, 7)), isset($c->maximum_lock_timeout) ? $c->maximum_lock_timeout : 86400 * 100);
                    break;
                }
            }
            if (!isset($this->timeout) || $this->timeout == 0) {
                $this->timeout = isset($c->default_lock_timeout) ? $c->default_lock_timeout : 900;
            }
        }
        $this->principal = new Principal('path', $this->path);
        /**
         * RFC2518, 5.2: URL pointing to a collection SHOULD end in '/', and if it does not then
         * we SHOULD return a Content-location header with the correction...
         *
         * We therefore look for a collection which matches one of the following URLs:
         *  - The exact request.
         *  - If the exact request, doesn't end in '/', then the request URL with a '/' appended
         *  - The request URL truncated to the last '/'
         * The collection URL for this request is therefore the longest row in the result, so we
         * can "... ORDER BY LENGTH(dav_name) DESC LIMIT 1"
         */
        $sql = "SELECT * FROM collection WHERE dav_name = :exact_name";
        $params = array(':exact_name' => $this->path);
        if (!preg_match('#/$#', $this->path)) {
            $sql .= " OR dav_name = :truncated_name OR dav_name = :trailing_slash_name";
            $params[':truncated_name'] = preg_replace('#[^/]*$#', '', $this->path);
            $params[':trailing_slash_name'] = $this->path . "/";
        }
        $sql .= " ORDER BY LENGTH(dav_name) DESC LIMIT 1";
        $qry = new AwlQuery($sql, $params);
        if ($qry->Exec('caldav', __LINE__, __FILE__) && $qry->rows() == 1 && ($row = $qry->Fetch())) {
            if ($row->dav_name == $this->path . "/") {
                $this->path = $row->dav_name;
                dbg_error_log("caldav", "Path is actually a collection - sending Content-Location header.");
                header("Content-Location: " . ConstructURL($this->path));
            }
            $this->collection_id = $row->collection_id;
            $this->collection_path = $row->dav_name;
            $this->collection_type = $row->is_calendar == 't' ? 'calendar' : 'collection';
            $this->collection = $row;
            if (preg_match('#^((/[^/]+/)\\.(in|out)/)[^/]*$#', $this->path, $matches)) {
                $this->collection_type = 'schedule-' . $matches[3] . 'box';
            }
            $this->collection->type = $this->collection_type;
        } else {
            if (preg_match('{^( ( / ([^/]+) / ) \\.(in|out)/ ) [^/]*$}x', $this->path, $matches)) {
                // The request is for a scheduling inbox or outbox (or something inside one) and we should auto-create it
                $params = array(':username' => $matches[3], ':parent_container' => $matches[2], ':dav_name' => $matches[1]);
                $params[':boxname'] = $matches[4] == 'in' ? ' Inbox' : ' Outbox';
                $this->collection_type = 'schedule-' . $matches[4] . 'box';
                $params[':resourcetypes'] = sprintf('<DAV::collection/><urn:ietf:params:xml:ns:caldav:%s/>', $this->collection_type);
                $sql = <<<EOSQL
INSERT INTO collection ( user_no, parent_container, dav_name, dav_displayname, is_calendar, created, modified, dav_etag, resourcetypes )
    VALUES( (SELECT user_no FROM usr WHERE username = text(:username)),
            :parent_container, :dav_name,
            (SELECT fullname FROM usr WHERE username = text(:username)) || :boxname,
             FALSE, current_timestamp, current_timestamp, '1', :resourcetypes )
EOSQL;
                $qry = new AwlQuery($sql, $params);
                $qry->Exec('caldav', __LINE__, __FILE__);
                dbg_error_log('caldav', 'Created new collection as "%s".', trim($params[':boxname']));
                // Uncache anything to do with the collection
                $cache = getCacheInstance();
                $cache->delete('collection-' . $params[':dav_name'], null);
                $cache->delete('principal-' . $params[':parent_container'], null);
                $qry = new AwlQuery("SELECT * FROM collection WHERE dav_name = :dav_name", array(':dav_name' => $matches[1]));
                if ($qry->Exec('caldav', __LINE__, __FILE__) && $qry->rows() == 1 && ($row = $qry->Fetch())) {
                    $this->collection_id = $row->collection_id;
                    $this->collection_path = $matches[1];
                    $this->collection = $row;
                    $this->collection->type = $this->collection_type;
                }
            } else {
                if (preg_match('#^((/[^/]+/)calendar-proxy-(read|write))/?[^/]*$#', $this->path, $matches)) {
                    $this->collection_type = 'proxy';
                    $this->_is_proxy_request = true;
                    $this->proxy_type = $matches[3];
                    $this->collection_path = $matches[1] . '/';
                    // Enforce trailling '/'
                    if ($this->collection_path == $this->path . "/") {
                        $this->path .= '/';
                        dbg_error_log("caldav", "Path is actually a (proxy) collection - sending Content-Location header.");
                        header("Content-Location: " . ConstructURL($this->path));
                    }
                } else {
                    if ($this->options['allow_by_email'] && preg_match('#^/(\\S+@\\S+[.]\\S+)/?$#', $this->path)) {
                        /** @todo we should deprecate this now that Evolution 2.27 can do scheduling extensions */
                        $this->collection_id = -1;
                        $this->collection_type = 'email';
                        $this->collection_path = $this->path;
                        $this->_is_principal = true;
                    } else {
                        if (preg_match('#^(/[^/?]+)/?$#', $this->path, $matches) || preg_match('#^(/principals/[^/]+/[^/]+)/?$#', $this->path, $matches)) {
                            $this->collection_id = -1;
                            $this->collection_path = $matches[1] . '/';
                            // Enforce trailling '/'
                            $this->collection_type = 'principal';
                            $this->_is_principal = true;
                            if ($this->collection_path == $this->path . "/") {
                                $this->path .= '/';
                                dbg_error_log("caldav", "Path is actually a collection - sending Content-Location header.");
                                header("Content-Location: " . ConstructURL($this->path));
                            }
                            if (preg_match('#^(/principals/[^/]+/[^/]+)/?$#', $this->path, $matches)) {
                                // Force a depth of 0 on these, which are at the wrong URL.
                                $this->depth = 0;
                            }
                        } else {
                            if ($this->path == '/') {
                                $this->collection_id = -1;
                                $this->collection_path = '/';
                                $this->collection_type = 'root';
                            }
                        }
                    }
                }
            }
        }
        if ($this->collection_path == $this->path) {
            $this->_is_collection = true;
        }
        dbg_error_log("caldav", " Collection '%s' is %d, type %s", $this->collection_path, $this->collection_id, $this->collection_type);
        /**
         * Extract the user whom we are accessing
         */
        $this->principal = new DAVPrincipal(array("path" => $this->path, "options" => $this->options));
        $this->user_no = $this->principal->user_no();
        $this->username = $this->principal->username();
        $this->by_email = $this->principal->byEmail();
        $this->principal_id = $this->principal->principal_id();
        if ($this->collection_type == 'principal' || $this->collection_type == 'email' || $this->collection_type == 'proxy') {
            $this->collection = $this->principal->AsCollection();
            if ($this->collection_type == 'proxy') {
                $this->collection = $this->principal->AsCollection();
                $this->collection->is_proxy = 't';
                $this->collection->type = 'proxy';
                $this->collection->proxy_type = $this->proxy_type;
                $this->collection->dav_displayname = sprintf('Proxy %s for %s', $this->proxy_type, $this->principal->username());
            }
        } elseif ($this->collection_type == 'root') {
            $this->collection = (object) array('collection_id' => 0, 'dav_name' => '/', 'dav_etag' => md5($c->system_name), 'is_calendar' => 'f', 'is_addressbook' => 'f', 'is_principal' => 'f', 'user_no' => 0, 'dav_displayname' => $c->system_name, 'type' => 'root', 'created' => date('Ymd\\THis'));
        }
        /**
         * Evaluate our permissions for accessing the target
         */
        $this->setPermissions();
        $this->supported_methods = array('OPTIONS' => '', 'PROPFIND' => '', 'REPORT' => '', 'DELETE' => '', 'LOCK' => '', 'UNLOCK' => '', 'MOVE' => '', 'ACL' => '');
        if ($this->IsCollection()) {
            switch ($this->collection_type) {
                case 'root':
                case 'email':
                    // We just override the list completely here.
                    $this->supported_methods = array('OPTIONS' => '', 'PROPFIND' => '', 'REPORT' => '');
                    break;
                case 'schedule-inbox':
                case 'schedule-outbox':
                    $this->supported_methods = array_merge($this->supported_methods, array('POST' => '', 'GET' => '', 'PUT' => '', 'HEAD' => '', 'PROPPATCH' => ''));
                    break;
                case 'calendar':
                    $this->supported_methods['GET'] = '';
                    $this->supported_methods['PUT'] = '';
                    $this->supported_methods['HEAD'] = '';
                    break;
                case 'collection':
                case 'principal':
                    $this->supported_methods['GET'] = '';
                    $this->supported_methods['PUT'] = '';
                    $this->supported_methods['HEAD'] = '';
                    $this->supported_methods['MKCOL'] = '';
                    $this->supported_methods['MKCALENDAR'] = '';
                    $this->supported_methods['PROPPATCH'] = '';
                    $this->supported_methods['BIND'] = '';
                    break;
            }
        } else {
            $this->supported_methods = array_merge($this->supported_methods, array('GET' => '', 'HEAD' => '', 'PUT' => ''));
        }
        $this->supported_reports = array('DAV::principal-property-search' => '', 'DAV::expand-property' => '', 'DAV::sync-collection' => '');
        if (isset($this->collection) && $this->collection->is_calendar) {
            $this->supported_reports = array_merge($this->supported_reports, array('urn:ietf:params:xml:ns:caldav:calendar-query' => '', 'urn:ietf:params:xml:ns:caldav:calendar-multiget' => '', 'urn:ietf:params:xml:ns:caldav:free-busy-query' => ''));
        }
        if (isset($this->collection) && $this->collection->is_addressbook) {
            $this->supported_reports = array_merge($this->supported_reports, array('urn:ietf:params:xml:ns:carddav:addressbook-query' => '', 'urn:ietf:params:xml:ns:carddav:addressbook-multiget' => ''));
        }
        /**
         * If the content we are receiving is XML then we parse it here.  RFC2518 says we
         * should reasonably expect to see either text/xml or application/xml
         */
        if (isset($this->content_type) && preg_match('#(application|text)/xml#', $this->content_type)) {
            if (!isset($this->raw_post) || $this->raw_post == '') {
                $this->XMLResponse(400, new XMLElement('error', new XMLElement('missing-xml'), array('xmlns' => 'DAV:')));
            }
            $xml_parser = xml_parser_create_ns('UTF-8');
            $this->xml_tags = array();
            xml_parser_set_option($xml_parser, XML_OPTION_SKIP_WHITE, 1);
            xml_parser_set_option($xml_parser, XML_OPTION_CASE_FOLDING, 0);
            $rc = xml_parse_into_struct($xml_parser, $this->raw_post, $this->xml_tags);
            if ($rc == false) {
                dbg_error_log('ERROR', 'XML parsing error: %s at line %d, column %d', xml_error_string(xml_get_error_code($xml_parser)), xml_get_current_line_number($xml_parser), xml_get_current_column_number($xml_parser));
                $this->XMLResponse(400, new XMLElement('error', new XMLElement('invalid-xml'), array('xmlns' => 'DAV:')));
            }
            xml_parser_free($xml_parser);
            if (count($this->xml_tags)) {
                dbg_error_log("caldav", " Parsed incoming XML request body.");
            } else {
                $this->xml_tags = null;
                dbg_error_log("ERROR", "Incoming request sent content-type XML with no XML request body.");
            }
        }
        /**
         * Look out for If-None-Match or If-Match headers
         */
        if (isset($_SERVER["HTTP_IF_NONE_MATCH"])) {
            $this->etag_none_match = $_SERVER["HTTP_IF_NONE_MATCH"];
            if ($this->etag_none_match == '') {
                unset($this->etag_none_match);
            }
        }
        if (isset($_SERVER["HTTP_IF_MATCH"])) {
            $this->etag_if_match = $_SERVER["HTTP_IF_MATCH"];
            if ($this->etag_if_match == '') {
                unset($this->etag_if_match);
            }
        }
    }
/**
 * Do the scheduling adjustments for a REPLY when an ATTENDEE updates their status.
 * @param vCalendar $resource The resource that the ATTENDEE is writing to their calendar
 * @param string $organizer The property which is the event ORGANIZER.
 */
function do_scheduling_reply(vCalendar $resource, vProperty $organizer)
{
    global $request;
    $organizer_email = preg_replace('/^mailto:/i', '', $organizer->Value());
    $organizer_principal = new Principal('email', $organizer_email);
    if (!$organizer_principal->Exists()) {
        dbg_error_log('PUT', 'Organizer "%s" not found - cannot perform scheduling reply.', $organizer);
        return false;
    }
    $sql = 'SELECT caldav_data.dav_name, caldav_data.caldav_data FROM caldav_data JOIN calendar_item USING(dav_id) ';
    $sql .= 'WHERE caldav_data.collection_id IN (SELECT collection_id FROM collection WHERE is_calendar AND user_no =?) ';
    $sql .= 'AND uid=? LIMIT 1';
    $uids = $resource->GetPropertiesByPath('/VCALENDAR/*/UID');
    if (count($uids) == 0) {
        dbg_error_log('PUT', 'No UID in VCALENDAR - giving up on REPLY.');
        return false;
    }
    $uid = $uids[0]->Value();
    $qry = new AwlQuery($sql, $organizer_principal->user_no(), $uid);
    if (!$qry->Exec('PUT', __LINE__, __FILE__) || $qry->rows() < 1) {
        dbg_error_log('PUT', 'Could not find original event from organizer - giving up on REPLY.');
        return false;
    }
    $row = $qry->Fetch();
    $attendees = $resource->GetAttendees();
    foreach ($attendees as $v) {
        $email = preg_replace('/^mailto:/i', '', $v->Value());
        if ($email == $request->principal->email()) {
            $attendee = $v;
        }
    }
    if (empty($attendee)) {
        dbg_error_log('PUT', 'Could not find ATTENDEE in VEVENT - giving up on REPLY.');
        return false;
    }
    $schedule_original = new vCalendar($row->caldav_data);
    $schedule_original->UpdateAttendeeStatus($request->principal->email(), clone $attendee);
    $collection_path = preg_replace('{/[^/]+$}', '/', $row->dav_name);
    $segment_name = str_replace($collection_path, '', $row->dav_name);
    $organizer_calendar = new WritableCollection(array('path' => $collection_path));
    $organizer_inbox = new WritableCollection(array('path' => $organizer_principal->internal_url('schedule-inbox')));
    $schedule_reply = clone $schedule_original;
    $schedule_reply->AddProperty('METHOD', 'REPLY');
    dbg_error_log('PUT', 'Writing scheduling REPLY from %s to %s', $request->principal->email(), $organizer_principal->email());
    $response = '3.7';
    // Organizer was not found on server.
    if (!$organizer_calendar->Exists()) {
        dbg_error_log('ERROR', 'Default calendar at "%s" does not exist for user "%s"', $organizer_calendar->dav_name(), $schedule_target->username());
        $response = '5.2';
        // No scheduling support for user
    } else {
        if (!$organizer_inbox->HavePrivilegeTo('schedule-deliver-reply')) {
            $response = '3.8';
            // No authority to deliver replies to organizer.
        } else {
            if ($organizer_inbox->WriteCalendarMember($schedule_reply, false, false, $request->principal->username() . $segment_name) !== false) {
                $response = '1.2';
                // Scheduling reply delivered successfully
                if ($organizer_calendar->WriteCalendarMember($schedule_original, false, false, $segment_name) === false) {
                    dbg_error_log('ERROR', 'Could not write updated calendar member to %s', $attendee_calendar->dav_name(), $attendee_calendar->dav_name(), $schedule_target->username());
                    trace_bug('Failed to write scheduling resource.');
                }
            }
        }
    }
    $schedule_request = clone $schedule_original;
    $schedule_request->AddProperty('METHOD', 'REQUEST');
    dbg_error_log('PUT', 'Status for organizer <%s> set to "%s"', $organizer->Value(), $response);
    $organizer->SetParameterValue('SCHEDULE-STATUS', $response);
    $resource->UpdateOrganizerStatus($organizer);
    $scheduling_actions = true;
    $calling_attendee = clone $attendee;
    $attendees = $schedule_original->GetAttendees();
    foreach ($attendees as $attendee) {
        $email = preg_replace('/^mailto:/i', '', $attendee->Value());
        if ($email == $request->principal->email() || $email == $organizer_principal->email()) {
            continue;
        }
        $agent = $attendee->GetParameterValue('SCHEDULE-AGENT');
        if ($agent && $agent != 'SERVER') {
            dbg_error_log("PUT", "not delivering to %s, schedule agent set to value other than server", $email);
            continue;
        }
        // an attendee's reply should modify only the PARTSTAT on other attendees' objects
        // other properties (that might have been adjusted individually by those other
        // attendees) should remain unmodified. Therefore, we have to make $schedule_original
        // and $schedule_request be initialized by each attendee's object here.
        $attendee_principal = new DAVPrincipal(array('email' => $email, 'options' => array('allow_by_email' => true)));
        if ($attendee_principal == false) {
            dbg_error_log('PUT', 'Could not find attendee %s', $email);
            continue;
        }
        $sql = 'SELECT caldav_data.dav_name, caldav_data.caldav_data, caldav_data.collection_id FROM caldav_data JOIN calendar_item USING(dav_id) ';
        $sql .= 'WHERE caldav_data.collection_id IN (SELECT collection_id FROM collection WHERE is_calendar AND user_no =?) ';
        $sql .= 'AND uid=? LIMIT 1';
        $qry = new AwlQuery($sql, $attendee_principal->user_no(), $uid);
        if (!$qry->Exec('PUT', __LINE__, __FILE__) || $qry->rows() < 1) {
            dbg_error_log('PUT', "Could not find attendee's event %s", $uid);
        }
        $row = $qry->Fetch();
        $schedule_original = new vCalendar($row->caldav_data);
        $schedule_original->UpdateAttendeeStatus($request->principal->email(), clone $calling_attendee);
        $schedule_request = clone $schedule_original;
        $schedule_request->AddProperty('METHOD', 'REQUEST');
        $schedule_target = new Principal('email', $email);
        $response = '3.7';
        // Attendee was not found on server.
        if ($schedule_target->Exists()) {
            // Instead of always writing to schedule-default-calendar, we first try to
            // find a calendar with an existing instance of the event in any calendar of this attendee.
            $r = new DAVResource($row);
            $attendee_calendar = new WritableCollection(array('path' => $r->parent_path()));
            if ($attendee_calendar->IsCalendar()) {
                dbg_error_log('XXX', "found the event in attendee's calendar %s", $attendee_calendar->dav_name());
            } else {
                dbg_error_log('XXX', 'could not find the event in any calendar, using schedule-default-calendar');
                $attendee_calendar = new WritableCollection(array('path' => $schedule_target->internal_url('schedule-default-calendar')));
            }
            if (!$attendee_calendar->Exists()) {
                dbg_error_log('ERROR', 'Default calendar at "%s" does not exist for user "%s"', $attendee_calendar->dav_name(), $schedule_target->username());
                $response = '5.2';
                // No scheduling support for user
            } else {
                $attendee_inbox = new WritableCollection(array('path' => $schedule_target->internal_url('schedule-inbox')));
                if (!$attendee_inbox->HavePrivilegeTo('schedule-deliver-invite')) {
                    $response = '3.8';
                    //  No authority to deliver invitations to user.
                } else {
                    if ($attendee_inbox->WriteCalendarMember($schedule_request, false) !== false) {
                        $response = '1.2';
                        // Scheduling invitation delivered successfully
                        if ($attendee_calendar->WriteCalendarMember($schedule_original, false) === false) {
                            dbg_error_log('ERROR', 'Could not write updated calendar member to %s', $attendee_calendar->dav_name(), $attendee_calendar->dav_name(), $schedule_target->username());
                            trace_bug('Failed to write scheduling resource.');
                        }
                    }
                }
            }
        }
        dbg_error_log('PUT', 'Status for attendee <%s> set to "%s"', $attendee->Value(), $response);
        $attendee->SetParameterValue('SCHEDULE-STATUS', $response);
        $scheduling_actions = true;
        $resource->UpdateAttendeeStatus($email, clone $attendee);
    }
    return $scheduling_actions;
}