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:
parent
bd19bbb926
commit
2d373416d8
|
@ -342,6 +342,19 @@ class Checker {
|
||||||
throw new InvalidSignatureException('Signature could not get verified.');
|
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
|
// Compare the list of files which are not identical
|
||||||
$currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
|
$currentInstanceHashes = $this->generateHashes($this->getFolderIterator($basePath), $basePath);
|
||||||
$differencesA = array_diff($expectedHashes, $currentInstanceHashes);
|
$differencesA = array_diff($expectedHashes, $currentInstanceHashes);
|
||||||
|
|
|
@ -39,6 +39,11 @@ class ExcludeFoldersByPathFilterIterator extends \RecursiveFilterIterator {
|
||||||
rtrim($root . '/apps', '/'),
|
rtrim($root . '/apps', '/'),
|
||||||
rtrim($root . '/assets', '/'),
|
rtrim($root . '/assets', '/'),
|
||||||
rtrim($root . '/lost+found', '/'),
|
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', '');
|
$customDataDir = \OC::$server->getConfig()->getSystemValue('datadirectory', '');
|
||||||
if($customDataDir !== '') {
|
if($customDataDir !== '') {
|
||||||
|
|
|
@ -31,6 +31,7 @@ namespace OC;
|
||||||
use OC\Hooks\BasicEmitter;
|
use OC\Hooks\BasicEmitter;
|
||||||
use OC\Hooks\Emitter;
|
use OC\Hooks\Emitter;
|
||||||
use OC\Repair\AssetCache;
|
use OC\Repair\AssetCache;
|
||||||
|
use OC\Repair\BrokenUpdaterRepair;
|
||||||
use OC\Repair\CleanTags;
|
use OC\Repair\CleanTags;
|
||||||
use OC\Repair\Collation;
|
use OC\Repair\Collation;
|
||||||
use OC\Repair\DropOldJobs;
|
use OC\Repair\DropOldJobs;
|
||||||
|
@ -114,6 +115,7 @@ class Repair extends BasicEmitter {
|
||||||
new RemoveGetETagEntries(\OC::$server->getDatabaseConnection()),
|
new RemoveGetETagEntries(\OC::$server->getDatabaseConnection()),
|
||||||
new UpdateOutdatedOcsIds(\OC::$server->getConfig()),
|
new UpdateOutdatedOcsIds(\OC::$server->getConfig()),
|
||||||
new RepairInvalidShares(\OC::$server->getConfig(), \OC::$server->getDatabaseConnection()),
|
new RepairInvalidShares(\OC::$server->getConfig(), \OC::$server->getDatabaseConnection()),
|
||||||
|
new BrokenUpdaterRepair(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/',
|
||||||
|
];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
]);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
];
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue