diff --git a/.php_cs b/.php_cs new file mode 100644 index 0000000..926df8c --- /dev/null +++ b/.php_cs @@ -0,0 +1,10 @@ +in(__DIR__) +; + +return Symfony\CS\Config\Config::create() + ->fixers(array('-psr0')) + ->finder($finder) +; \ No newline at end of file diff --git a/Controller/SabreDavController.php b/Controller/SabreDavController.php index 28e4d76..0960373 100644 --- a/Controller/SabreDavController.php +++ b/Controller/SabreDavController.php @@ -14,12 +14,12 @@ use Sabre\DAV\Server; use Secotrust\Bundle\SabreDavBundle\SabreDav\HttpRequest; use Secotrust\Bundle\SabreDavBundle\SabreDav\HttpResponse; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\Routing\RouterInterface; /** - * Class SabreDavController + * Class SabreDavController. */ class SabreDavController { @@ -29,20 +29,16 @@ class SabreDavController private $dav; /** - * @var EventDispatcherInterface - */ - private $dispatcher; - - /** - * Constructor + * Constructor. * - * @param Server $dav - * @param EventDispatcherInterface $dispatcher + * @param Server $dav + * @param RouterInterface $router */ - public function __construct(Server $dav, EventDispatcherInterface $dispatcher) + public function __construct(Server $dav, RouterInterface $router, $base_uri = '') { + $router->getContext()->setBaseUrl($router->getContext()->getBaseUrl() . $base_uri); $this->dav = $dav; - $this->dispatcher = $dispatcher; // TODO needed? + $this->dav->setBaseUri($router->generate('secotrust_sabre_dav', array())); } /** diff --git a/DependencyInjection/Compiler/CollectionPass.php b/DependencyInjection/Compiler/CollectionPass.php index 7812c6a..ec588b5 100644 --- a/DependencyInjection/Compiler/CollectionPass.php +++ b/DependencyInjection/Compiler/CollectionPass.php @@ -16,7 +16,7 @@ use Symfony\Component\DependencyInjection\Reference; /** - * Class CollectionPass + * Class CollectionPass. */ class CollectionPass implements CompilerPassInterface { diff --git a/DependencyInjection/Compiler/PluginPass.php b/DependencyInjection/Compiler/PluginPass.php index 99ec3de..f3b09ea 100644 --- a/DependencyInjection/Compiler/PluginPass.php +++ b/DependencyInjection/Compiler/PluginPass.php @@ -17,7 +17,7 @@ use Symfony\Component\Filesystem\Filesystem; /** - * Class PluginPass + * Class PluginPass. */ class PluginPass implements CompilerPassInterface { diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index afafee6..c24515a 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -15,36 +15,67 @@ use Symfony\Component\Config\Definition\ConfigurationInterface; /** - * Class Configuration + * Class Configuration. */ class Configuration implements ConfigurationInterface { /** - * {@inheritDoc} + * {@inheritdoc} */ public function getConfigTreeBuilder() { $treeBuilder = new TreeBuilder(); $rootNode = $treeBuilder->root('secotrust_sabre_dav'); - // TODO needs "little" improvement ;-) + $default_base_uri = '/app_dev.php/remote'; $rootNode ->children() - ->scalarNode('root_dir')->example('%kernel.root_dir%/../web/dav')->defaultNull()->end() - ->scalarNode('base_uri')->example('/app_dev.php/dav/')->isRequired()->end() + ->scalarNode('root_dir') + ->example('%kernel.root_dir%/../web/dav/') + ->end() + ->scalarNode('browser_logo') + ->example('%kernel.root_dir%/../web/logo/sabredav.png') + ->defaultValue('') + ->end() + ->scalarNode('favicon') + ->example('%kernel.root_dir%/../web/logo/favicon.ico') + ->defaultValue('') + ->end() + ->scalarNode('security_service') + ->example('sabredav.security_service') + ->defaultValue('') + ->end() + ->scalarNode('base_uri') + ->example($default_base_uri) + ->end() ->arrayNode('plugins') ->addDefaultsIfNotSet() ->children() ->booleanNode('acl')->defaultFalse()->end() ->booleanNode('auth')->defaultFalse()->end() ->booleanNode('browser')->defaultFalse()->end() - ->booleanNode('caldav')->defaultFalse()->end() ->booleanNode('lock')->defaultTrue()->end() ->booleanNode('temp')->defaultTrue()->end() ->booleanNode('mount')->defaultFalse()->end() ->booleanNode('patch')->defaultFalse()->end() ->booleanNode('content_type')->defaultFalse()->end() + ->booleanNode('webdav')->defaultFalse()->end() + ->booleanNode('principal')->defaultFalse()->end() + ->booleanNode('carddav')->defaultFalse()->end() + ->booleanNode('caldav')->defaultFalse()->end() + ->end() + ->end() + ->arrayNode('settings') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('cards_class')->defaultValue('')->end() + ->scalarNode('addressbooks_class')->defaultValue('')->end() + ->scalarNode('calendarobjects_class')->defaultValue('')->end() + ->scalarNode('calendar_class')->defaultValue('')->end() + ->scalarNode('principals_class')->defaultValue('')->end() + ->scalarNode('principalgroups_class')->defaultValue('')->end() + ->end() ->end() ->end() ->end(); diff --git a/DependencyInjection/SecotrustSabreDavExtension.php b/DependencyInjection/SecotrustSabreDavExtension.php index fd1bbb1..db58cbe 100644 --- a/DependencyInjection/SecotrustSabreDavExtension.php +++ b/DependencyInjection/SecotrustSabreDavExtension.php @@ -17,12 +17,12 @@ use Symfony\Component\DependencyInjection\Loader; /** - * Class SecotrustSabreDavExtension + * Class SecotrustSabreDavExtension. */ class SecotrustSabreDavExtension extends Extension { /** - * {@inheritDoc} + * {@inheritdoc} */ public function load(array $configs, ContainerBuilder $container) { @@ -32,20 +32,40 @@ public function load(array $configs, ContainerBuilder $container) $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load('services/services.xml'); - if ($config['base_uri']) { + if (isset($config['base_uri'])) { $container->getDefinition('secotrust.sabredav.server')->addMethodCall('setBaseUri', array($config['base_uri'])); + $container->setParameter('secotrust.sabredav.base_uri', $config['base_uri']); } - if ($config['root_dir']) { - $container->getDefinition('secotrust.sabredav_root')->replaceArgument(0, $config['root_dir']); - } else { - $container->getDefinition('secotrust.sabredav_root')->clearTag('secotrust.sabredav.collection'); - } - + // load all plugins foreach ($config['plugins'] as $plugin => $enabled) { if ($enabled) { $loader->load(sprintf('services/plugins/%s.xml', $plugin)); } } + + // no root dir is set, but webdav plugin is active: throw exception + if (!empty($config['root_dir']) && $config['plugins']['webdav']) { + //replace argument + $container->getDefinition('secotrust.sabredav_root')->replaceArgument(0, $config['root_dir']); + } + + // add logo to browser-plugin + if ($config['plugins']['browser']) { + $container->setParameter('secotrust.sabredav.browser_plugin.logo', $config['browser_logo']); + $container->setParameter('secotrust.sabredav.browser_plugin.favicon', $config['favicon']); + } + + // add security-service-class + if ($config['security_service']) { + $container->setParameter('secotrust.sabredav.acl.securityService', $config['security_service']); + } + + $container->setParameter('secotrust.cards_class', $config['settings']['cards_class']); + $container->setParameter('secotrust.addressbooks_class', $config['settings']['addressbooks_class']); + $container->setParameter('secotrust.calendarobjects_class', $config['settings']['calendarobjects_class']); + $container->setParameter('secotrust.calendar_class', $config['settings']['calendar_class']); + $container->setParameter('secotrust.principals_class', $config['settings']['principals_class']); + $container->setParameter('secotrust.principalgroups_class', $config['settings']['principalgroups_class']); } } diff --git a/Entity/AddressbookInterface.php b/Entity/AddressbookInterface.php new file mode 100644 index 0000000..365e248 --- /dev/null +++ b/Entity/AddressbookInterface.php @@ -0,0 +1,114 @@ + + * and use the desired setting, if you want to delete the cards
+ * - either only from the current Addressbook
+ * - or from the cards-table too + * + * @param \Secotrust\Bundle\SabreDavBundle\Entity\CardInterface $card + * + * @return bool + */ + public function removeCard(CardInterface $card); + + /** + * Remove all Cards from current Addressbook. + * + * Possible solution: use $cards = $this->getCards() and + * $this->removeCard($card) to remove all cards + */ + public function removeAllCards(); +} diff --git a/Entity/CardInterface.php b/Entity/CardInterface.php new file mode 100644 index 0000000..97147f2 --- /dev/null +++ b/Entity/CardInterface.php @@ -0,0 +1,88 @@ +getVCard()); + */ + public function getETag(); +} diff --git a/Entity/PrincipalInterface.php b/Entity/PrincipalInterface.php new file mode 100644 index 0000000..4c6d723 --- /dev/null +++ b/Entity/PrincipalInterface.php @@ -0,0 +1,60 @@ + - + secotrust.sabredav.controller:execAction + /?.* + diff --git a/Resources/config/services/plugins/acl.xml b/Resources/config/services/plugins/acl.xml index 6433203..c557111 100644 --- a/Resources/config/services/plugins/acl.xml +++ b/Resources/config/services/plugins/acl.xml @@ -5,12 +5,30 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - Sabre\DAVACL\Plugin + Secotrust\Bundle\SabreDavBundle\SabreDav\AclPlugin + Secotrust\Bundle\SabreDavBundle\SabreDav\Acl\SecurityManager + true + true + principals + + + + %sabredav.acl.hideNodesFromListings% + + + %sabredav.acl.accessToNodesWithoutACL% + + + %sabredav.acl.defaultUsernamePath% + + + + diff --git a/Resources/config/services/plugins/auth.xml b/Resources/config/services/plugins/auth.xml index 6417561..dd59029 100644 --- a/Resources/config/services/plugins/auth.xml +++ b/Resources/config/services/plugins/auth.xml @@ -7,15 +7,17 @@ Secotrust\Bundle\SabreDavBundle\SabreDav\AuthBackend Sabre\DAV\Auth\Plugin + SabreDAV - + + %secotrust.sabredav.auth.realm% - SabreDAV + %secotrust.sabredav.auth.realm% diff --git a/Resources/config/services/plugins/browser.xml b/Resources/config/services/plugins/browser.xml index 8a34d5f..87cbd8f 100644 --- a/Resources/config/services/plugins/browser.xml +++ b/Resources/config/services/plugins/browser.xml @@ -5,12 +5,19 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - Sabre\DAV\Browser\Plugin + Secotrust\Bundle\SabreDavBundle\SabreDav\BrowserPlugin + + %secotrust.sabredav.browser_plugin.logo% + %secotrust.sabredav.browser_plugin.favicon% + false + + %secotrust.sabredav.browser.config% + diff --git a/Resources/config/services/plugins/caldav.xml b/Resources/config/services/plugins/caldav.xml index 43d2f8a..33aa0b2 100644 --- a/Resources/config/services/plugins/caldav.xml +++ b/Resources/config/services/plugins/caldav.xml @@ -5,12 +5,22 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + Secotrust\Bundle\SabreDavBundle\SabreDav\CalDavBackend Sabre\CalDAV\Plugin + Sabre\CalDAV\CalendarRoot + + + + + + + + diff --git a/Resources/config/services/plugins/carddav.xml b/Resources/config/services/plugins/carddav.xml new file mode 100644 index 0000000..34846da --- /dev/null +++ b/Resources/config/services/plugins/carddav.xml @@ -0,0 +1,29 @@ + + + + + + Secotrust\Bundle\SabreDavBundle\SabreDav\CardDavBackend + Sabre\CardDAV\Plugin + Sabre\CardDAV\AddressBookRoot + + + + + + + + + + + + + + + + + + + diff --git a/Resources/config/services/plugins/notifications.xml b/Resources/config/services/plugins/notifications.xml new file mode 100644 index 0000000..a6761eb --- /dev/null +++ b/Resources/config/services/plugins/notifications.xml @@ -0,0 +1,16 @@ + + + + + + Sabre\CalDAV\Notifications\Plugin + + + + + + + + diff --git a/Resources/config/services/plugins/principal.xml b/Resources/config/services/plugins/principal.xml new file mode 100644 index 0000000..1d7feb3 --- /dev/null +++ b/Resources/config/services/plugins/principal.xml @@ -0,0 +1,25 @@ + + + + + + Secotrust\Bundle\SabreDavBundle\SabreDav\PrincipalBackend + Sabre\DAVACL\Plugin + Sabre\DAVACL\PrincipalCollection + + + + + + + + + + + + + + + diff --git a/Resources/config/services/plugins/schedule.xml b/Resources/config/services/plugins/schedule.xml new file mode 100644 index 0000000..0a31be7 --- /dev/null +++ b/Resources/config/services/plugins/schedule.xml @@ -0,0 +1,16 @@ + + + + + + Sabre\CalDAV\Schedule\Plugin + + + + + + + + diff --git a/Resources/config/services/plugins/webdav.xml b/Resources/config/services/plugins/webdav.xml new file mode 100644 index 0000000..f85ce5e --- /dev/null +++ b/Resources/config/services/plugins/webdav.xml @@ -0,0 +1,17 @@ + + + + + + Sabre\DAV\FS\Directory + + + + + + + + + diff --git a/Resources/config/services/services.xml b/Resources/config/services/services.xml index 3493065..0892cf2 100644 --- a/Resources/config/services/services.xml +++ b/Resources/config/services/services.xml @@ -7,20 +7,22 @@ Secotrust\Bundle\SabreDavBundle\Controller\SabreDavController Sabre\DAV\Server - Sabre\DAV\FS\Directory + + + + + + - + + %secotrust.sabredav.base_uri% - - - - - + diff --git a/Resources/doc/index.md b/Resources/doc/index.md index 212f0a0..eb88f8c 100644 --- a/Resources/doc/index.md +++ b/Resources/doc/index.md @@ -1,3 +1,83 @@ # SecotrustSabreDavBundle # This bundle is still WIP. + +## Setup +First add this bundle to your composer dependencies: + +`> composer require secotrust/sabredav-bundle dev-master` + +Then register it in your AppKernel.php. + +```php +class AppKernel extends Kernel +{ + public function registerBundles() + { + $bundles = array( + new Secotrust\Bundle\SabreDavBundle\SecotrustSabreDavBundle(), + // ... +``` + +Add DAV routes. + +```yaml +# app/config/routing.yml +dav: + resource: "@SecotrustSabreDavBundle/Resources/config/routing.xml" + prefix: dav +``` + +Define a service to use for file access tagged with `secotrust.sabredav.collection`. + +```yaml +# app/config/services.yml +services: + gaufrette.adapter: + class: Gaufrette\Adapter\Local + arguments: + - "%kernel.root_dir%/../var/uploads" + gaufrette.filesystem: + class: Gaufrette\Filesystem + arguments: + - @gaufrette.adapter + gaufrette.dav.collection: + class: Secotrust\Bundle\SabreDavBundle\SabreDav\Gaufrette\Collection + arguments: + - @gaufrette.filesystem + tags: + - { name: secotrust.sabredav.collection } +``` + +## Add principal Collection + +```yaml +# app/config/config.yml +secotrust_sabre_dav: + plugins: + #... + principal: true + settings: + principals_class: Symfony\Component\Security\Core\User\User +``` + +## DAV-Clients + +Full List of supported SabreDav Clients: [http://sabre.io/dav/clients/](http://sabre.io/dav/clients/) + +### Thunderbird ++ SogoConnector: [http://www.sogo.nu/downloads/frontends.html](http://www.sogo.nu/downloads/frontends.html) + +### Microsoft Outlook / Windows 8 ++ [http://german.evomailserver.com/download.php](http://german.evomailserver.com/download.php): EVO Kollaborateur (Untested), ++ [https://help.atmail.com/hc/en-us/articles/200907874-Syncing-Outlook-Contacts-and-Calendars-with-Atmail-DavSync](https://help.atmail.com/hc/en-us/articles/200907874-Syncing-Outlook-Contacts-and-Calendars-with-Atmail-DavSync) ++ [http://forum.xda-developers.com/showthread.php?t=2478215](http://forum.xda-developers.com/showthread.php?t=2478215) ++ [http://www.outlookdav.com/](http://www.outlookdav.com/): no freeware + +### Mac OS X >= 10.8 / iOS >=7: ++ native implementation + +### Android Smartphones ++ [http://dmfs.org/carddav/](http://dmfs.org/carddav/) ++ [http://dmfs.org/calcav/](http://dmfs.org/caldav/) + diff --git a/SabreDav/Acl/SecurityManager.php b/SabreDav/Acl/SecurityManager.php new file mode 100644 index 0000000..3f4fd40 --- /dev/null +++ b/SabreDav/Acl/SecurityManager.php @@ -0,0 +1,61 @@ +container = $container; + $this->token = $container->get('security.token_storage')->getToken(); + $this->authorizationChecker = $container->get('security.authorization_checker'); + } + + /** + * returns the ACL list in the following format:
+ * return array('read', 'write', 'delete');. + * + * consider: + * null will be returned to tell the AclPlugin to use the default Node-Acl (e.g. if no ACL was found for this entry) + * + * an empty array will be returned, if the user has no permissions for the current object. + * + * @param string $username + * @param $objectClass + * @param $objectIdentifier + * @param null $groupIdentifier + */ + public function getACL($username, $objectClass, $objectIdentifier, $groupIdentifier = null) + { + return; + } +} diff --git a/SabreDav/AclPlugin.php b/SabreDav/AclPlugin.php new file mode 100644 index 0000000..a523077 --- /dev/null +++ b/SabreDav/AclPlugin.php @@ -0,0 +1,219 @@ +authChecker = $authChecker; + $this->container = $container; + } + + /** + * By default nodes that are inaccessible by the user, can still be seen + * in directory listings (PROPFIND on parent with Depth: 1). + * + * @param bool $flag + */ + public function setHideNodesFromListings($flag = false) + { + $this->hideNodesFromListings = (bool) $flag; + } + + /** + * By default ACL is only enforced for nodes that have ACL support (the + * ones that implement IACL). For any other node, access is + * always granted. + * + * To override this behaviour you can turn this setting off. This is useful + * if you plan to fully support ACL in the entire tree. + * + * @param bool $flag + */ + public function setAccessToNodesWithoutACL($flag = true) + { + $this->allowAccessToNodesWithoutACL = (bool) $flag; + } + + /** + * This string is prepended to the username of the currently logged in + * user. This allows the plugin to determine the principal path based on + * the username. + * + * @param string $usernamePath + */ + public function setDefaultUsernamePath($usernamePath = 'principals') + { + $this->defaultUsernamePath = $usernamePath; + } + + /** + * add a principal to the admin-list to automatically receive {DAV:}all privileges. + * + * @param string $principal + * + * @return bool + */ + public function addAdminPrincipal($principal) + { + if (strpos($principal, $this->defaultUsernamePath.'/') !== 0) { + $principal = $this->defaultUsernamePath.'/'.$principal; + } + + if (!in_array($principal, $this->adminPrincipals)) { + $this->adminPrincipals[] = $principal; + + return true; + } + + return false; + } + + /** + * remove principal from admin-list. + * + * @param string $principal + * + * @return bool + */ + public function removeAdminPrincipal($principal) + { + if (strpos($principal, $this->defaultUsernamePath.'/') !== 0) { + $principal = $this->defaultUsernamePath.'/'.$principal; + } + + if (false !== ($key = \array_search($principal, $this->adminPrincipals))) { + unset($this->adminPrincipals[$key]); + + return true; + } + + return false; + } + + /** + * Returns the full ACL list. + * + * Either a uri or a DAV\INode may be passed. + * + * null will be returned if the node doesn't support ACLs. + * + * @param string|\Sabre\DAV\INode $node + * + * @return array + */ + public function getACL($node) + { + if (is_string($node)) { + $node = $this->server->tree->getNodeForPath($node); + } + + if (!$node instanceof IACL) { + return; + } + + $username = $this->server->httpRequest->getCurrentUsername(); + $acl = array(); + + $this->davSecurity = $this->container->get('secotrust.sabredav_acl_securityManager'); + + if (!is_null($this->davSecurity) && ( + $node instanceof \Sabre\CalDAV\Calendar || + $node instanceof \Sabre\CalDAV\CalendarObject || + $node instanceof \Sabre\CardDAV\AddressBook || + $node instanceof \Sabre\CardDAV\Card + )) { + $objectClass = ''; + $objectIdentifier = array('name' => $node->getName()); + + if ($node instanceof \Sabre\CardDAV\AddressBook) { + $objectClass = $this->container->getParameter('secotrust.addressbooks_class'); + $objectIdentifier = $node->getProperties(['id']); + $this->groupNode = $objectIdentifier; + } elseif ($node instanceof \Sabre\CardDAV\Calendar) { + $objectClass = $this->container->getParameter('secotrust.calendar_class'); + $this->groupNode = $objectIdentifier; + } elseif ($node instanceof \Sabre\CardDAV\CalendarObject) { + $objectClass = $this->container->getParameter('secotrust.calendarobject_class'); + } elseif ($node instanceof \Sabre\CardDAV\Card) { + $objectClass = $this->container->getParameter('secotrust.cards_class'); + } + + // load the permission-list from the davSecurity-Service + $permissionList = $this->davSecurity->getACL( + $username, $objectClass, $objectIdentifier, $this->groupNode + ); + + if ($permissionList === null) { + // use the "default" ACL from the current node + $acl = $node->getACL(); + } else { + // write permissions to DAV-ACL + foreach ($permissionList as $permission) { + $acl[] = array( + 'privilege' => '{DAV:}'.$permission, + 'principal' => $this->defaultUsernamePath.'/'.$username, + 'protected' => true, + ); + } + } + } elseif (!($node instanceof \Sabre\DAVACL\Principal && $node->getName() !== $username)) { + // get node-acl; if node is a principal-node + // and the name is not like the current username, don't display the node-acl + $acl = $node->getACL(); + } + + // add admin-privileges for all adminPrincipals + foreach ($this->adminPrincipals as $adminPrincipal) { + $acl[] = array( + 'principal' => $adminPrincipal, + 'privilege' => '{DAV:}all', + 'protected' => true, + ); + } + + return $acl; + } +} diff --git a/SabreDav/Auth/BasicAuth.php b/SabreDav/Auth/BasicAuth.php new file mode 100644 index 0000000..a72dd81 --- /dev/null +++ b/SabreDav/Auth/BasicAuth.php @@ -0,0 +1,102 @@ +user_manager = $user_manager; + parent::__construct($realm, $request, $response); + } + + /** + * find username in the user-manager. + * + * @param string $username + * + * @return \FOS\UserBundle\Model\UserInterface + */ + private function getUser($username) + { + $user = $this->user_manager->findUserByUsername($username); + + return $user; + } + + /** + * Return user-credentials; returned password is encoded via "security.encoder_factory". + * + * @param \Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface $encoder_service + * + * @return array|bool + */ + public function getCredentials(EncoderFactoryInterface $encoder_service = null) + { + if (($user = $this->request->getRawServerValue('PHP_AUTH_USER')) && ($pass = $this->request->getRawServerValue('PHP_AUTH_PW'))) { + $credentials = array($user, $pass); + } else { + + // Most other webservers + $auth = $this->request->getHeader('Authorization'); + + // Apache could prefix environment variables with REDIRECT_ when urls + // are passed through mod_rewrite + if (!$auth) { + $auth = $this->request->getRawServerValue('REDIRECT_HTTP_AUTHORIZATION'); + } + + if (!$auth) { + return false; + } + + if (strpos(strtolower($auth), 'basic') !== 0) { + return false; + } + + $credentials = explode(':', base64_decode(substr($auth, 6)), 2); + } + + $user = $this->getUser($credentials[0]); + + if (!$user) { + return false; + } + + if ($encoder_service === null) { + // don't return password, because it isn't encoded + return array($credentials[0], ''); + } + + $encoder = $encoder_service->getEncoder($user); + $encoded_pass = $encoder->encodePassword($credentials[1], $user->getSalt()); + + return array($credentials[0], $encoded_pass); + } +} diff --git a/SabreDav/AuthBackend.php b/SabreDav/AuthBackend.php index b2b4ff1..696f1e7 100644 --- a/SabreDav/AuthBackend.php +++ b/SabreDav/AuthBackend.php @@ -13,43 +13,231 @@ use Sabre\DAV\Auth\Backend\BackendInterface; use Sabre\DAV\Exception; -use Sabre\DAV\Server; -use Symfony\Component\Security\Core\SecurityContextInterface; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\EventDispatcher\Event; class AuthBackend implements BackendInterface { /** - * @var SecurityContextInterface + * @var ContainerInterface */ - private $context; + private $container; /** - * Constructor + * @var \FOS\UserBundle\Model\UserManagerInterface + */ + private $user_manager; + + /** + * @var \Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface + */ + private $token_storage; + + /** + * @var \Symfony\Component\Serializer\Encoder\EncoderInterface + */ + private $encoder_service; + + /** + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + private $dispatcher; + + /** + * @var string + */ + protected $currentUser; + + /** + * Authentication Realm. + * + * The realm is often displayed by browser clients when showing the + * authentication dialog. + * + * @var string + */ + protected $realm = 'SabreDAV'; + + /** + * This is the prefix that will be used to generate principal urls. + * + * @var string + */ + protected $principalPrefix = 'principals/'; + + /** + * Constructor. * - * @param SecurityContextInterface $context + * @param ContainerInterface $container + * @param $realm */ - public function __construct(SecurityContextInterface $context) + public function __construct(ContainerInterface $container, $realm) { - $this->context = $context; + $this->container = $container; + $this->realm = $realm; + + $this->user_manager = $this->container->get('fos_user.user_manager'); + $this->token_storage = $this->container->get('security.token_storage'); + $this->encoder_service = $this->container->get('security.encoder_factory'); + $this->dispatcher = $this->container->get('event_dispatcher'); } /** - * @inheritdoc + * Checks if username and password are valid. (Checked by the FOSUserManager) + * Returns. + * + * @param $username + * @param $passwordHash + * + * @return bool + */ + public function validateUserPass($username, $passwordHash) + { + $user = $this->user_manager->findUserByUsername($username); + + if (is_null($user)) { + return false; + } + + if ($passwordHash === $user->getPassword()) { + // $this->userLoginAction($user, $passwordHash); + return true; + } + + return false; + } + + /** + * add the symfony-login "manually". + * + * use the symfony token-storage for the generated UsernamePasswordToken + * to access the (logged in) user later (e.g. to check for roles or permissions) + * + * the given $passwordHash must match the encrypted password in the user-object + * + * before and after the generation/setting of the token, the events "secotrust.user_login.before" + * and "secotrust.user_login.after" are called, if some EventListeners are configured + * + * @param \FOS\UserBundle\Model\UserInterface $user + * @param $passwordHash */ - public function authenticate(Server $server, $realm) + private function userLoginAction(\FOS\UserBundle\Model\UserInterface $user, $passwordHash) { - if (null === $this->context->getToken()) { - throw new Exception('The security token is NULL'); + // call the pre-login-event + $event = new Event(); + $this->dispatcher->dispatch('secotrust.user_login.before', $event); + + if ($user->getPassword() !== $passwordHash) { + // stop the login-action, when the password doesn't match + return; } - return $this->context->getToken()->isAuthenticated(); + $token = new UsernamePasswordToken($user, null, 'secured_area', $user->getRoles()); + $this->token_storage->setToken($token); + + // call the post-login-event + $event = new Event(); + $this->dispatcher->dispatch('secotrust.user_login.after', $event); } /** - * @inheritdoc + * {@inheritdoc} */ public function getCurrentUser() { - return $this->context->getToken()->getUsername(); + return $this->currentUser; + } + + /** + * When this method is called, the backend must check if authentication was + * successful. + * + * The returned value must be one of the following + * + * [true, "principals/username"] + * [false, "reason for failure"] + * + * If authentication was successful, it's expected that the authentication + * backend returns a so-called principal url. + * + * Examples of a principal url: + * + * principals/admin + * principals/user1 + * principals/users/joe + * principals/uid/123457 + * + * If you don't use WebDAV ACL (RFC3744) we recommend that you simply + * return a string such as: + * + * principals/users/[username] + * + * @param RequestInterface $request + * @param ResponseInterface $response + * + * @return array + * + * @throws Exception + */ + public function check(RequestInterface $request, ResponseInterface $response) + { + $auth = new Auth\BasicAuth($this->realm, $request, $response, $this->user_manager); + $userpass = $auth->getCredentials($this->encoder_service); + + // No username was given + if ($userpass === false) { + return [false, "No 'Authorization' header found. Either the client didn't send one, or the server is mis-configured"]; + } + + // Authenticates the user + if (!$this->validateUserPass($userpass[0], $userpass[1])) { + return [false, 'Username or password was incorrect']; + } + + $this->currentUser = $userpass[0]; + $request->setCurrentUsername($this->currentUser); + + return [true, $this->principalPrefix.$userpass[0]]; + } + + /** + * This method is called when a user could not be authenticated, and + * authentication was required for the current request. + * + * This gives you the opportunity to set authentication headers. The 401 + * status code will already be set. + * + * In this case of Basic Auth, this would for example mean that the + * following header needs to be set: + * + * $response->addHeader('WWW-Authenticate', 'Basic realm=SabreDAV'); + * + * Keep in mind that in the case of multiple authentication backends, other + * WWW-Authenticate headers may already have been set, and you'll want to + * append your own WWW-Authenticate header instead of overwriting the + * existing one. + * + * @param RequestInterface $request + * @param ResponseInterface $response + */ + public function challenge(RequestInterface $request, ResponseInterface $response) + { + $auth = new Auth\BasicAuth($this->realm, $request, $response, $this->user_manager); + $userpass = $auth->getCredentials($this->encoder_service); + + if (!$userpass) { + $auth->requireLogin(); + } + + // Authenticates the user + if (!$this->validateUserPass($userpass[0], $userpass[1])) { + $auth->requireLogin(); + } + + $this->currentUser = $userpass[0]; + $request->setCurrentUsername($this->currentUser); } } diff --git a/SabreDav/BrowserPlugin.php b/SabreDav/BrowserPlugin.php new file mode 100644 index 0000000..18e4227 --- /dev/null +++ b/SabreDav/BrowserPlugin.php @@ -0,0 +1,68 @@ + $value) { + $this->config[$key] = $value; + } + } + + /** + * @param string $key + * + * @return string + */ + public function getBrowserConfig($key) + { + if (isset($this->config[$key])) { + return $this->config[$key]; + } + + return; + } + + /** + * This method returns a local pathname to an asset. + * + * The logo and favicon can be overwritten + * + * @param string $assetName + * + * @return string + */ + protected function getLocalAssetPath($assetName) + { + // load path to logo from parameters + if ($assetName === 'sabredav.png' && $this->getBrowserConfig('browser_logo')) { + return $this->getBrowserConfig('browser_logo'); + } elseif ($assetName === 'favicon.ico' && $this->getBrowserConfig('favicon')) { + return $this->getBrowserConfig('favicon'); + } + + return parent::getLocalAssetPath($assetName); + } +} diff --git a/SabreDav/CalDavBackend.php b/SabreDav/CalDavBackend.php new file mode 100644 index 0000000..c14f853 --- /dev/null +++ b/SabreDav/CalDavBackend.php @@ -0,0 +1,339 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Secotrust\Bundle\SabreDavBundle\SabreDav; + +use Sabre\CalDAV\Backend\BackendInterface; +use Sabre\DAV\Exception; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; + +class CalDavBackend implements BackendInterface +{ + /** + * @var ContainerInterface + */ + private $_em; + + /** + * @var string + */ + private $calendar_class; + + /** + * @var string + */ + private $calendarobjects_class; + + /** + * Constructor + * + * @param ContainerInterface $container + */ + public function __construct(ContainerInterface $container) + { + $this->_em = $container->get('doctrine')->getManager(); + + $this->calendar_class = $container->getParameter('secotrust.calendar_class'); + $this->calendarobjects_class = $container->getParameter('secotrust.calendarobjects_class'); + } + + /** + * Returns a list of calendars for a principal. + * + * Every project is an array with the following keys: + * * id, a unique id that will be used by other functions to modify the + * calendar. This can be the same as the uri or a database key. + * * uri, which the basename of the uri with which the calendar is + * accessed. + * * principaluri. The owner of the calendar. Almost always the same as + * principalUri passed to this method. + * + * Furthermore it can contain webdav properties in clark notation. A very + * common one is '{DAV:}displayname'. + * + * @param string $principalUri + * @return array + */ + public function getCalendarsForUser($principalUri) + { + return array($principalUri); + } + + /** + * Creates a new calendar for a principal. + * + * If the creation was a success, an id must be returned that can be used to reference + * this calendar in other methods, such as updateCalendar. + * + * @param string $principalUri + * @param string $calendarUri + * @param array $properties + * @return void + */ + public function createCalendar($principalUri, $calendarUri, array $properties) + { + return; + } + + /** + * Updates properties for a calendar. + * + * The mutations array uses the propertyName in clark-notation as key, + * and the array value for the property value. In the case a property + * should be deleted, the property value will be null. + * + * This method must be atomic. If one property cannot be changed, the + * entire operation must fail. + * + * If the operation was successful, true can be returned. + * If the operation failed, false can be returned. + * + * Deletion of a non-existent property is always successful. + * + * Lastly, it is optional to return detailed information about any + * failures. In this case an array should be returned with the following + * structure: + * + * array( + * 403 => array( + * '{DAV:}displayname' => null, + * ), + * 424 => array( + * '{DAV:}owner' => null, + * ) + * ) + * + * In this example it was forbidden to update {DAV:}displayname. + * (403 Forbidden), which in turn also caused {DAV:}owner to fail + * (424 Failed Dependency) because the request needs to be atomic. + * + * @param mixed $calendarId + * @param $calendarId + * @param \Sabre\DAV\PropPatch $propPatch + * @return bool|array + */ + public function updateCalendar($calendarId, \Sabre\DAV\PropPatch $propPatch) + { + return true; + } + + /** + * Delete a calendar and all it's objects + * + * @param mixed $calendarId + * @return void + */ + public function deleteCalendar($calendarId) + { + return; + } + + /** + * Returns all calendar objects within a calendar. + * + * Every item contains an array with the following keys: + * * id - unique identifier which will be used for subsequent updates + * * calendardata - The iCalendar-compatible calendar data + * * uri - a unique key which will be used to construct the uri. This can be any arbitrary string. + * * lastmodified - a timestamp of the last modification time + * * etag - An arbitrary string, surrounded by double-quotes. (e.g.: + * ' "abcdef"') + * * calendarid - The calendarid as it was passed to this function. + * * size - The size of the calendar objects, in bytes. + * + * Note that the etag is optional, but it's highly encouraged to return for + * speed reasons. + * + * The calendardata is also optional. If it's not returned + * 'getCalendarObject' will be called later, which *is* expected to return + * calendardata. + * + * If neither etag or size are specified, the calendardata will be + * used/fetched to determine these numbers. If both are specified the + * amount of times this is needed is reduced by a great degree. + * + * @param mixed $calendarId + * @return array + */ + public function getCalendarObjects($calendarId) + { + return array($calendarId); + } + + /** + * Returns information from a single calendar object, based on it's object + * uri. + * + * The returned array must have the same keys as getCalendarObjects. The + * 'calendardata' object is required here though, while it's not required + * for getCalendarObjects. + * + * This method must return null if the object did not exist. + * + * @param mixed $calendarId + * @param string $objectUri + * @return array|null + */ + public function getCalendarObject($calendarId, $objectUri) + { + return array($calendarId); + } + + /** + * Returns a list of calendar objects. + * + * This method should work identical to getCalendarObject, but instead + * return all the calendar objects in the list as an array. + * + * If the backend supports this, it may allow for some speed-ups. + * + * @param mixed $calendarId + * @param array $uris + * @return array + */ + function getMultipleCalendarObjects($calendarId, array $uris) + { + return array($calendarId); + } + + /** + * Creates a new calendar object. + * + * It is possible return an etag from this function, which will be used in + * the response to this PUT request. Note that the ETag must be surrounded + * by double-quotes. + * + * However, you should only really return this ETag if you don't mangle the + * calendar-data. If the result of a subsequent GET to this object is not + * the exact same as this request body, you should omit the ETag. + * + * @param mixed $calendarId + * @param string $objectUri + * @param string $calendarData + * @return string|null + */ + public function createCalendarObject($calendarId, $objectUri, $calendarData) + { + return null; + } + + /** + * Updates an existing calendarobject, based on it's uri. + * + * It is possible return an etag from this function, which will be used in + * the response to this PUT request. Note that the ETag must be surrounded + * by double-quotes. + * + * However, you should only really return this ETag if you don't mangle the + * calendar-data. If the result of a subsequent GET to this object is not + * the exact same as this request body, you should omit the ETag. + * + * @param mixed $calendarId + * @param string $objectUri + * @param string $calendarData + * @return string|null + */ + public function updateCalendarObject($calendarId, $objectUri, $calendarData) + { + return null; + } + + /** + * Deletes an existing calendar object. + * + * @param mixed $calendarId + * @param string $objectUri + * @return void + */ + public function deleteCalendarObject($calendarId, $objectUri) + { + return null; + } + + /** + * Performs a calendar-query on the contents of this calendar. + * + * The calendar-query is defined in RFC4791 : CalDAV. Using the + * calendar-query it is possible for a client to request a specific set of + * object, based on contents of iCalendar properties, date-ranges and + * iCalendar component types (VTODO, VEVENT). + * + * This method should just return a list of (relative) urls that match this + * query. + * + * The list of filters are specified as an array. The exact array is + * documented by Sabre\CalDAV\CalendarQueryParser. + * + * Note that it is extremely likely that getCalendarObject for every path + * returned from this method will be called almost immediately after. You + * may want to anticipate this to speed up these requests. + * + * This method provides a default implementation, which parses *all* the + * iCalendar objects in the specified calendar. + * + * This default may well be good enough for personal use, and calendars + * that aren't very large. But if you anticipate high usage, big calendars + * or high loads, you are strongly adviced to optimize certain paths. + * + * The best way to do so is override this method and to optimize + * specifically for 'common filters'. + * + * Requests that are extremely common are: + * * requests for just VEVENTS + * * requests for just VTODO + * * requests with a time-range-filter on either VEVENT or VTODO. + * + * ..and combinations of these requests. It may not be worth it to try to + * handle every possible situation and just rely on the (relatively + * easy to use) CalendarQueryValidator to handle the rest. + * + * Note that especially time-range-filters may be difficult to parse. A + * time-range filter specified on a VEVENT must for instance also handle + * recurrence rules correctly. + * A good example of how to interprete all these filters can also simply + * be found in Sabre\CalDAV\CalendarQueryFilter. This class is as correct + * as possible, so it gives you a good idea on what type of stuff you need + * to think of. + * + * @param mixed $calendarId + * @param array $filters + * @return array + */ + public function calendarQuery($calendarId, array $filters) + { + return array($calendarId); + } + + /** + * Searches through all of a users calendars and calendar objects to find + * an object with a specific UID. + * + * This method should return the path to this object, relative to the + * calendar home, so this path usually only contains two parts: + * + * calendarpath/objectpath.ics + * + * If the uid is not found, return null. + * + * This method should only consider * objects that the principal owns, so + * any calendars owned by other principals that also appear in this + * collection should be ignored. + * + * @param string $principalUri + * @param string $uid + * @return string|null + */ + function getCalendarObjectByUID($principalUri, $uid) + { + return null; + } +} diff --git a/SabreDav/CardDavBackend.php b/SabreDav/CardDavBackend.php new file mode 100644 index 0000000..aea5129 --- /dev/null +++ b/SabreDav/CardDavBackend.php @@ -0,0 +1,536 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Secotrust\Bundle\SabreDavBundle\SabreDav; + +use Sabre\CardDAV\Backend\AbstractBackend; +use Sabre\CardDAV\Backend\SyncSupport; +use Sabre\CardDAV; +use Sabre\DAV\Exception\BadRequest; +use Secotrust\Bundle\SabreDavBundle\Entity\CardInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +class CardDavBackend extends AbstractBackend implements SyncSupport +{ + /** + * @var \Doctrine\ORM\EntityManager + */ + private $_em; + + /** + * @var string + */ + private $addressbooks_class; + + /** + * @var string + */ + private $cards_class; + + /** + * Create array with Card-Data. + * + * @param CardInterface $entity + * @param bool $show_id + * + * @return array|bool + */ + private function getCardArray($entity, $show_id = false) + { + if (!($entity instanceof CardInterface)) { + return false; + } + + $card = array( + 'id' => $entity->getId(), + 'carddata' => $entity->getVCard(), + 'uri' => $entity->getVCardUid().'.vcf', + 'lastmodified' => $entity->getLastmodified(), + 'size' => strlen($entity->getVCard()), + 'etag' => $entity->getETag(), + ); + + if ($show_id === false) { + unset($card['id']); + } + + return $card; + } + + /** + * Constructor. + * + * @param ContainerInterface $container + */ + public function __construct(ContainerInterface $container) + { + $this->_em = $container->get('doctrine')->getManager(); + + $this->addressbooks_class = $container->getParameter('secotrust.addressbooks_class'); + $this->cards_class = $container->getParameter('secotrust.cards_class'); + } + + /** + * Returns the list of addressbooks for a specific user. + * + * Every addressbook should have the following properties: + * id - an arbitrary unique id + * uri - the 'basename' part of the url + * principaluri - Same as the passed parameter + * + * Any additional clark-notation property may be passed besides this. Some + * common ones are : + * {DAV:}displayname + * {urn:ietf:params:xml:ns:carddav}addressbook-description + * {http://calendarserver.org/ns/}getctag + * + * @param string $principalUri + * + * @return array + */ + public function getAddressBooksForUser($principalUri) + { + $addressBooks = array(); + + $entities = $this->_em->getRepository($this->addressbooks_class)->findAllPrincipalAddressbooks($principalUri); + + foreach ($entities as $entity) { + $addressBooks[] = array( + 'id' => $entity->getId(), + 'uri' => $entity->getUriLabel(), + 'principaluri' => $principalUri, + '{DAV:}displayname' => $entity->getLabel(), + '{'.CardDAV\Plugin::NS_CARDDAV.'}addressbook-description' => $entity->getDescription(), + '{http://calendarserver.org/ns/}getctag' => $entity->getSyncToken(), + '{http://sabredav.org/ns}sync-token' => $entity->getSyncToken(), + ); + } + + 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 + */ + public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch) + { + $supportedProperties = [ + '{DAV:}displayname', + '{'.CardDAV\Plugin::NS_CARDDAV.'}addressbook-description', + ]; + + $addressbook = $this->_em->getRepository($this->addressbooks_class)->find($addressBookId); + + if (!$addressbook) { + return; + } + + $propPatch->handle($supportedProperties, function ($mutations) use ($addressbook) { + + $updates = []; + foreach ($mutations as $property => $newValue) { + switch ($property) { + case '{DAV:}displayname' : + $updates['setLabel'] = $newValue; + break; + case '{'.CardDAV\Plugin::NS_CARDDAV.'}addressbook-description' : + $updates['setDescription'] = $newValue; + break; + } + } + + foreach ($updates as $setter => $value) { + if (method_exists($addressbook, $setter)) { + $addressbook->$setter($value); + } + } + + $this->_em->persist($addressbook); + $this->_em->flush(); + + return true; + }); + } + + /** + * Creates a new address book. + * + * @param string $principalUri + * @param string $url Just the 'basename' of the url. + * @param array $properties + * + * @throws BadRequest + */ + public function createAddressBook($principalUri, $url, array $properties) + { + $values = array( + 'setLabel' => null, + 'setDescription' => null, + 'principaluri' => $principalUri, + 'uri' => $url, + ); + + foreach ($properties as $property => $newValue) { + switch ($property) { + case '{DAV:}displayname' : + $values['setLabel'] = $newValue; + break; + case '{'.CardDAV\Plugin::NS_CARDDAV.'}addressbook-description' : + $values['setDescription'] = $newValue; + break; + default : + throw new BadRequest('Unknown property: '.$property); + } + } + + // check if current addressbooks-class can be instantiated + if ((new \ReflectionClass($this->addressbooks_class))->isAbstract()) { + return; + } + + $addressbook = new $this->addressbooks_class(); + + foreach ($values as $setter => $value) { + if (method_exists($addressbook, $setter)) { + $addressbook->$setter($value); + } + } + + $this->_em->persist($addressbook); + $this->_em->flush(); + + return $addressbook->getId(); + } + + /** + * Deletes an entire addressbook and all its contents. + * + * @param mixed $addressBookId + */ + public function deleteAddressBook($addressBookId) + { + $addressbook = $this->_em->getRepository($this->addressbooks_class)->find($addressBookId); + + if (!$addressbook) { + return; + } + + $addressbook->removeAllCards(); + $this->_em->delete($addressbook); + } + + /** + * 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 + */ + public function getCards($addressbookId) + { + $contactGroup = $this->_em->getRepository($this->addressbooks_class)->findOneById($addressbookId); + $entities = $contactGroup->getContactCollection(); + + $cards = array(); + foreach ($entities as $entity) { + $cards[] = $this->getCardArray($entity, true); + } + + return $cards; + } + + /** + * Returns a specfic card. + * + * The same set of properties must be returned as with getCards. The only + * exception is that 'carddata' is absolutely required. + * + * @param mixed $addressBookId + * @param string $cardUri + * + * @return array + */ + public function getCard($addressBookId, $cardUri) + { + $entity = $this->_em->getRepository($this->cards_class)->findSingleCardByUid($cardUri, $addressBookId); + + return $this->getCardArray($entity, true); + } + + /** + * 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 + */ + public function createCard($addressBookId, $cardUri, $cardData) + { + $addressbook = $this->_em->getRepository($this->addressbooks_class)->find($addressBookId); + + if ((new \ReflectionClass($this->cards_class))->isAbstract()) { + return; + } + + $card = new $this->cards_class(); + $card->setVCard($cardData); + $card->setVCardUid($cardUri); + $addressbook->addCard($card); + + $this->_em->persist($card); + $this->_em->flush(); + + return $card->getETag(); + } + + /** + * 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 + */ + public function updateCard($addressBookId, $cardUri, $cardData) + { + $card = $this->_em->getRepository($this->cards_class)->findSingleCardByUid($cardUri, $addressBookId); + + if (!$card) { + return; + } + + $card->setVCard($cardData); + + $this->_em->flush(); + + return $card->getEtag(); + } + + /** + * Deletes a card. + * + * @param mixed $addressBookId + * @param string $cardUri + * + * @return bool + */ + public function deleteCard($addressBookId, $cardUri) + { + $addressbook = $this->_em->getRepository($this->addressbooks_class)->find($addressBookId); + + $card = $addressbook->findCard($cardUri); + + if ($card instanceof CardInterface) { + return $addressbook->removeCard($card); + } + + return false; + } + + /** + * 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' => [ + * 'modified.txt', + * ], + * 'deleted' => [ + * 'foo.php.bak', + * 'old.txt' + * ] + * ]; + * + * The returned syncToken property should reflect the *current* syncToken + * of the calendar, 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 + */ + public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) + { + + // the "Doctrine2 behavioral extensions" (https://github.com/Atlantic18/DoctrineExtensions) + // are used to log the addressbook-changes + $loggableClass = 'Gedmo\Loggable\Entity\LogEntry'; + + if (!class_exists($loggableClass)) { + return; + } + + /* @var $addressbook \Secotrust\Bundle\SabreDavBundle\Entity\AddressbookInterface */ + $addressbook = $this->_em->getRepository($this->addressbooks_class)->find($addressBookId); + + if ($addressbook->getSynctoken() === 0) { + return; + } + + $result = [ + 'syncToken' => $addressbook->getSynctoken(), + 'added' => [], + 'modified' => [], + 'deleted' => [], + ]; + + if ($syncToken) { + + // Fetching all changes + $repo = $this->_em->getRepository($loggableClass); + $logs = $repo->getLogEntries($addressbook); + + $changes = []; + + // This loop ensures that any duplicates are overwritten, only the + // last change on a node is relevant. + foreach ($logs as $log) { + $changes[$addressbook->getUri()] = $log->getAction(); + } + + foreach ($changes as $uri => $operation) { + switch ($operation) { + case 'create': + $result['added'][] = $uri; + break; + case 'update': + $result['modified'][] = $uri; + break; + case 'remove': + $result['deleted'][] = $uri; + break; + } + } + } else { + // No synctoken supplied, this is the initial sync. + $result['added'] = $addressbook->getUri(); + } + + 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 + */ + protected function addChange($addressBookId, $objectUri, $operation) + { + + // it is suggested to use the Loggable-Extension for Doctrine to manage + // the changes in the entities + // https://github.com/Atlantic18/DoctrineExtensions/blob/master/doc/loggable.md + // + // if the extension is configured in the right way, the changes are logged automatically + // configuration-example: https://github.com/Atlantic18/DoctrineExtensions/blob/master/doc/loggable.md#entity-mapping + + return; + } +} diff --git a/SabreDav/Gaufrette/Collection.php b/SabreDav/Gaufrette/Collection.php index 7bfbfdd..e71f738 100644 --- a/SabreDav/Gaufrette/Collection.php +++ b/SabreDav/Gaufrette/Collection.php @@ -29,7 +29,7 @@ class Collection extends BaseCollection /** * @param Filesystem $filesystem - * @param string $prefix + * @param string $prefix */ public function __construct(Filesystem $filesystem, $prefix = '') { @@ -38,7 +38,7 @@ public function __construct(Filesystem $filesystem, $prefix = '') } /** - * @inheritdoc + * {@inheritdoc} */ public function getChildren() { @@ -58,25 +58,25 @@ public function getChildren() } /** - * @inheritdoc + * {@inheritdoc} */ public function getChild($name) { $key = $this->prefix.$name; if (!$this->filesystem->has($key)) { - throw new Exception\NotFound('The file with name: ' . $name . ' could not be found'); + throw new Exception\NotFound('The file with name: '.$name.' could not be found'); } if ($this->filesystem->getAdapter()->isDirectory($key)) { - return new Collection($this->filesystem, $key.'/'); + return new self($this->filesystem, $key.'/'); } return new File($this->filesystem->get($key)); } /** - * @inheritdoc + * {@inheritdoc} */ public function childExists($name) { @@ -84,7 +84,7 @@ public function childExists($name) } /** - * @inheritdoc + * {@inheritdoc} */ public function getName() { @@ -92,7 +92,7 @@ public function getName() } /** - * @inheritdoc + * {@inheritdoc} */ public function getLastModified() { diff --git a/SabreDav/Gaufrette/File.php b/SabreDav/Gaufrette/File.php index b2492a6..de28812 100644 --- a/SabreDav/Gaufrette/File.php +++ b/SabreDav/Gaufrette/File.php @@ -11,9 +11,7 @@ namespace Secotrust\Bundle\SabreDavBundle\SabreDav\Gaufrette; -use Sabre\DAV\Exception; use Sabre\DAV\File as BaseFile; -use Sabre\DAV\Sabre; class File extends BaseFile { @@ -23,7 +21,7 @@ class File extends BaseFile protected $file; /** - * Constructor + * Constructor. * * @param \Gaufrette\File $file */ @@ -33,7 +31,7 @@ public function __construct(\Gaufrette\File $file) } /** - * @inheritdoc + * {@inheritdoc} */ public function getName() { @@ -41,7 +39,7 @@ public function getName() } /** - * @inheritdoc + * {@inheritdoc} */ public function getSize() { @@ -49,7 +47,7 @@ public function getSize() } /** - * @inheritdoc + * {@inheritdoc} */ public function getLastModified() { @@ -57,7 +55,7 @@ public function getLastModified() } /** - * @inheritdoc + * {@inheritdoc} */ public function put($data) { @@ -65,7 +63,7 @@ public function put($data) } /** - * @inheritdoc + * {@inheritdoc} */ public function get() { @@ -73,7 +71,7 @@ public function get() } /** - * @inheritdoc + * {@inheritdoc} */ public function delete() { diff --git a/SabreDav/HttpRequest.php b/SabreDav/HttpRequest.php index 35d822e..3d0f072 100644 --- a/SabreDav/HttpRequest.php +++ b/SabreDav/HttpRequest.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Request; /** - * Class HttpRequest + * Class HttpRequest. */ class HttpRequest extends BaseRequest { @@ -25,14 +25,38 @@ class HttpRequest extends BaseRequest private $request; /** - * Constructor + * @var string + */ + private $currentUsername; + + /** + * Constructor. * * @param Request $request */ public function __construct(Request $request) { - parent::__construct($request->server->all(), $request->request->all()); - $this->setBody($request->getContent(true), true); - $this->request = $request; // TODO needed? + parent::__construct($request->getMethod(), $request->getRequestUri(), $request->headers->all(), $request->getContent(true)); + $this->request = $request; + } + + /** + * set the current username. + * + * @param string $username + */ + public function setCurrentUsername($username) + { + $this->currentUsername = $username; + } + + /** + * get the current username. + * + * @return string + */ + public function getCurrentUsername() + { + return $this->currentUsername; } } diff --git a/SabreDav/HttpResponse.php b/SabreDav/HttpResponse.php index 9a12989..37bc79c 100644 --- a/SabreDav/HttpResponse.php +++ b/SabreDav/HttpResponse.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse; /** - * Class HttpResponse + * Class HttpResponse. */ class HttpResponse extends BaseResponse { @@ -25,12 +25,13 @@ class HttpResponse extends BaseResponse private $response; /** - * Constructor + * Constructor. * * @param StreamedResponse $response */ public function __construct(StreamedResponse $response) { - $this->response = $response; // TODO needed? + parent::__construct($response->getStatusCode(), $response->headers->all()); + $this->response = $response; } } diff --git a/SabreDav/PrincipalBackend.php b/SabreDav/PrincipalBackend.php new file mode 100644 index 0000000..953ccf1 --- /dev/null +++ b/SabreDav/PrincipalBackend.php @@ -0,0 +1,426 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Secotrust\Bundle\SabreDavBundle\SabreDav; + +use Sabre\DAV\Exception; +use Sabre\DAV\MkCol; +use Sabre\DAVACL\PrincipalBackend\AbstractBackend; +use Sabre\DAVACL\PrincipalBackend\CreatePrincipalSupport; +use FOS\UserBundle\Model\UserInterface; +use FOS\UserBundle\Model\GroupInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +class PrincipalBackend extends AbstractBackend implements CreatePrincipalSupport +{ + /** + * @var \Doctrine\ORM\EntityManager + */ + private $_em; + + /** + * @var \FOS\UserBundle\Model\UserManagerInterface + */ + private $user_manager; + + /** + * @var \FOS\UserBundle\Model\GroupManagerInterface + */ + private $group_manager; + + /** + * @var string + */ + private $principals_class; + + /** + * @var string + */ + private $principalgroups_class; + + /** + * A list of additional fields to support. + * + * @var array + */ + protected $fieldMap = array( + /* + * This property can be used to display the users' real name. + */ + '{DAV:}displayname' => array( + 'getter' => 'getUsername', + 'setter' => 'setUsername', + ), + /* + * This property is actually used by the CardDAV plugin, where it gets + * mapped to {http://calendarserver.orgi/ns/}me-card. + * + * The reason we don't straight-up use that property, is because + * me-card is defined as a property on the users' addressbook + * collection. + */ + '{http://sabredav.org/ns}vcard-url' => array( + 'getter' => 'getVCardUrl', + 'setter' => 'setVCardUrl', + ), + /* + * This is the users' primary email-address. + */ + '{http://sabredav.org/ns}email-address' => array( + 'getter' => 'getEmail', + 'setter' => 'setEmail', + ), + ); + + /** + * Constructor. + * + * @param ContainerInterface $container + */ + public function __construct(ContainerInterface $container) + { + $this->_em = $container->get('doctrine')->getManager(); + $this->principals_class = $container->getParameter('secotrust.principals_class'); + $this->principalgroups_class = $container->getParameter('secotrust.principalgroups_class'); + $this->user_manager = $container->get('fos_user.user_manager'); + + if ($container->has('fos_user.group_manager')) { + $this->group_manager = $container->get('fos_user.group_manager'); + } + } + + /** + * get Array with Principal-Data from User-Object. + * + * @param UserInterface|GroupInterface $principalObject + * @param bool $show_id + * + * @return array + * + * @throws Exception + */ + private function getPrincipalArray($principalObject, $show_id = false) + { + if (!($principalObject instanceof UserInterface) && !($principalObject instanceof GroupInterface)) { + throw new Exception('$principalObject must be of type UserInterface of GroupInterface'); + } + + $principal = array(); + if ($show_id) { + $principal['id'] = $principalObject->getId(); + } + + if ($principalObject instanceof UserInterface) { + $principal['uri'] = 'principals/'.$principalObject->getUsername(); + } else { + $principal['uri'] = 'principals/'.$principalObject->getName(); + } + + // get all fields from $this->fieldMap, additional to 'uri' and 'id' + foreach ($this->fieldMap as $key => $value) { + if (!method_exists($principalObject, $value['getter'])) { + continue; + } + + $valueGetter = call_user_func(array($principalObject, $value['getter'])); + + if ($valueGetter) { + $principal[$key] = $valueGetter; + } + } + + return $principal; + } + + /** + * Returns a list of principals based on a prefix. + * + * This prefix will often contain something like 'principals'. You are only + * expected to return principals that are in this base path. + * + * You are expected to return at least a 'uri' for every user, you can + * return any additional properties if you wish so. Common properties are: + * {DAV:}displayname + * {http://sabredav.org/ns}email-address - This is a custom SabreDAV + * field that's actually injected in a number of other properties. If + * you have an email address, use this property. + * + * @param string $prefixPath + * + * @return array + */ + public function getPrincipalsByPrefix($prefixPath) + { + $userlist = $this->user_manager->findUsers(); + $principals = array(); + + foreach ($userlist as $user) { + + // due to the lack of the implementation of prefixes, return all users + $principals[] = $this->getPrincipalArray($user); + } + + return $principals; + } + + /** + * Returns a specific principal, specified by it's path. + * The returned structure should be the exact same as from + * getPrincipalsByPrefix. + * + * @param string $path + * @param bool $getObject + * + * @return array|GroupInterface|UserInterface|void + * + * @throws Exception + */ + public function getPrincipalByPath($path, $getObject = false) + { + $name = str_replace('principals/', '', $path); + + // get username from path-string, if string contains additional slashes (e.g. admin/calendar-proxy-read) + if (!(strpos($name, '/') === false)) { + $name = substr($name, 0, strpos($name, '/')); + } + + $user = $this->user_manager->findUserByUsername($name); + + if ($user === null) { + if (!$this->group_manager) { + return; + } + + // search in group-manager + $group = $this->group_manager->findGroupByName($name); + + if ($group === null) { + return; + } + + if ($getObject === true) { + return $group; + } + + return $this->getPrincipalArray($group, true); + } + + if ($getObject === true) { + return $user; + } + + return $this->getPrincipalArray($user, true); + } + + /** + * Updates one ore more webdav properties on a principal. + * + * 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 $path + * @param \Sabre\DAV\PropPatch $propPatch + */ + public function updatePrincipal($path, \Sabre\DAV\PropPatch $propPatch) + { + $principal = $this->getPrincipalByPath($path, true); + + if (empty($principal)) { + return; + } + + $propPatch->handle(array_keys($this->fieldMap), function ($properties) use ($principal) { + + foreach ($properties as $key => $value) { + $setter = $this->fieldMap[$key]['setter']; + $principal->$setter($value); + } + + $this->_em->flush(); + + return true; + }); + } + + /** + * This method is used to search for principals matching a set of + * properties. + * + * This search is specifically used by RFC3744's principal-property-search + * REPORT. + * + * The actual search should be a unicode-non-case-sensitive search. The + * keys in searchProperties are the WebDAV property names, while the values + * are the property values to search on. + * + * By default, if multiple properties are submitted to this method, the + * various properties should be combined with 'AND'. If $test is set to + * 'anyof', it should be combined using 'OR'. + * + * This method should simply return an array with full principal uri's. + * + * If somebody attempted to search on a property the backend does not + * support, you should simply return 0 results. + * + * You can also just return 0 results if you choose to not support + * searching at all, but keep in mind that this may stop certain features + * from working. + * + * @param string $prefixPath + * @param array $searchProperties + * @param string $test + * + * @return array + */ + public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') + { + foreach ($searchProperties as $property => $value) { + switch ($property) { + + case '{DAV:}displayname' : + $searchArray['email'] = $value; + break; + case '{http://sabredav.org/ns}email-address' : + $searchArray['email'] = $value; + break; + default : + // Unsupported property + return array(); + } + } + + $principals = $this->_em->getRepository($this->principals_class)->searchPrincipals($prefixPath, $searchArray, $test); + + return $principals; + } + + /** + * Returns the list of members for a group-principal. + * + * @param string $principal + * + * @return array + * + * @throws Exception + */ + public function getGroupMemberSet($principal) + { + $groupMemberSet = array(); + + $principalObject = $this->getPrincipalByPath($principal, true); + + if (!$principalObject) { + throw new Exception('Principal not found'); + } + + // add current principal to group-list + $principalArray = $this->getPrincipalArray($principalObject); + $groupMemberSet[] = $principalArray['uri']; + + if ($this->principalgroups_class === '') { + // groups-class is not defined: return current principal as only group-member + return $groupMemberSet; + } + + //TODO: list all group memberships for current group (FOSUserBundle) + + return $groupMemberSet; + } + + /** + * Returns the list of groups a principal is a member of (each element of the list contains a URI). + * + * @param string $principal + * + * @return array + */ + public function getGroupMembership($principal) + { + $principal_data = $this->getPrincipalByPath($principal, true); + + if (!$principal_data) { + return array(); + } + + $groupMembership = array($principal); + + if ($this->principalgroups_class !== '') { + foreach ($principal_data->getGroups() as $group) { + $groupPrincipal = $this->getPrincipalArray($group); + $groupMembership[] = $groupPrincipal['uri']; + } + } + + return $groupMembership; + } + + /** + * Updates the list of group members for a group principal. + * + * The principals should be passed as a list of uri's. + * + * @param string $principal + * @param array $members + * + * @throws Exception + */ + public function setGroupMemberSet($principal, array $members) + { + $groupPrincipal = $this->getPrincipalByPath($principal); + + if (!$groupPrincipal || !($groupPrincipal instanceof GroupInterface)) { + throw new Exception('(Group-)Principal not found'); + } + + // check if update of user-groups is possible; break if no group-manager or principalgroups_class + if ($this->principalgroups_class === '' || !$this->group_manager) { + return; + } + + $memberObjects = array($groupPrincipal); + + foreach ($members as $memberUri) { + $memberObjects[] = $this->getPrincipalByPath($memberUri); + } + + // TODO: Implement the addition/deletion of new/old members + } + + /** + * Creates a new principal. + * + * This method receives a full path for the new principal. The mkCol object + * contains any additional webdav properties specified during the creation + * of the principal. + * + * @param string $path + * @param MkCol $mkCol + */ + public function createPrincipal($path, MkCol $mkCol) + { + + // create new user + $username = str_replace('principal/', '', $path); + + $user = $this->user_manager->createUser(); + $user->setUsername($username); + $user->setForename($username); + + $this->_em->persist($user); + $this->_em->flush(); + } +} diff --git a/SecotrustSabreDavBundle.php b/SecotrustSabreDavBundle.php index 0cbc778..eb3c0ec 100644 --- a/SecotrustSabreDavBundle.php +++ b/SecotrustSabreDavBundle.php @@ -17,7 +17,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; /** - * Class SecotrustSabreDavBundle + * Class SecotrustSabreDavBundle. */ class SecotrustSabreDavBundle extends Bundle { diff --git a/composer.json b/composer.json index 0febc05..d2e9eac 100644 --- a/composer.json +++ b/composer.json @@ -22,20 +22,20 @@ ], "require": { "php": ">=5.3.3", - "symfony/framework-bundle": "~2.2", - "sabre/dav": "1.8.*" + "symfony/framework-bundle": "~2.2 || ^3.0", + "friendsofsymfony/user-bundle": ">=1.3.5", + "sabre/dav": "~3.1.0" }, "suggest": { - "sabre/vobject": "~3.0.0", - "knplabs/knp-gaufrette-bundle": "0.2.*" + "knplabs/knp-gaufrette-bundle": "0.2.*", + "gedmo/doctrine-extensions": "~2.3" }, "autoload": { - "psr-0": { "Secotrust\\Bundle\\SabreDavBundle": "" } + "psr-4": { "Secotrust\\Bundle\\SabreDavBundle\\": "" } }, - "target-dir": "Secotrust/Bundle/SabreDavBundle", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } } }