Add repair step for updater issues

The updater as shipped with ownCloud =< 9.0.1 has several bugs leading to a not properly executed update. For example the third-party changes are not copied.

This pull request:

1. Ships the third-party files changed since ownCloud 9.0.1 in the resources folder. On update the files are replaced. (https://github.com/owncloud/updater/issues/316)
2. Adds updater/* and _oc_upgrade/* as an exemption to the code integrity checker since the updater is updating in the wrong order. (https://github.com/owncloud/updater/issues/318)
This commit is contained in:
Lukas Reschke 2016-04-20 16:41:11 +02:00
parent bd19bbb926
commit 2d373416d8
No known key found for this signature in database
GPG Key ID: 9AB0ADB949B6898C
19 changed files with 13233 additions and 0 deletions

View File

@ -342,6 +342,19 @@ class Checker {
throw new InvalidSignatureException('Signature could not get verified.');
}
// Fixes for the updater as shipped with ownCloud 9.0.x: The updater is
// replaced after the code integrity check is performed.
//
// Due to this reason we exclude the whole updater/ folder from the code
// integrity check.
if($basePath === $this->environmentHelper->getServerRoot()) {
foreach($expectedHashes as $fileName => $hash) {
if(strpos($fileName, 'updater/') === 0) {
unset($expectedHashes[$fileName]);
}
}
}
// Compare the list of files which are not identical
$currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
$differencesA = array_diff($expectedHashes, $currentInstanceHashes);

View File

@ -39,6 +39,11 @@ class ExcludeFoldersByPathFilterIterator extends \RecursiveFilterIterator {
rtrim($root . '/apps', '/'),
rtrim($root . '/assets', '/'),
rtrim($root . '/lost+found', '/'),
// Ignore folders generated by updater since the updater is replaced
// after the integrity check is run.
// See https://github.com/owncloud/updater/issues/318#issuecomment-212497846
rtrim($root . '/updater', '/'),
rtrim($root . '/_oc_upgrade', '/'),
];
$customDataDir = \OC::$server->getConfig()->getSystemValue('datadirectory', '');
if($customDataDir !== '') {

View File

@ -31,6 +31,7 @@ namespace OC;
use OC\Hooks\BasicEmitter;
use OC\Hooks\Emitter;
use OC\Repair\AssetCache;
use OC\Repair\BrokenUpdaterRepair;
use OC\Repair\CleanTags;
use OC\Repair\Collation;
use OC\Repair\DropOldJobs;
@ -114,6 +115,7 @@ class Repair extends BasicEmitter {
new RemoveGetETagEntries(\OC::$server->getDatabaseConnection()),
new UpdateOutdatedOcsIds(\OC::$server->getConfig()),
new RepairInvalidShares(\OC::$server->getConfig(), \OC::$server->getDatabaseConnection()),
new BrokenUpdaterRepair(),
];
}

View File

@ -0,0 +1,109 @@
<?php
/**
* @author Lukas Reschke <lukas@owncloud.com>
*
* @copyright Copyright (c) 2016, ownCloud, Inc.
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OC\Repair;
use OC\Hooks\BasicEmitter;
/**
* Class BrokenUpdaterRepair fixes some issues caused by bugs in the ownCloud
* updater below version 9.0.2.
*
* FIXME: This file should be removed after the 9.0.2 release. The update server
* is instructed to deliver 9.0.2 for 9.0.0 and 9.0.1.
*
* @package OC\Repair
*/
class BrokenUpdaterRepair extends BasicEmitter implements \OC\RepairStep {
public function getName() {
return 'Manually copies the third-party folder changes since 9.0.0 due ' .
'to a bug in the updater.';
}
/**
* Manually copy the third-party files that have changed since 9.0.0 because
* the old updater does not copy over third-party changes.
*
* @return bool True if action performed, false otherwise
*/
private function manuallyCopyThirdPartyFiles() {
$resourceDir = __DIR__ . '/../../../resources/updater-fixes/';
$thirdPartyDir = __DIR__ . '/../../../3rdparty/';
$filesToCopy = [
// Composer updates
'composer.json',
'composer.lock',
'composer/autoload_classmap.php',
'composer/installed.json',
'composer/LICENSE',
// Icewind stream library
'icewind/streams/src/DirectoryFilter.php',
'icewind/streams/src/DirectoryWrapper.php',
'icewind/streams/src/RetryWrapper.php',
'icewind/streams/src/SeekableWrapper.php',
// Sabre update
'sabre/dav/CHANGELOG.md',
'sabre/dav/composer.json',
'sabre/dav/lib/CalDAV/Plugin.php',
'sabre/dav/lib/CardDAV/Backend/PDO.php',
'sabre/dav/lib/DAV/CorePlugin.php',
'sabre/dav/lib/DAV/Version.php',
];
// First check whether the files have been copied the first time already
// if so there is no need to run the move routine.
if(file_exists($thirdPartyDir . '/icewind/streams/src/RetryWrapper.php')) {
$this->emit('\OC\Repair', 'info', ['Third-party files seem already to have been copied. No repair necessary.']);
return false;
}
foreach($filesToCopy as $file) {
$state = copy($resourceDir . '/' . $file, $thirdPartyDir . '/' . $file);
if($state === true) {
$this->emit('\OC\Repair', 'info', ['Successfully replaced '.$file.' with new version.']);
} else {
$this->emit('\OC\Repair', 'warning', ['Could not replace '.$file.' with new version.']);
}
}
return true;
}
/**
* Rerun the integrity check after the update since the repair step has
* repaired some invalid copied files.
*/
private function recheckIntegrity() {
\OC::$server->getIntegrityCodeChecker()->runInstanceVerification();
}
public function run() {
if($this->manuallyCopyThirdPartyFiles()) {
$this->emit('\OC\Repair', 'info', ['Start integrity recheck.']);
$this->recheckIntegrity();
$this->emit('\OC\Repair', 'info', ['Finished integrity recheck.']);
} else {
$this->emit('\OC\Repair', 'info', ['Rechecking code integrity not necessary.']);
}
}
}

View File

@ -0,0 +1,44 @@
{
"name": "owncloud/3rdparty",
"description": "All 3rdparty components",
"license": "MIT",
"config": {
"vendor-dir": ".",
"optimize-autoloader": true,
"classmap-authoritative": true
},
"require": {
"doctrine/dbal": "2.5.2",
"mcnetic/zipstreamer": "^1.0",
"phpseclib/phpseclib": "2.0.0",
"rackspace/php-opencloud": "v1.9.2",
"james-heinrich/getid3": "dev-master",
"jeremeamia/superclosure": "2.1.0",
"ircmaxell/random-lib": "~1.1",
"bantu/ini-get-wrapper": "v1.0.1",
"natxet/CssMin": "dev-master",
"punic/punic": "1.6.3",
"pear/archive_tar": "1.4.1",
"patchwork/utf8": "1.2.6",
"symfony/console": "2.8.1",
"symfony/event-dispatcher": "2.8.1",
"symfony/routing": "2.8.1",
"symfony/process": "2.8.1",
"pimple/pimple": "3.0.2",
"ircmaxell/password-compat": "1.0.*",
"nikic/php-parser": "1.4.1",
"icewind/Streams": "0.4.0",
"swiftmailer/swiftmailer": "@stable",
"guzzlehttp/guzzle": "5.3.0",
"league/flysystem": "1.0.16",
"pear/pear-core-minimal": "v1.10.1",
"interfasys/lognormalizer": "^v1.0",
"deepdiver1975/TarStreamer": "v0.1.0",
"patchwork/jsqueeze": "^2.0",
"kriswallsmith/assetic": "1.3.2",
"sabre/dav": "3.0.9",
"symfony/polyfill-php70": "^1.0",
"symfony/polyfill-php55": "^1.0",
"symfony/polyfill-php56": "^1.0"
}
}

3156
resources/updater-fixes/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
Copyright (c) 2016 Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
<?php
/**
* Copyright (c) 2015 Robin Appelman <icewind@owncloud.com>
* This file is licensed under the Licensed under the MIT license:
* http://opensource.org/licenses/MIT
*/
namespace Icewind\Streams;
/**
* Wrapper allows filtering of directories
*
* The filter callback will be called for each entry in the folder
* when the callback return false the entry will be filtered out
*/
class DirectoryFilter extends DirectoryWrapper {
/**
* @var callable
*/
private $filter;
/**
* @param string $path
* @param array $options
* @return bool
*/
public function dir_opendir($path, $options) {
$context = $this->loadContext('filter');
$this->filter = $context['filter'];
return true;
}
/**
* @return string
*/
public function dir_readdir() {
$file = readdir($this->source);
$filter = $this->filter;
// keep reading untill we have an accepted entry or we're at the end of the folder
while ($file !== false && $filter($file) === false) {
$file = readdir($this->source);
}
return $file;
}
/**
* @param resource $source
* @param callable $filter
* @return resource
*/
public static function wrap($source, callable $filter) {
$options = array(
'filter' => array(
'source' => $source,
'filter' => $filter
)
);
return self::wrapWithOptions($options, '\Icewind\Streams\DirectoryFilter');
}
}

View File

@ -0,0 +1,88 @@
<?php
/**
* Copyright (c) 2015 Robin Appelman <icewind@owncloud.com>
* This file is licensed under the Licensed under the MIT license:
* http://opensource.org/licenses/MIT
*/
namespace Icewind\Streams;
class DirectoryWrapper implements Directory {
/**
* @var resource
*/
public $context;
/**
* @var resource
*/
protected $source;
/**
* Load the source from the stream context and return the context options
*
* @param string $name
* @return array
* @throws \Exception
*/
protected function loadContext($name) {
$context = stream_context_get_options($this->context);
if (isset($context[$name])) {
$context = $context[$name];
} else {
throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set');
}
if (isset($context['source']) and is_resource($context['source'])) {
$this->source = $context['source'];
} else {
throw new \BadMethodCallException('Invalid context, source not set');
}
return $context;
}
/**
* @param string $path
* @param array $options
* @return bool
*/
public function dir_opendir($path, $options) {
$this->loadContext('dir');
return true;
}
/**
* @return string
*/
public function dir_readdir() {
return readdir($this->source);
}
/**
* @return bool
*/
public function dir_closedir() {
closedir($this->source);
return true;
}
/**
* @return bool
*/
public function dir_rewinddir() {
rewinddir($this->source);
return true;
}
/**
* @param array $options the options for the context to wrap the stream with
* @param string $class
* @return resource
*/
protected static function wrapWithOptions($options, $class) {
$context = stream_context_create($options);
stream_wrapper_register('dirwrapper', $class);
$wrapped = opendir('dirwrapper://', $context);
stream_wrapper_unregister('dirwrapper');
return $wrapped;
}
}

View File

@ -0,0 +1,66 @@
<?php
/**
* Copyright (c) 2016 Robin Appelman <icewind@owncloud.com>
* This file is licensed under the Licensed under the MIT license:
* http://opensource.org/licenses/MIT
*/
namespace Icewind\Streams;
/**
* Wrapper that retries reads/writes to remote streams that dont deliver/recieve all requested data at once
*/
class RetryWrapper extends Wrapper {
/**
* Wraps a stream with the provided callbacks
*
* @param resource $source
* @return resource
*/
public static function wrap($source) {
$context = stream_context_create(array(
'retry' => array(
'source' => $source
)
));
return Wrapper::wrapSource($source, $context, 'retry', '\Icewind\Streams\RetryWrapper');
}
protected function open() {
$this->loadContext('retry');
return true;
}
public function dir_opendir($path, $options) {
return false;
}
public function stream_open($path, $mode, $options, &$opened_path) {
return $this->open();
}
public function stream_read($count) {
$result = parent::stream_read($count);
$bytesReceived = strlen($result);
while ($bytesReceived < $count && !$this->stream_eof()) {
$result .= parent::stream_read($count - $bytesReceived);
$bytesReceived = strlen($result);
}
return $result;
}
public function stream_write($data) {
$bytesToSend = strlen($data);
$result = parent::stream_write($data);
while ($result < $bytesToSend && !$this->stream_eof()) {
$dataLeft = substr($data, $result);
$result += parent::stream_write($dataLeft);
}
return $result;
}
}

View File

@ -0,0 +1,92 @@
<?php
/**
* Copyright (c) 2014 Robin Appelman <icewind@owncloud.com>
* This file is licensed under the Licensed under the MIT license:
* http://opensource.org/licenses/MIT
*/
namespace Icewind\Streams;
/**
* Wrapper that provides callbacks for write, read and close
*
* The following options should be passed in the context when opening the stream
* [
* 'callback' => [
* 'source' => resource
* ]
* ]
*
* All callbacks are called after the operation is executed on the source stream
*/
class SeekableWrapper extends Wrapper {
/**
* @var resource
*/
protected $cache;
/**
* Wraps a stream to make it seekable
*
* @param resource $source
* @return resource
*
* @throws \BadMethodCallException
*/
public static function wrap($source) {
$context = stream_context_create(array(
'callback' => array(
'source' => $source
)
));
return Wrapper::wrapSource($source, $context, 'callback', '\Icewind\Streams\SeekableWrapper');
}
public function dir_opendir($path, $options) {
return false;
}
public function stream_open($path, $mode, $options, &$opened_path) {
$this->loadContext('callback');
$this->cache = fopen('php://temp', 'w+');
return true;
}
protected function readTill($position) {
$current = ftell($this->source);
if ($position > $current) {
$data = parent::stream_read($position - $current);
$cachePosition = ftell($this->cache);
fseek($this->cache, $current);
fwrite($this->cache, $data);
fseek($this->cache, $cachePosition);
}
}
public function stream_read($count) {
$current = ftell($this->cache);
$this->readTill($current + $count);
return fread($this->cache, $count);
}
public function stream_seek($offset, $whence = SEEK_SET) {
if ($whence === SEEK_SET) {
$target = $offset;
} else if ($whence === SEEK_CUR) {
$current = ftell($this->cache);
$target = $current + $offset;
} else {
return false;
}
$this->readTill($target);
return fseek($this->cache, $target) === 0;
}
public function stream_tell() {
return ftell($this->cache);
}
public function stream_eof() {
return parent::stream_eof() and (ftell($this->source) === ftell($this->cache));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
{
"name": "sabre/dav",
"type": "library",
"description": "WebDAV Framework for PHP",
"keywords": ["Framework", "WebDAV", "CalDAV", "CardDAV", "iCalendar"],
"homepage": "http://sabre.io/",
"license" : "BSD-3-Clause",
"authors": [
{
"name": "Evert Pot",
"email": "me@evertpot.com",
"homepage" : "http://evertpot.com/",
"role" : "Developer"
}
],
"require": {
"php": ">=5.4.1",
"sabre/vobject": "^3.3.4",
"sabre/event" : "~2.0",
"sabre/xml" : "~1.0",
"sabre/http" : "~4.0",
"sabre/uri" : "~1.0",
"ext-dom": "*",
"ext-pcre": "*",
"ext-spl": "*",
"ext-simplexml": "*",
"ext-mbstring" : "*",
"ext-ctype" : "*",
"ext-date" : "*",
"ext-iconv" : "*",
"lib-libxml" : ">=2.7.0"
},
"require-dev" : {
"phpunit/phpunit" : "~4.2",
"evert/phpdoc-md" : "~0.1.0",
"sabre/cs" : "~0.0.2"
},
"suggest" : {
"ext-curl" : "*",
"ext-pdo" : "*"
},
"autoload": {
"psr-4" : {
"Sabre\\DAV\\" : "lib/DAV/",
"Sabre\\DAVACL\\" : "lib/DAVACL/",
"Sabre\\CalDAV\\" : "lib/CalDAV/",
"Sabre\\CardDAV\\" : "lib/CardDAV/"
}
},
"support" : {
"forum" : "https://groups.google.com/group/sabredav-discuss",
"source" : "https://github.com/fruux/sabre-dav"
},
"bin" : [
"bin/sabredav",
"bin/naturalselection"
],
"config" : {
"bin-dir" : "./bin"
},
"extra" : {
"branch-alias": {
"dev-master": "3.0.0-dev"
}
}
}

View File

@ -0,0 +1,992 @@
<?php
namespace Sabre\CalDAV;
use DateTimeZone;
use Sabre\DAV;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\MkCol;
use Sabre\DAV\Xml\Property\Href;
use Sabre\DAVACL;
use Sabre\VObject;
use Sabre\HTTP;
use Sabre\Uri;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
/**
* CalDAV plugin
*
* This plugin provides functionality added by CalDAV (RFC 4791)
* It implements new reports, and the MKCALENDAR method.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class Plugin extends DAV\ServerPlugin {
/**
* This is the official CalDAV namespace
*/
const NS_CALDAV = 'urn:ietf:params:xml:ns:caldav';
/**
* This is the namespace for the proprietary calendarserver extensions
*/
const NS_CALENDARSERVER = 'http://calendarserver.org/ns/';
/**
* The hardcoded root for calendar objects. It is unfortunate
* that we're stuck with it, but it will have to do for now
*/
const CALENDAR_ROOT = 'calendars';
/**
* Reference to server object
*
* @var DAV\Server
*/
protected $server;
/**
* The default PDO storage uses a MySQL MEDIUMBLOB for iCalendar data,
* which can hold up to 2^24 = 16777216 bytes. This is plenty. We're
* capping it to 10M here.
*/
protected $maxResourceSize = 10000000;
/**
* Use this method to tell the server this plugin defines additional
* HTTP methods.
*
* This method is passed a uri. It should only return HTTP methods that are
* available for the specified uri.
*
* @param string $uri
* @return array
*/
function getHTTPMethods($uri) {
// The MKCALENDAR is only available on unmapped uri's, whose
// parents extend IExtendedCollection
list($parent, $name) = Uri\split($uri);
$node = $this->server->tree->getNodeForPath($parent);
if ($node instanceof DAV\IExtendedCollection) {
try {
$node->getChild($name);
} catch (DAV\Exception\NotFound $e) {
return ['MKCALENDAR'];
}
}
return [];
}
/**
* Returns the path to a principal's calendar home.
*
* The return url must not end with a slash.
*
* @param string $principalUrl
* @return string
*/
function getCalendarHomeForPrincipal($principalUrl) {
// The default is a bit naive, but it can be overwritten.
list(, $nodeName) = Uri\split($principalUrl);
return self::CALENDAR_ROOT . '/' . $nodeName;
}
/**
* Returns a list of features for the DAV: HTTP header.
*
* @return array
*/
function getFeatures() {
return ['calendar-access', 'calendar-proxy'];
}
/**
* Returns a plugin name.
*
* Using this name other plugins will be able to access other plugins
* using DAV\Server::getPlugin
*
* @return string
*/
function getPluginName() {
return 'caldav';
}
/**
* Returns a list of reports this plugin supports.
*
* This will be used in the {DAV:}supported-report-set property.
* Note that you still need to subscribe to the 'report' event to actually
* implement them
*
* @param string $uri
* @return array
*/
function getSupportedReportSet($uri) {
$node = $this->server->tree->getNodeForPath($uri);
$reports = [];
if ($node instanceof ICalendarObjectContainer || $node instanceof ICalendarObject) {
$reports[] = '{' . self::NS_CALDAV . '}calendar-multiget';
$reports[] = '{' . self::NS_CALDAV . '}calendar-query';
}
if ($node instanceof ICalendar) {
$reports[] = '{' . self::NS_CALDAV . '}free-busy-query';
}
// iCal has a bug where it assumes that sync support is enabled, only
// if we say we support it on the calendar-home, even though this is
// not actually the case.
if ($node instanceof CalendarHome && $this->server->getPlugin('sync')) {
$reports[] = '{DAV:}sync-collection';
}
return $reports;
}
/**
* Initializes the plugin
*
* @param DAV\Server $server
* @return void
*/
function initialize(DAV\Server $server) {
$this->server = $server;
$server->on('method:MKCALENDAR', [$this, 'httpMkCalendar']);
$server->on('report', [$this, 'report']);
$server->on('propFind', [$this, 'propFind']);
$server->on('onHTMLActionsPanel', [$this, 'htmlActionsPanel']);
$server->on('beforeCreateFile', [$this, 'beforeCreateFile']);
$server->on('beforeWriteContent', [$this, 'beforeWriteContent']);
$server->on('afterMethod:GET', [$this, 'httpAfterGET']);
$server->xml->namespaceMap[self::NS_CALDAV] = 'cal';
$server->xml->namespaceMap[self::NS_CALENDARSERVER] = 'cs';
$server->xml->elementMap['{' . self::NS_CALDAV . '}calendar-query'] = 'Sabre\\CalDAV\\Xml\\Request\\CalendarQueryReport';
$server->xml->elementMap['{' . self::NS_CALDAV . '}calendar-multiget'] = 'Sabre\\CalDAV\\Xml\\Request\\CalendarMultiGetReport';
$server->xml->elementMap['{' . self::NS_CALDAV . '}free-busy-query'] = 'Sabre\\CalDAV\\Xml\\Request\\FreeBusyQueryReport';
$server->xml->elementMap['{' . self::NS_CALDAV . '}mkcalendar'] = 'Sabre\\CalDAV\\Xml\\Request\\MkCalendar';
$server->xml->elementMap['{' . self::NS_CALDAV . '}schedule-calendar-transp'] = 'Sabre\\CalDAV\\Xml\\Property\\ScheduleCalendarTransp';
$server->xml->elementMap['{' . self::NS_CALDAV . '}supported-calendar-component-set'] = 'Sabre\\CalDAV\\Xml\\Property\\SupportedCalendarComponentSet';
$server->resourceTypeMapping['\\Sabre\\CalDAV\\ICalendar'] = '{urn:ietf:params:xml:ns:caldav}calendar';
$server->resourceTypeMapping['\\Sabre\\CalDAV\\Principal\\IProxyRead'] = '{http://calendarserver.org/ns/}calendar-proxy-read';
$server->resourceTypeMapping['\\Sabre\\CalDAV\\Principal\\IProxyWrite'] = '{http://calendarserver.org/ns/}calendar-proxy-write';
array_push($server->protectedProperties,
'{' . self::NS_CALDAV . '}supported-calendar-component-set',
'{' . self::NS_CALDAV . '}supported-calendar-data',
'{' . self::NS_CALDAV . '}max-resource-size',
'{' . self::NS_CALDAV . '}min-date-time',
'{' . self::NS_CALDAV . '}max-date-time',
'{' . self::NS_CALDAV . '}max-instances',
'{' . self::NS_CALDAV . '}max-attendees-per-instance',
'{' . self::NS_CALDAV . '}calendar-home-set',
'{' . self::NS_CALDAV . '}supported-collation-set',
'{' . self::NS_CALDAV . '}calendar-data',
// CalendarServer extensions
'{' . self::NS_CALENDARSERVER . '}getctag',
'{' . self::NS_CALENDARSERVER . '}calendar-proxy-read-for',
'{' . self::NS_CALENDARSERVER . '}calendar-proxy-write-for'
);
if ($aclPlugin = $server->getPlugin('acl')) {
$aclPlugin->principalSearchPropertySet['{' . self::NS_CALDAV . '}calendar-user-address-set'] = 'Calendar address';
}
}
/**
* This functions handles REPORT requests specific to CalDAV
*
* @param string $reportName
* @param mixed $report
* @return bool
*/
function report($reportName, $report) {
switch ($reportName) {
case '{' . self::NS_CALDAV . '}calendar-multiget' :
$this->server->transactionType = 'report-calendar-multiget';
$this->calendarMultiGetReport($report);
return false;
case '{' . self::NS_CALDAV . '}calendar-query' :
$this->server->transactionType = 'report-calendar-query';
$this->calendarQueryReport($report);
return false;
case '{' . self::NS_CALDAV . '}free-busy-query' :
$this->server->transactionType = 'report-free-busy-query';
$this->freeBusyQueryReport($report);
return false;
}
}
/**
* This function handles the MKCALENDAR HTTP method, which creates
* a new calendar.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpMkCalendar(RequestInterface $request, ResponseInterface $response) {
$body = $request->getBodyAsString();
$path = $request->getPath();
$properties = [];
if ($body) {
try {
$mkcalendar = $this->server->xml->expect(
'{urn:ietf:params:xml:ns:caldav}mkcalendar',
$body
);
} catch (\Sabre\Xml\ParseException $e) {
throw new BadRequest($e->getMessage(), null, $e);
}
$properties = $mkcalendar->getProperties();
}
// iCal abuses MKCALENDAR since iCal 10.9.2 to create server-stored
// subscriptions. Before that it used MKCOL which was the correct way
// to do this.
//
// If the body had a {DAV:}resourcetype, it means we stumbled upon this
// request, and we simply use it instead of the pre-defined list.
if (isset($properties['{DAV:}resourcetype'])) {
$resourceType = $properties['{DAV:}resourcetype']->getValue();
} else {
$resourceType = ['{DAV:}collection','{urn:ietf:params:xml:ns:caldav}calendar'];
}
$this->server->createCollection($path, new MkCol($resourceType, $properties));
$this->server->httpResponse->setStatus(201);
$this->server->httpResponse->setHeader('Content-Length', 0);
// This breaks the method chain.
return false;
}
/**
* PropFind
*
* This method handler is invoked before any after properties for a
* resource are fetched. This allows us to add in any CalDAV specific
* properties.
*
* @param DAV\PropFind $propFind
* @param DAV\INode $node
* @return void
*/
function propFind(DAV\PropFind $propFind, DAV\INode $node) {
$ns = '{' . self::NS_CALDAV . '}';
if ($node instanceof ICalendarObjectContainer) {
$propFind->handle($ns . 'max-resource-size', $this->maxResourceSize);
$propFind->handle($ns . 'supported-calendar-data', function() {
return new Xml\Property\SupportedCalendarData();
});
$propFind->handle($ns . 'supported-collation-set', function() {
return new Xml\Property\SupportedCollationSet();
});
}
if ($node instanceof DAVACL\IPrincipal) {
$principalUrl = $node->getPrincipalUrl();
$propFind->handle('{' . self::NS_CALDAV . '}calendar-home-set', function() use ($principalUrl) {
$calendarHomePath = $this->getCalendarHomeForPrincipal($principalUrl) . '/';
return new Href($calendarHomePath);
});
// The calendar-user-address-set property is basically mapped to
// the {DAV:}alternate-URI-set property.
$propFind->handle('{' . self::NS_CALDAV . '}calendar-user-address-set', function() use ($node) {
$addresses = $node->getAlternateUriSet();
$addresses[] = $this->server->getBaseUri() . $node->getPrincipalUrl() . '/';
return new Href($addresses, false);
});
// For some reason somebody thought it was a good idea to add
// another one of these properties. We're supporting it too.
$propFind->handle('{' . self::NS_CALENDARSERVER . '}email-address-set', function() use ($node) {
$addresses = $node->getAlternateUriSet();
$emails = [];
foreach ($addresses as $address) {
if (substr($address, 0, 7) === 'mailto:') {
$emails[] = substr($address, 7);
}
}
return new Xml\Property\EmailAddressSet($emails);
});
// These two properties are shortcuts for ical to easily find
// other principals this principal has access to.
$propRead = '{' . self::NS_CALENDARSERVER . '}calendar-proxy-read-for';
$propWrite = '{' . self::NS_CALENDARSERVER . '}calendar-proxy-write-for';
if ($propFind->getStatus($propRead) === 404 || $propFind->getStatus($propWrite) === 404) {
$aclPlugin = $this->server->getPlugin('acl');
$membership = $aclPlugin->getPrincipalMembership($propFind->getPath());
$readList = [];
$writeList = [];
foreach ($membership as $group) {
$groupNode = $this->server->tree->getNodeForPath($group);
$listItem = Uri\split($group)[0] . '/';
// If the node is either ap proxy-read or proxy-write
// group, we grab the parent principal and add it to the
// list.
if ($groupNode instanceof Principal\IProxyRead) {
$readList[] = $listItem;
}
if ($groupNode instanceof Principal\IProxyWrite) {
$writeList[] = $listItem;
}
}
$propFind->set($propRead, new Href($readList));
$propFind->set($propWrite, new Href($writeList));
}
} // instanceof IPrincipal
if ($node instanceof ICalendarObject) {
// The calendar-data property is not supposed to be a 'real'
// property, but in large chunks of the spec it does act as such.
// Therefore we simply expose it as a property.
$propFind->handle('{' . self::NS_CALDAV . '}calendar-data', function() use ($node) {
$val = $node->get();
if (is_resource($val))
$val = stream_get_contents($val);
// Taking out \r to not screw up the xml output
return str_replace("\r", "", $val);
});
}
}
/**
* This function handles the calendar-multiget REPORT.
*
* This report is used by the client to fetch the content of a series
* of urls. Effectively avoiding a lot of redundant requests.
*
* @param CalendarMultiGetReport $report
* @return void
*/
function calendarMultiGetReport($report) {
$needsJson = $report->contentType === 'application/calendar+json';
$timeZones = [];
$propertyList = [];
$paths = array_map(
[$this->server, 'calculateUri'],
$report->hrefs
);
foreach ($this->server->getPropertiesForMultiplePaths($paths, $report->properties) as $uri => $objProps) {
if (($needsJson || $report->expand) && isset($objProps[200]['{' . self::NS_CALDAV . '}calendar-data'])) {
$vObject = VObject\Reader::read($objProps[200]['{' . self::NS_CALDAV . '}calendar-data']);
if ($report->expand) {
// We're expanding, and for that we need to figure out the
// calendar's timezone.
list($calendarPath) = Uri\split($uri);
if (!isset($timeZones[$calendarPath])) {
// Checking the calendar-timezone property.
$tzProp = '{' . self::NS_CALDAV . '}calendar-timezone';
$tzResult = $this->server->getProperties($calendarPath, [$tzProp]);
if (isset($tzResult[$tzProp])) {
// This property contains a VCALENDAR with a single
// VTIMEZONE.
$vtimezoneObj = VObject\Reader::read($tzResult[$tzProp]);
$timeZone = $vtimezoneObj->VTIMEZONE->getTimeZone();
} else {
// Defaulting to UTC.
$timeZone = new DateTimeZone('UTC');
}
$timeZones[$calendarPath] = $timeZone;
}
$vObject->expand($report->expand['start'], $report->expand['end'], $timeZones[$calendarPath]);
}
if ($needsJson) {
$objProps[200]['{' . self::NS_CALDAV . '}calendar-data'] = json_encode($vObject->jsonSerialize());
} else {
$objProps[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize();
}
}
$propertyList[] = $objProps;
}
$prefer = $this->server->getHTTPPrefer();
$this->server->httpResponse->setStatus(207);
$this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8');
$this->server->httpResponse->setHeader('Vary', 'Brief,Prefer');
$this->server->httpResponse->setBody($this->server->generateMultiStatus($propertyList, $prefer['return'] === 'minimal'));
}
/**
* This function handles the calendar-query REPORT
*
* This report is used by clients to request calendar objects based on
* complex conditions.
*
* @param Xml\Request\CalendarQueryReport $report
* @return void
*/
function calendarQueryReport($report) {
$path = $this->server->getRequestUri();
$needsJson = $report->contentType === 'application/calendar+json';
$node = $this->server->tree->getNodeForPath($this->server->getRequestUri());
$depth = $this->server->getHTTPDepth(0);
// The default result is an empty array
$result = [];
$calendarTimeZone = null;
if ($report->expand) {
// We're expanding, and for that we need to figure out the
// calendar's timezone.
$tzProp = '{' . self::NS_CALDAV . '}calendar-timezone';
$tzResult = $this->server->getProperties($path, [$tzProp]);
if (isset($tzResult[$tzProp])) {
// This property contains a VCALENDAR with a single
// VTIMEZONE.
$vtimezoneObj = VObject\Reader::read($tzResult[$tzProp]);
$calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone();
unset($vtimezoneObj);
} else {
// Defaulting to UTC.
$calendarTimeZone = new DateTimeZone('UTC');
}
}
// The calendarobject was requested directly. In this case we handle
// this locally.
if ($depth == 0 && $node instanceof ICalendarObject) {
$requestedCalendarData = true;
$requestedProperties = $report->properties;
if (!in_array('{urn:ietf:params:xml:ns:caldav}calendar-data', $requestedProperties)) {
// We always retrieve calendar-data, as we need it for filtering.
$requestedProperties[] = '{urn:ietf:params:xml:ns:caldav}calendar-data';
// If calendar-data wasn't explicitly requested, we need to remove
// it after processing.
$requestedCalendarData = false;
}
$properties = $this->server->getPropertiesForPath(
$path,
$requestedProperties,
0
);
// This array should have only 1 element, the first calendar
// object.
$properties = current($properties);
// If there wasn't any calendar-data returned somehow, we ignore
// this.
if (isset($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data'])) {
$validator = new CalendarQueryValidator();
$vObject = VObject\Reader::read($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data']);
if ($validator->validate($vObject, $report->filters)) {
// If the client didn't require the calendar-data property,
// we won't give it back.
if (!$requestedCalendarData) {
unset($properties[200]['{urn:ietf:params:xml:ns:caldav}calendar-data']);
} else {
if ($report->expand) {
$vObject->expand($report->expand['start'], $report->expand['end'], $calendarTimeZone);
}
if ($needsJson) {
$properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = json_encode($vObject->jsonSerialize());
} elseif ($report->expand) {
$properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize();
}
}
$result = [$properties];
}
}
}
if ($node instanceof ICalendarObjectContainer && $depth === 0) {
if (strpos($this->server->httpRequest->getHeader('User-Agent'), 'MSFT-') === 0) {
// Microsoft clients incorrectly supplied depth as 0, when it actually
// should have set depth to 1. We're implementing a workaround here
// to deal with this.
//
// This targets at least the following clients:
// Windows 10
// Windows Phone 8, 10
$depth = 1;
} else {
throw new BadRequest('A calendar-query REPORT on a calendar with a Depth: 0 is undefined. Set Depth to 1');
}
}
// If we're dealing with a calendar, the calendar itself is responsible
// for the calendar-query.
if ($node instanceof ICalendarObjectContainer && $depth == 1) {
$nodePaths = $node->calendarQuery($report->filters);
foreach ($nodePaths as $path) {
list($properties) =
$this->server->getPropertiesForPath($this->server->getRequestUri() . '/' . $path, $report->properties);
if (($needsJson || $report->expand)) {
$vObject = VObject\Reader::read($properties[200]['{' . self::NS_CALDAV . '}calendar-data']);
if ($report->expand) {
$vObject->expand($report->expand['start'], $report->expand['end'], $calendarTimeZone);
}
if ($needsJson) {
$properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = json_encode($vObject->jsonSerialize());
} else {
$properties[200]['{' . self::NS_CALDAV . '}calendar-data'] = $vObject->serialize();
}
}
$result[] = $properties;
}
}
$prefer = $this->server->getHTTPPrefer();
$this->server->httpResponse->setStatus(207);
$this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8');
$this->server->httpResponse->setHeader('Vary', 'Brief,Prefer');
$this->server->httpResponse->setBody($this->server->generateMultiStatus($result, $prefer['return'] === 'minimal'));
}
/**
* This method is responsible for parsing the request and generating the
* response for the CALDAV:free-busy-query REPORT.
*
* @param Xml\Request\FreeBusyQueryReport $report
* @return void
*/
protected function freeBusyQueryReport(Xml\Request\FreeBusyQueryReport $report) {
$uri = $this->server->getRequestUri();
$acl = $this->server->getPlugin('acl');
if ($acl) {
$acl->checkPrivileges($uri, '{' . self::NS_CALDAV . '}read-free-busy');
}
$calendar = $this->server->tree->getNodeForPath($uri);
if (!$calendar instanceof ICalendar) {
throw new DAV\Exception\NotImplemented('The free-busy-query REPORT is only implemented on calendars');
}
$tzProp = '{' . self::NS_CALDAV . '}calendar-timezone';
// Figuring out the default timezone for the calendar, for floating
// times.
$calendarProps = $this->server->getProperties($uri, [$tzProp]);
if (isset($calendarProps[$tzProp])) {
$vtimezoneObj = VObject\Reader::read($calendarProps[$tzProp]);
$calendarTimeZone = $vtimezoneObj->VTIMEZONE->getTimeZone();
} else {
$calendarTimeZone = new DateTimeZone('UTC');
}
// Doing a calendar-query first, to make sure we get the most
// performance.
$urls = $calendar->calendarQuery([
'name' => 'VCALENDAR',
'comp-filters' => [
[
'name' => 'VEVENT',
'comp-filters' => [],
'prop-filters' => [],
'is-not-defined' => false,
'time-range' => [
'start' => $report->start,
'end' => $report->end,
],
],
],
'prop-filters' => [],
'is-not-defined' => false,
'time-range' => null,
]);
$objects = array_map(function($url) use ($calendar) {
$obj = $calendar->getChild($url)->get();
return $obj;
}, $urls);
$generator = new VObject\FreeBusyGenerator();
$generator->setObjects($objects);
$generator->setTimeRange($report->start, $report->end);
$generator->setTimeZone($calendarTimeZone);
$result = $generator->getResult();
$result = $result->serialize();
$this->server->httpResponse->setStatus(200);
$this->server->httpResponse->setHeader('Content-Type', 'text/calendar');
$this->server->httpResponse->setHeader('Content-Length', strlen($result));
$this->server->httpResponse->setBody($result);
}
/**
* This method is triggered before a file gets updated with new content.
*
* This plugin uses this method to ensure that CalDAV objects receive
* valid calendar data.
*
* @param string $path
* @param DAV\IFile $node
* @param resource $data
* @param bool $modified Should be set to true, if this event handler
* changed &$data.
* @return void
*/
function beforeWriteContent($path, DAV\IFile $node, &$data, &$modified) {
if (!$node instanceof ICalendarObject)
return;
// We're onyl interested in ICalendarObject nodes that are inside of a
// real calendar. This is to avoid triggering validation and scheduling
// for non-calendars (such as an inbox).
list($parent) = Uri\split($path);
$parentNode = $this->server->tree->getNodeForPath($parent);
if (!$parentNode instanceof ICalendar)
return;
$this->validateICalendar(
$data,
$path,
$modified,
$this->server->httpRequest,
$this->server->httpResponse,
false
);
}
/**
* This method is triggered before a new file is created.
*
* This plugin uses this method to ensure that newly created calendar
* objects contain valid calendar data.
*
* @param string $path
* @param resource $data
* @param DAV\ICollection $parentNode
* @param bool $modified Should be set to true, if this event handler
* changed &$data.
* @return void
*/
function beforeCreateFile($path, &$data, DAV\ICollection $parentNode, &$modified) {
if (!$parentNode instanceof ICalendar)
return;
$this->validateICalendar(
$data,
$path,
$modified,
$this->server->httpRequest,
$this->server->httpResponse,
true
);
}
/**
* Checks if the submitted iCalendar data is in fact, valid.
*
* An exception is thrown if it's not.
*
* @param resource|string $data
* @param string $path
* @param bool $modified Should be set to true, if this event handler
* changed &$data.
* @param RequestInterface $request The http request.
* @param ResponseInterface $response The http response.
* @param bool $isNew Is the item a new one, or an update.
* @return void
*/
protected function validateICalendar(&$data, $path, &$modified, RequestInterface $request, ResponseInterface $response, $isNew) {
// If it's a stream, we convert it to a string first.
if (is_resource($data)) {
$data = stream_get_contents($data);
}
$before = md5($data);
// Converting the data to unicode, if needed.
$data = DAV\StringUtil::ensureUTF8($data);
if ($before !== md5($data)) $modified = true;
try {
// If the data starts with a [, we can reasonably assume we're dealing
// with a jCal object.
if (substr($data, 0, 1) === '[') {
$vobj = VObject\Reader::readJson($data);
// Converting $data back to iCalendar, as that's what we
// technically support everywhere.
$data = $vobj->serialize();
$modified = true;
} else {
$vobj = VObject\Reader::read($data);
}
} catch (VObject\ParseException $e) {
throw new DAV\Exception\UnsupportedMediaType('This resource only supports valid iCalendar 2.0 data. Parse error: ' . $e->getMessage());
}
if ($vobj->name !== 'VCALENDAR') {
throw new DAV\Exception\UnsupportedMediaType('This collection can only support iCalendar objects.');
}
$sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set';
// Get the Supported Components for the target calendar
list($parentPath) = Uri\split($path);
$calendarProperties = $this->server->getProperties($parentPath, [$sCCS]);
if (isset($calendarProperties[$sCCS])) {
$supportedComponents = $calendarProperties[$sCCS]->getValue();
} else {
$supportedComponents = ['VJOURNAL', 'VTODO', 'VEVENT'];
}
$foundType = null;
$foundUID = null;
foreach ($vobj->getComponents() as $component) {
switch ($component->name) {
case 'VTIMEZONE' :
continue 2;
case 'VEVENT' :
case 'VTODO' :
case 'VJOURNAL' :
if (is_null($foundType)) {
$foundType = $component->name;
if (!in_array($foundType, $supportedComponents)) {
throw new Exception\InvalidComponentType('This calendar only supports ' . implode(', ', $supportedComponents) . '. We found a ' . $foundType);
}
if (!isset($component->UID)) {
throw new DAV\Exception\BadRequest('Every ' . $component->name . ' component must have an UID');
}
$foundUID = (string)$component->UID;
} else {
if ($foundType !== $component->name) {
throw new DAV\Exception\BadRequest('A calendar object must only contain 1 component. We found a ' . $component->name . ' as well as a ' . $foundType);
}
if ($foundUID !== (string)$component->UID) {
throw new DAV\Exception\BadRequest('Every ' . $component->name . ' in this object must have identical UIDs');
}
}
break;
default :
throw new DAV\Exception\BadRequest('You are not allowed to create components of type: ' . $component->name . ' here');
}
}
if (!$foundType)
throw new DAV\Exception\BadRequest('iCalendar object must contain at least 1 of VEVENT, VTODO or VJOURNAL');
// We use an extra variable to allow event handles to tell us wether
// the object was modified or not.
//
// This helps us determine if we need to re-serialize the object.
$subModified = false;
$this->server->emit(
'calendarObjectChange',
[
$request,
$response,
$vobj,
$parentPath,
&$subModified,
$isNew
]
);
if ($subModified) {
// An event handler told us that it modified the object.
$data = $vobj->serialize();
// Using md5 to figure out if there was an *actual* change.
if (!$modified && $before !== md5($data)) {
$modified = true;
}
}
}
/**
* This method is used to generate HTML output for the
* DAV\Browser\Plugin. This allows us to generate an interface users
* can use to create new calendars.
*
* @param DAV\INode $node
* @param string $output
* @return bool
*/
function htmlActionsPanel(DAV\INode $node, &$output) {
if (!$node instanceof CalendarHome)
return;
$output .= '<tr><td colspan="2"><form method="post" action="">
<h3>Create new calendar</h3>
<input type="hidden" name="sabreAction" value="mkcol" />
<input type="hidden" name="resourceType" value="{DAV:}collection,{' . self::NS_CALDAV . '}calendar" />
<label>Name (uri):</label> <input type="text" name="name" /><br />
<label>Display name:</label> <input type="text" name="{DAV:}displayname" /><br />
<input type="submit" value="create" />
</form>
</td></tr>';
return false;
}
/**
* This event is triggered after GET requests.
*
* This is used to transform data into jCal, if this was requested.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return void
*/
function httpAfterGet(RequestInterface $request, ResponseInterface $response) {
if (strpos($response->getHeader('Content-Type'), 'text/calendar') === false) {
return;
}
$result = HTTP\Util::negotiate(
$request->getHeader('Accept'),
['text/calendar', 'application/calendar+json']
);
if ($result !== 'application/calendar+json') {
// Do nothing
return;
}
// Transforming.
$vobj = VObject\Reader::read($response->getBody());
$jsonBody = json_encode($vobj->jsonSerialize());
$response->setBody($jsonBody);
$response->setHeader('Content-Type', 'application/calendar+json');
$response->setHeader('Content-Length', strlen($jsonBody));
}
/**
* Returns a bunch of meta-data about the plugin.
*
* Providing this information is optional, and is mainly displayed by the
* Browser plugin.
*
* The description key in the returned array may contain html and will not
* be sanitized.
*
* @return array
*/
function getPluginInfo() {
return [
'name' => $this->getPluginName(),
'description' => 'Adds support for CalDAV (rfc4791)',
'link' => 'http://sabre.io/dav/caldav/',
];
}
}

View File

@ -0,0 +1,545 @@
<?php
namespace Sabre\CardDAV\Backend;
use Sabre\CardDAV;
use Sabre\DAV;
/**
* PDO CardDAV backend
*
* This CardDAV backend uses PDO to store addressbooks
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class PDO extends AbstractBackend implements SyncSupport {
/**
* PDO connection
*
* @var PDO
*/
protected $pdo;
/**
* The PDO table name used to store addressbooks
*/
public $addressBooksTableName = 'addressbooks';
/**
* The PDO table name used to store cards
*/
public $cardsTableName = 'cards';
/**
* The table name that will be used for tracking changes in address books.
*
* @var string
*/
public $addressBookChangesTableName = 'addressbookchanges';
/**
* Sets up the object
*
* @param \PDO $pdo
*/
function __construct(\PDO $pdo) {
$this->pdo = $pdo;
}
/**
* Returns the list of addressbooks for a specific user.
*
* @param string $principalUri
* @return array
*/
function getAddressBooksForUser($principalUri) {
$stmt = $this->pdo->prepare('SELECT id, uri, displayname, principaluri, description, synctoken FROM ' . $this->addressBooksTableName . ' WHERE principaluri = ?');
$stmt->execute([$principalUri]);
$addressBooks = [];
foreach ($stmt->fetchAll() as $row) {
$addressBooks[] = [
'id' => $row['id'],
'uri' => $row['uri'],
'principaluri' => $row['principaluri'],
'{DAV:}displayname' => $row['displayname'],
'{' . CardDAV\Plugin::NS_CARDDAV . '}addressbook-description' => $row['description'],
'{http://calendarserver.org/ns/}getctag' => $row['synctoken'],
'{http://sabredav.org/ns}sync-token' => $row['synctoken'] ? $row['synctoken'] : '0',
];
}
return $addressBooks;
}
/**
* Updates properties for an address book.
*
* The list of mutations is stored in a Sabre\DAV\PropPatch object.
* To do the actual updates, you must tell this object which properties
* you're going to process with the handle() method.
*
* Calling the handle method is like telling the PropPatch object "I
* promise I can handle updating this property".
*
* Read the PropPatch documenation for more info and examples.
*
* @param string $addressBookId
* @param \Sabre\DAV\PropPatch $propPatch
* @return void
*/
function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) {
$supportedProperties = [
'{DAV:}displayname',
'{' . CardDAV\Plugin::NS_CARDDAV . '}addressbook-description',
];
$propPatch->handle($supportedProperties, function($mutations) use ($addressBookId) {
$updates = [];
foreach ($mutations as $property => $newValue) {
switch ($property) {
case '{DAV:}displayname' :
$updates['displayname'] = $newValue;
break;
case '{' . CardDAV\Plugin::NS_CARDDAV . '}addressbook-description' :
$updates['description'] = $newValue;
break;
}
}
$query = 'UPDATE ' . $this->addressBooksTableName . ' SET ';
$first = true;
foreach ($updates as $key => $value) {
if ($first) {
$first = false;
} else {
$query .= ', ';
}
$query .= ' `' . $key . '` = :' . $key . ' ';
}
$query .= ' WHERE id = :addressbookid';
$stmt = $this->pdo->prepare($query);
$updates['addressbookid'] = $addressBookId;
$stmt->execute($updates);
$this->addChange($addressBookId, "", 2);
return true;
});
}
/**
* Creates a new address book
*
* @param string $principalUri
* @param string $url Just the 'basename' of the url.
* @param array $properties
* @return void
*/
function createAddressBook($principalUri, $url, array $properties) {
$values = [
'displayname' => null,
'description' => null,
'principaluri' => $principalUri,
'uri' => $url,
];
foreach ($properties as $property => $newValue) {
switch ($property) {
case '{DAV:}displayname' :
$values['displayname'] = $newValue;
break;
case '{' . CardDAV\Plugin::NS_CARDDAV . '}addressbook-description' :
$values['description'] = $newValue;
break;
default :
throw new DAV\Exception\BadRequest('Unknown property: ' . $property);
}
}
$query = 'INSERT INTO ' . $this->addressBooksTableName . ' (uri, displayname, description, principaluri, synctoken) VALUES (:uri, :displayname, :description, :principaluri, 1)';
$stmt = $this->pdo->prepare($query);
$stmt->execute($values);
return $this->pdo->lastInsertId();
}
/**
* Deletes an entire addressbook and all its contents
*
* @param int $addressBookId
* @return void
*/
function deleteAddressBook($addressBookId) {
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->cardsTableName . ' WHERE addressbookid = ?');
$stmt->execute([$addressBookId]);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->addressBooksTableName . ' WHERE id = ?');
$stmt->execute([$addressBookId]);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->addressBookChangesTableName . ' WHERE addressbookid = ?');
$stmt->execute([$addressBookId]);
}
/**
* Returns all cards for a specific addressbook id.
*
* This method should return the following properties for each card:
* * carddata - raw vcard data
* * uri - Some unique url
* * lastmodified - A unix timestamp
*
* It's recommended to also return the following properties:
* * etag - A unique etag. This must change every time the card changes.
* * size - The size of the card in bytes.
*
* If these last two properties are provided, less time will be spent
* calculating them. If they are specified, you can also ommit carddata.
* This may speed up certain requests, especially with large cards.
*
* @param mixed $addressbookId
* @return array
*/
function getCards($addressbookId) {
$stmt = $this->pdo->prepare('SELECT id, uri, lastmodified, etag, size FROM ' . $this->cardsTableName . ' WHERE addressbookid = ?');
$stmt->execute([$addressbookId]);
$result = [];
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$row['etag'] = '"' . $row['etag'] . '"';
$result[] = $row;
}
return $result;
}
/**
* Returns a specfic card.
*
* The same set of properties must be returned as with getCards. The only
* exception is that 'carddata' is absolutely required.
*
* If the card does not exist, you must return false.
*
* @param mixed $addressBookId
* @param string $cardUri
* @return array
*/
function getCard($addressBookId, $cardUri) {
$stmt = $this->pdo->prepare('SELECT id, carddata, uri, lastmodified, etag, size FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri = ? LIMIT 1');
$stmt->execute([$addressBookId, $cardUri]);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
if (!$result) return false;
$result['etag'] = '"' . $result['etag'] . '"';
return $result;
}
/**
* Returns a list of cards.
*
* This method should work identical to getCard, but instead return all the
* cards in the list as an array.
*
* If the backend supports this, it may allow for some speed-ups.
*
* @param mixed $addressBookId
* @param array $uris
* @return array
*/
function getMultipleCards($addressBookId, array $uris) {
$query = 'SELECT id, uri, lastmodified, etag, size, carddata FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri IN (';
// Inserting a whole bunch of question marks
$query .= implode(',', array_fill(0, count($uris), '?'));
$query .= ')';
$stmt = $this->pdo->prepare($query);
$stmt->execute(array_merge([$addressBookId], $uris));
$result = [];
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$row['etag'] = '"' . $row['etag'] . '"';
$result[] = $row;
}
return $result;
}
/**
* Creates a new card.
*
* The addressbook id will be passed as the first argument. This is the
* same id as it is returned from the getAddressBooksForUser method.
*
* The cardUri is a base uri, and doesn't include the full path. The
* cardData argument is the vcard body, and is passed as a string.
*
* It is possible to return an ETag from this method. This ETag is for the
* newly created resource, and must be enclosed with double quotes (that
* is, the string itself must contain the double quotes).
*
* You should only return the ETag if you store the carddata as-is. If a
* subsequent GET request on the same card does not have the same body,
* byte-by-byte and you did return an ETag here, clients tend to get
* confused.
*
* If you don't return an ETag, you can just return null.
*
* @param mixed $addressBookId
* @param string $cardUri
* @param string $cardData
* @return string|null
*/
function createCard($addressBookId, $cardUri, $cardData) {
$stmt = $this->pdo->prepare('INSERT INTO ' . $this->cardsTableName . ' (carddata, uri, lastmodified, addressbookid, size, etag) VALUES (?, ?, ?, ?, ?, ?)');
$etag = md5($cardData);
$stmt->execute([
$cardData,
$cardUri,
time(),
$addressBookId,
strlen($cardData),
$etag,
]);
$this->addChange($addressBookId, $cardUri, 1);
return '"' . $etag . '"';
}
/**
* Updates a card.
*
* The addressbook id will be passed as the first argument. This is the
* same id as it is returned from the getAddressBooksForUser method.
*
* The cardUri is a base uri, and doesn't include the full path. The
* cardData argument is the vcard body, and is passed as a string.
*
* It is possible to return an ETag from this method. This ETag should
* match that of the updated resource, and must be enclosed with double
* quotes (that is: the string itself must contain the actual quotes).
*
* You should only return the ETag if you store the carddata as-is. If a
* subsequent GET request on the same card does not have the same body,
* byte-by-byte and you did return an ETag here, clients tend to get
* confused.
*
* If you don't return an ETag, you can just return null.
*
* @param mixed $addressBookId
* @param string $cardUri
* @param string $cardData
* @return string|null
*/
function updateCard($addressBookId, $cardUri, $cardData) {
$stmt = $this->pdo->prepare('UPDATE ' . $this->cardsTableName . ' SET carddata = ?, lastmodified = ?, size = ?, etag = ? WHERE uri = ? AND addressbookid =?');
$etag = md5($cardData);
$stmt->execute([
$cardData,
time(),
strlen($cardData),
$etag,
$cardUri,
$addressBookId
]);
$this->addChange($addressBookId, $cardUri, 2);
return '"' . $etag . '"';
}
/**
* Deletes a card
*
* @param mixed $addressBookId
* @param string $cardUri
* @return bool
*/
function deleteCard($addressBookId, $cardUri) {
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->cardsTableName . ' WHERE addressbookid = ? AND uri = ?');
$stmt->execute([$addressBookId, $cardUri]);
$this->addChange($addressBookId, $cardUri, 3);
return $stmt->rowCount() === 1;
}
/**
* The getChanges method returns all the changes that have happened, since
* the specified syncToken in the specified address book.
*
* This function should return an array, such as the following:
*
* [
* 'syncToken' => 'The current synctoken',
* 'added' => [
* 'new.txt',
* ],
* 'modified' => [
* 'updated.txt',
* ],
* 'deleted' => [
* 'foo.php.bak',
* 'old.txt'
* ]
* ];
*
* The returned syncToken property should reflect the *current* syncToken
* of the addressbook, as reported in the {http://sabredav.org/ns}sync-token
* property. This is needed here too, to ensure the operation is atomic.
*
* If the $syncToken argument is specified as null, this is an initial
* sync, and all members should be reported.
*
* The modified property is an array of nodenames that have changed since
* the last token.
*
* The deleted property is an array with nodenames, that have been deleted
* from collection.
*
* The $syncLevel argument is basically the 'depth' of the report. If it's
* 1, you only have to report changes that happened only directly in
* immediate descendants. If it's 2, it should also include changes from
* the nodes below the child collections. (grandchildren)
*
* The $limit argument allows a client to specify how many results should
* be returned at most. If the limit is not specified, it should be treated
* as infinite.
*
* If the limit (infinite or not) is higher than you're willing to return,
* you should throw a Sabre\DAV\Exception\TooMuchMatches() exception.
*
* If the syncToken is expired (due to data cleanup) or unknown, you must
* return null.
*
* The limit is 'suggestive'. You are free to ignore it.
*
* @param string $addressBookId
* @param string $syncToken
* @param int $syncLevel
* @param int $limit
* @return array
*/
function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
// Current synctoken
$stmt = $this->pdo->prepare('SELECT synctoken FROM ' . $this->addressBooksTableName . ' WHERE id = ?');
$stmt->execute([ $addressBookId ]);
$currentToken = $stmt->fetchColumn(0);
if (is_null($currentToken)) return null;
$result = [
'syncToken' => $currentToken,
'added' => [],
'modified' => [],
'deleted' => [],
];
if ($syncToken) {
$query = "SELECT uri, operation FROM " . $this->addressBookChangesTableName . " WHERE synctoken >= ? AND synctoken < ? AND addressbookid = ? ORDER BY synctoken";
if ($limit > 0) $query .= " LIMIT " . (int)$limit;
// Fetching all changes
$stmt = $this->pdo->prepare($query);
$stmt->execute([$syncToken, $currentToken, $addressBookId]);
$changes = [];
// This loop ensures that any duplicates are overwritten, only the
// last change on a node is relevant.
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$changes[$row['uri']] = $row['operation'];
}
foreach ($changes as $uri => $operation) {
switch ($operation) {
case 1:
$result['added'][] = $uri;
break;
case 2:
$result['modified'][] = $uri;
break;
case 3:
$result['deleted'][] = $uri;
break;
}
}
} else {
// No synctoken supplied, this is the initial sync.
$query = "SELECT uri FROM " . $this->cardsTableName . " WHERE addressbookid = ?";
$stmt = $this->pdo->prepare($query);
$stmt->execute([$addressBookId]);
$result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN);
}
return $result;
}
/**
* Adds a change record to the addressbookchanges table.
*
* @param mixed $addressBookId
* @param string $objectUri
* @param int $operation 1 = add, 2 = modify, 3 = delete
* @return void
*/
protected function addChange($addressBookId, $objectUri, $operation) {
$stmt = $this->pdo->prepare('INSERT INTO ' . $this->addressBookChangesTableName . ' (uri, synctoken, addressbookid, operation) SELECT ?, synctoken, ?, ? FROM ' . $this->addressBooksTableName . ' WHERE id = ?');
$stmt->execute([
$objectUri,
$addressBookId,
$operation,
$addressBookId
]);
$stmt = $this->pdo->prepare('UPDATE ' . $this->addressBooksTableName . ' SET synctoken = synctoken + 1 WHERE id = ?');
$stmt->execute([
$addressBookId
]);
}
}

View File

@ -0,0 +1,927 @@
<?php
namespace Sabre\DAV;
use Sabre\DAV\Exception\BadRequest;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Sabre\Xml\ParseException;
/**
* The core plugin provides all the basic features for a WebDAV server.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class CorePlugin extends ServerPlugin {
/**
* Reference to server object.
*
* @var Server
*/
protected $server;
/**
* Sets up the plugin
*
* @param Server $server
* @return void
*/
function initialize(Server $server) {
$this->server = $server;
$server->on('method:GET', [$this, 'httpGet']);
$server->on('method:OPTIONS', [$this, 'httpOptions']);
$server->on('method:HEAD', [$this, 'httpHead']);
$server->on('method:DELETE', [$this, 'httpDelete']);
$server->on('method:PROPFIND', [$this, 'httpPropFind']);
$server->on('method:PROPPATCH', [$this, 'httpPropPatch']);
$server->on('method:PUT', [$this, 'httpPut']);
$server->on('method:MKCOL', [$this, 'httpMkcol']);
$server->on('method:MOVE', [$this, 'httpMove']);
$server->on('method:COPY', [$this, 'httpCopy']);
$server->on('method:REPORT', [$this, 'httpReport']);
$server->on('propPatch', [$this, 'propPatchProtectedPropertyCheck'], 90);
$server->on('propPatch', [$this, 'propPatchNodeUpdate'], 200);
$server->on('propFind', [$this, 'propFind']);
$server->on('propFind', [$this, 'propFindNode'], 120);
$server->on('propFind', [$this, 'propFindLate'], 200);
}
/**
* Returns a plugin name.
*
* Using this name other plugins will be able to access other plugins
* using DAV\Server::getPlugin
*
* @return string
*/
function getPluginName() {
return 'core';
}
/**
* This is the default implementation for the GET method.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpGet(RequestInterface $request, ResponseInterface $response) {
$path = $request->getPath();
$node = $this->server->tree->getNodeForPath($path, 0);
if (!$node instanceof IFile) return;
$body = $node->get();
// Converting string into stream, if needed.
if (is_string($body)) {
$stream = fopen('php://temp', 'r+');
fwrite($stream, $body);
rewind($stream);
$body = $stream;
}
/*
* TODO: getetag, getlastmodified, getsize should also be used using
* this method
*/
$httpHeaders = $this->server->getHTTPHeaders($path);
/* ContentType needs to get a default, because many webservers will otherwise
* default to text/html, and we don't want this for security reasons.
*/
if (!isset($httpHeaders['Content-Type'])) {
$httpHeaders['Content-Type'] = 'application/octet-stream';
}
if (isset($httpHeaders['Content-Length'])) {
$nodeSize = $httpHeaders['Content-Length'];
// Need to unset Content-Length, because we'll handle that during figuring out the range
unset($httpHeaders['Content-Length']);
} else {
$nodeSize = null;
}
$response->addHeaders($httpHeaders);
$range = $this->server->getHTTPRange();
$ifRange = $request->getHeader('If-Range');
$ignoreRangeHeader = false;
// If ifRange is set, and range is specified, we first need to check
// the precondition.
if ($nodeSize && $range && $ifRange) {
// if IfRange is parsable as a date we'll treat it as a DateTime
// otherwise, we must treat it as an etag.
try {
$ifRangeDate = new \DateTime($ifRange);
// It's a date. We must check if the entity is modified since
// the specified date.
if (!isset($httpHeaders['Last-Modified'])) $ignoreRangeHeader = true;
else {
$modified = new \DateTime($httpHeaders['Last-Modified']);
if ($modified > $ifRangeDate) $ignoreRangeHeader = true;
}
} catch (\Exception $e) {
// It's an entity. We can do a simple comparison.
if (!isset($httpHeaders['ETag'])) $ignoreRangeHeader = true;
elseif ($httpHeaders['ETag'] !== $ifRange) $ignoreRangeHeader = true;
}
}
// We're only going to support HTTP ranges if the backend provided a filesize
if (!$ignoreRangeHeader && $nodeSize && $range) {
// Determining the exact byte offsets
if (!is_null($range[0])) {
$start = $range[0];
$end = $range[1] ? $range[1] : $nodeSize - 1;
if ($start >= $nodeSize)
throw new Exception\RequestedRangeNotSatisfiable('The start offset (' . $range[0] . ') exceeded the size of the entity (' . $nodeSize . ')');
if ($end < $start) throw new Exception\RequestedRangeNotSatisfiable('The end offset (' . $range[1] . ') is lower than the start offset (' . $range[0] . ')');
if ($end >= $nodeSize) $end = $nodeSize - 1;
} else {
$start = $nodeSize - $range[1];
$end = $nodeSize - 1;
if ($start < 0) $start = 0;
}
// Streams may advertise themselves as seekable, but still not
// actually allow fseek. We'll manually go forward in the stream
// if fseek failed.
if (!stream_get_meta_data($body)['seekable'] || fseek($body, $start, SEEK_SET) === -1) {
$consumeBlock = 8192;
for ($consumed = 0; $start - $consumed > 0;){
if (feof($body)) throw new Exception\RequestedRangeNotSatisfiable('The start offset (' . $start . ') exceeded the size of the entity (' . $consumed . ')');
$consumed += strlen(fread($body, min($start - $consumed, $consumeBlock)));
}
}
$response->setHeader('Content-Length', $end - $start + 1);
$response->setHeader('Content-Range', 'bytes ' . $start . '-' . $end . '/' . $nodeSize);
$response->setStatus(206);
$response->setBody($body);
} else {
if ($nodeSize) $response->setHeader('Content-Length', $nodeSize);
$response->setStatus(200);
$response->setBody($body);
}
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* HTTP OPTIONS
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpOptions(RequestInterface $request, ResponseInterface $response) {
$methods = $this->server->getAllowedMethods($request->getPath());
$response->setHeader('Allow', strtoupper(implode(', ', $methods)));
$features = ['1', '3', 'extended-mkcol'];
foreach ($this->server->getPlugins() as $plugin) {
$features = array_merge($features, $plugin->getFeatures());
}
$response->setHeader('DAV', implode(', ', $features));
$response->setHeader('MS-Author-Via', 'DAV');
$response->setHeader('Accept-Ranges', 'bytes');
$response->setHeader('Content-Length', '0');
$response->setStatus(200);
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* HTTP HEAD
*
* This method is normally used to take a peak at a url, and only get the
* HTTP response headers, without the body. This is used by clients to
* determine if a remote file was changed, so they can use a local cached
* version, instead of downloading it again
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpHead(RequestInterface $request, ResponseInterface $response) {
// This is implemented by changing the HEAD request to a GET request,
// and dropping the response body.
$subRequest = clone $request;
$subRequest->setMethod('GET');
try {
$this->server->invokeMethod($subRequest, $response, false);
$response->setBody('');
} catch (Exception\NotImplemented $e) {
// Some clients may do HEAD requests on collections, however, GET
// requests and HEAD requests _may_ not be defined on a collection,
// which would trigger a 501.
// This breaks some clients though, so we're transforming these
// 501s into 200s.
$response->setStatus(200);
$response->setBody('');
$response->setHeader('Content-Type', 'text/plain');
$response->setHeader('X-Sabre-Real-Status', $e->getHTTPCode());
}
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* HTTP Delete
*
* The HTTP delete method, deletes a given uri
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return void
*/
function httpDelete(RequestInterface $request, ResponseInterface $response) {
$path = $request->getPath();
if (!$this->server->emit('beforeUnbind', [$path])) return false;
$this->server->tree->delete($path);
$this->server->emit('afterUnbind', [$path]);
$response->setStatus(204);
$response->setHeader('Content-Length', '0');
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* WebDAV PROPFIND
*
* This WebDAV method requests information about an uri resource, or a list of resources
* If a client wants to receive the properties for a single resource it will add an HTTP Depth: header with a 0 value
* If the value is 1, it means that it also expects a list of sub-resources (e.g.: files in a directory)
*
* The request body contains an XML data structure that has a list of properties the client understands
* The response body is also an xml document, containing information about every uri resource and the requested properties
*
* It has to return a HTTP 207 Multi-status status code
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return void
*/
function httpPropFind(RequestInterface $request, ResponseInterface $response) {
$path = $request->getPath();
$requestBody = $request->getBodyAsString();
if (strlen($requestBody)) {
try {
$propFindXml = $this->server->xml->expect('{DAV:}propfind', $requestBody);
} catch (ParseException $e) {
throw new BadRequest($e->getMessage(), null, $e);
}
} else {
$propFindXml = new Xml\Request\PropFind();
$propFindXml->allProp = true;
$propFindXml->properties = [];
}
$depth = $this->server->getHTTPDepth(1);
// The only two options for the depth of a propfind is 0 or 1 - as long as depth infinity is not enabled
if (!$this->server->enablePropfindDepthInfinity && $depth != 0) $depth = 1;
$newProperties = $this->server->getPropertiesForPath($path, $propFindXml->properties, $depth);
// This is a multi-status response
$response->setStatus(207);
$response->setHeader('Content-Type', 'application/xml; charset=utf-8');
$response->setHeader('Vary', 'Brief,Prefer');
// Normally this header is only needed for OPTIONS responses, however..
// iCal seems to also depend on these being set for PROPFIND. Since
// this is not harmful, we'll add it.
$features = ['1', '3', 'extended-mkcol'];
foreach ($this->server->getPlugins() as $plugin) {
$features = array_merge($features, $plugin->getFeatures());
}
$response->setHeader('DAV', implode(', ', $features));
$prefer = $this->server->getHTTPPrefer();
$minimal = $prefer['return'] === 'minimal';
$data = $this->server->generateMultiStatus($newProperties, $minimal);
$response->setBody($data);
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* WebDAV PROPPATCH
*
* This method is called to update properties on a Node. The request is an XML body with all the mutations.
* In this XML body it is specified which properties should be set/updated and/or deleted
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpPropPatch(RequestInterface $request, ResponseInterface $response) {
$path = $request->getPath();
try {
$propPatch = $this->server->xml->expect('{DAV:}propertyupdate', $request->getBody());
} catch (ParseException $e) {
throw new BadRequest($e->getMessage(), null, $e);
}
$newProperties = $propPatch->properties;
$result = $this->server->updateProperties($path, $newProperties);
$prefer = $this->server->getHTTPPrefer();
$response->setHeader('Vary', 'Brief,Prefer');
if ($prefer['return'] === 'minimal') {
// If return-minimal is specified, we only have to check if the
// request was succesful, and don't need to return the
// multi-status.
$ok = true;
foreach ($result as $prop => $code) {
if ((int)$code > 299) {
$ok = false;
}
}
if ($ok) {
$response->setStatus(204);
return false;
}
}
$response->setStatus(207);
$response->setHeader('Content-Type', 'application/xml; charset=utf-8');
// Reorganizing the result for generateMultiStatus
$multiStatus = [];
foreach ($result as $propertyName => $code) {
if (isset($multiStatus[$code])) {
$multiStatus[$code][$propertyName] = null;
} else {
$multiStatus[$code] = [$propertyName => null];
}
}
$multiStatus['href'] = $path;
$response->setBody(
$this->server->generateMultiStatus([$multiStatus])
);
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* HTTP PUT method
*
* This HTTP method updates a file, or creates a new one.
*
* If a new resource was created, a 201 Created status code should be returned. If an existing resource is updated, it's a 204 No Content
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpPut(RequestInterface $request, ResponseInterface $response) {
$body = $request->getBodyAsStream();
$path = $request->getPath();
// Intercepting Content-Range
if ($request->getHeader('Content-Range')) {
/*
An origin server that allows PUT on a given target resource MUST send
a 400 (Bad Request) response to a PUT request that contains a
Content-Range header field.
Reference: http://tools.ietf.org/html/rfc7231#section-4.3.4
*/
throw new Exception\BadRequest('Content-Range on PUT requests are forbidden.');
}
// Intercepting the Finder problem
if (($expected = $request->getHeader('X-Expected-Entity-Length')) && $expected > 0) {
/*
Many webservers will not cooperate well with Finder PUT requests,
because it uses 'Chunked' transfer encoding for the request body.
The symptom of this problem is that Finder sends files to the
server, but they arrive as 0-length files in PHP.
If we don't do anything, the user might think they are uploading
files successfully, but they end up empty on the server. Instead,
we throw back an error if we detect this.
The reason Finder uses Chunked, is because it thinks the files
might change as it's being uploaded, and therefore the
Content-Length can vary.
Instead it sends the X-Expected-Entity-Length header with the size
of the file at the very start of the request. If this header is set,
but we don't get a request body we will fail the request to
protect the end-user.
*/
// Only reading first byte
$firstByte = fread($body, 1);
if (strlen($firstByte) !== 1) {
throw new Exception\Forbidden('This server is not compatible with OS/X finder. Consider using a different WebDAV client or webserver.');
}
// The body needs to stay intact, so we copy everything to a
// temporary stream.
$newBody = fopen('php://temp', 'r+');
fwrite($newBody, $firstByte);
stream_copy_to_stream($body, $newBody);
rewind($newBody);
$body = $newBody;
}
if ($this->server->tree->nodeExists($path)) {
$node = $this->server->tree->getNodeForPath($path);
// If the node is a collection, we'll deny it
if (!($node instanceof IFile)) throw new Exception\Conflict('PUT is not allowed on non-files.');
if (!$this->server->updateFile($path, $body, $etag)) {
return false;
}
$response->setHeader('Content-Length', '0');
if ($etag) $response->setHeader('ETag', $etag);
$response->setStatus(204);
} else {
$etag = null;
// If we got here, the resource didn't exist yet.
if (!$this->server->createFile($path, $body, $etag)) {
// For one reason or another the file was not created.
return false;
}
$response->setHeader('Content-Length', '0');
if ($etag) $response->setHeader('ETag', $etag);
$response->setStatus(201);
}
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* WebDAV MKCOL
*
* The MKCOL method is used to create a new collection (directory) on the server
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpMkcol(RequestInterface $request, ResponseInterface $response) {
$requestBody = $request->getBodyAsString();
$path = $request->getPath();
if ($requestBody) {
$contentType = $request->getHeader('Content-Type');
if (strpos($contentType, 'application/xml') !== 0 && strpos($contentType, 'text/xml') !== 0) {
// We must throw 415 for unsupported mkcol bodies
throw new Exception\UnsupportedMediaType('The request body for the MKCOL request must have an xml Content-Type');
}
try {
$mkcol = $this->server->xml->expect('{DAV:}mkcol', $requestBody);
} catch (\Sabre\Xml\ParseException $e) {
throw new Exception\BadRequest($e->getMessage(), null, $e);
}
$properties = $mkcol->getProperties();
if (!isset($properties['{DAV:}resourcetype']))
throw new Exception\BadRequest('The mkcol request must include a {DAV:}resourcetype property');
$resourceType = $properties['{DAV:}resourcetype']->getValue();
unset($properties['{DAV:}resourcetype']);
} else {
$properties = [];
$resourceType = ['{DAV:}collection'];
}
$mkcol = new MkCol($resourceType, $properties);
$result = $this->server->createCollection($path, $mkcol);
if (is_array($result)) {
$response->setStatus(207);
$response->setHeader('Content-Type', 'application/xml; charset=utf-8');
$response->setBody(
$this->server->generateMultiStatus([$result])
);
} else {
$response->setHeader('Content-Length', '0');
$response->setStatus(201);
}
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* WebDAV HTTP MOVE method
*
* This method moves one uri to a different uri. A lot of the actual request processing is done in getCopyMoveInfo
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpMove(RequestInterface $request, ResponseInterface $response) {
$path = $request->getPath();
$moveInfo = $this->server->getCopyAndMoveInfo($request);
if ($moveInfo['destinationExists']) {
if (!$this->server->emit('beforeUnbind', [$moveInfo['destination']])) return false;
}
if (!$this->server->emit('beforeUnbind', [$path])) return false;
if (!$this->server->emit('beforeBind', [$moveInfo['destination']])) return false;
if (!$this->server->emit('beforeMove', [$path, $moveInfo['destination']])) return false;
if ($moveInfo['destinationExists']) {
$this->server->tree->delete($moveInfo['destination']);
$this->server->emit('afterUnbind', [$moveInfo['destination']]);
}
$this->server->tree->move($path, $moveInfo['destination']);
// Its important afterMove is called before afterUnbind, because it
// allows systems to transfer data from one path to another.
// PropertyStorage uses this. If afterUnbind was first, it would clean
// up all the properties before it has a chance.
$this->server->emit('afterMove', [$path, $moveInfo['destination']]);
$this->server->emit('afterUnbind', [$path]);
$this->server->emit('afterBind', [$moveInfo['destination']]);
// If a resource was overwritten we should send a 204, otherwise a 201
$response->setHeader('Content-Length', '0');
$response->setStatus($moveInfo['destinationExists'] ? 204 : 201);
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* WebDAV HTTP COPY method
*
* This method copies one uri to a different uri, and works much like the MOVE request
* A lot of the actual request processing is done in getCopyMoveInfo
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpCopy(RequestInterface $request, ResponseInterface $response) {
$path = $request->getPath();
$copyInfo = $this->server->getCopyAndMoveInfo($request);
if ($copyInfo['destinationExists']) {
if (!$this->server->emit('beforeUnbind', [$copyInfo['destination']])) return false;
$this->server->tree->delete($copyInfo['destination']);
}
if (!$this->server->emit('beforeBind', [$copyInfo['destination']])) return false;
$this->server->tree->copy($path, $copyInfo['destination']);
$this->server->emit('afterBind', [$copyInfo['destination']]);
// If a resource was overwritten we should send a 204, otherwise a 201
$response->setHeader('Content-Length', '0');
$response->setStatus($copyInfo['destinationExists'] ? 204 : 201);
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* HTTP REPORT method implementation
*
* Although the REPORT method is not part of the standard WebDAV spec (it's from rfc3253)
* It's used in a lot of extensions, so it made sense to implement it into the core.
*
* @param RequestInterface $request
* @param ResponseInterface $response
* @return bool
*/
function httpReport(RequestInterface $request, ResponseInterface $response) {
$path = $request->getPath();
$result = $this->server->xml->parse(
$request->getBody(),
$request->getUrl(),
$rootElementName
);
if ($this->server->emit('report', [$rootElementName, $result, $path])) {
// If emit returned true, it means the report was not supported
throw new Exception\ReportNotSupported();
}
// Sending back false will interupt the event chain and tell the server
// we've handled this method.
return false;
}
/**
* This method is called during property updates.
*
* Here we check if a user attempted to update a protected property and
* ensure that the process fails if this is the case.
*
* @param string $path
* @param PropPatch $propPatch
* @return void
*/
function propPatchProtectedPropertyCheck($path, PropPatch $propPatch) {
// Comparing the mutation list to the list of propetected properties.
$mutations = $propPatch->getMutations();
$protected = array_intersect(
$this->server->protectedProperties,
array_keys($mutations)
);
if ($protected) {
$propPatch->setResultCode($protected, 403);
}
}
/**
* This method is called during property updates.
*
* Here we check if a node implements IProperties and let the node handle
* updating of (some) properties.
*
* @param string $path
* @param PropPatch $propPatch
* @return void
*/
function propPatchNodeUpdate($path, PropPatch $propPatch) {
// This should trigger a 404 if the node doesn't exist.
$node = $this->server->tree->getNodeForPath($path);
if ($node instanceof IProperties) {
$node->propPatch($propPatch);
}
}
/**
* This method is called when properties are retrieved.
*
* Here we add all the default properties.
*
* @param PropFind $propFind
* @param INode $node
* @return void
*/
function propFind(PropFind $propFind, INode $node) {
$propFind->handle('{DAV:}getlastmodified', function() use ($node) {
$lm = $node->getLastModified();
if ($lm) {
return new Xml\Property\GetLastModified($lm);
}
});
if ($node instanceof IFile) {
$propFind->handle('{DAV:}getcontentlength', [$node, 'getSize']);
$propFind->handle('{DAV:}getetag', [$node, 'getETag']);
$propFind->handle('{DAV:}getcontenttype', [$node, 'getContentType']);
}
if ($node instanceof IQuota) {
$quotaInfo = null;
$propFind->handle('{DAV:}quota-used-bytes', function() use (&$quotaInfo, $node) {
$quotaInfo = $node->getQuotaInfo();
return $quotaInfo[0];
});
$propFind->handle('{DAV:}quota-available-bytes', function() use (&$quotaInfo, $node) {
if (!$quotaInfo) {
$quotaInfo = $node->getQuotaInfo();
}
return $quotaInfo[1];
});
}
$propFind->handle('{DAV:}supported-report-set', function() use ($propFind) {
$reports = [];
foreach ($this->server->getPlugins() as $plugin) {
$reports = array_merge($reports, $plugin->getSupportedReportSet($propFind->getPath()));
}
return new Xml\Property\SupportedReportSet($reports);
});
$propFind->handle('{DAV:}resourcetype', function() use ($node) {
return new Xml\Property\ResourceType($this->server->getResourceTypeForNode($node));
});
$propFind->handle('{DAV:}supported-method-set', function() use ($propFind) {
return new Xml\Property\SupportedMethodSet(
$this->server->getAllowedMethods($propFind->getPath())
);
});
}
/**
* Fetches properties for a node.
*
* This event is called a bit later, so plugins have a chance first to
* populate the result.
*
* @param PropFind $propFind
* @param INode $node
* @return void
*/
function propFindNode(PropFind $propFind, INode $node) {
if ($node instanceof IProperties && $propertyNames = $propFind->get404Properties()) {
$nodeProperties = $node->getProperties($propertyNames);
foreach ($propertyNames as $propertyName) {
if (array_key_exists($propertyName, $nodeProperties)) {
$propFind->set($propertyName, $nodeProperties[$propertyName], 200);
}
}
}
}
/**
* This method is called when properties are retrieved.
*
* This specific handler is called very late in the process, because we
* want other systems to first have a chance to handle the properties.
*
* @param PropFind $propFind
* @param INode $node
* @return void
*/
function propFindLate(PropFind $propFind, INode $node) {
$propFind->handle('{http://calendarserver.org/ns/}getctag', function() use ($propFind) {
// If we already have a sync-token from the current propFind
// request, we can re-use that.
$val = $propFind->get('{http://sabredav.org/ns}sync-token');
if ($val) return $val;
$val = $propFind->get('{DAV:}sync-token');
if ($val && is_scalar($val)) {
return $val;
}
if ($val && $val instanceof Xml\Property\Href) {
return substr($val->getHref(), strlen(Sync\Plugin::SYNCTOKEN_PREFIX));
}
// If we got here, the earlier two properties may simply not have
// been part of the earlier request. We're going to fetch them.
$result = $this->server->getProperties($propFind->getPath(), [
'{http://sabredav.org/ns}sync-token',
'{DAV:}sync-token',
]);
if (isset($result['{http://sabredav.org/ns}sync-token'])) {
return $result['{http://sabredav.org/ns}sync-token'];
}
if (isset($result['{DAV:}sync-token'])) {
$val = $result['{DAV:}sync-token'];
if (is_scalar($val)) {
return $val;
} elseif ($val instanceof Xml\Property\Href) {
return substr($val->getHref(), strlen(Sync\Plugin::SYNCTOKEN_PREFIX));
}
}
});
}
/**
* Returns a bunch of meta-data about the plugin.
*
* Providing this information is optional, and is mainly displayed by the
* Browser plugin.
*
* The description key in the returned array may contain html and will not
* be sanitized.
*
* @return array
*/
function getPluginInfo() {
return [
'name' => $this->getPluginName(),
'description' => 'The Core plugin provides a lot of the basic functionality required by WebDAV, such as a default implementation for all HTTP and WebDAV methods.',
'link' => null,
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Sabre\DAV;
/**
* This class contains the SabreDAV version constants.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class Version {
/**
* Full version number
*/
const VERSION = '3.0.9';
}