Implementing a WebDAV server using SabreDAV

8 followers

This wiki article has not been tagged with a corresponding Yii version yet.
Help us improve the wiki by updating the version information.

This article will show you how to easily implement a WebDAV server in your project using SabreDAV.

Short introduction to WebDAV

WebDAV server provides a remote filesystem in a tree structure, with locks, authentication and ACLs for authorization. As a storage backend, it can use an ordinary filesystem, storing data in a directory or some other data source, like a database. The tree structure doesn't have to exist, it could just represent some business logic of a custom project.

CalDAV and CardDAV are WebDAV extensions, adding special directories holding files representing calendar events and contact cards.

SabreDAV is a PHP implementation of a WebDAV server. It's designed to be integrated into other software. Thanks to this, a WebDAV server can be added to an existing projects utilizing existing:

  • database structures
  • business logic in ActiveRecords
  • authentication mechanisms

Every WebDAV server uses following data models:

  • principals - representing users and other resources
  • groups of principals
  • locks - optionally

CalDAV uses:

  • calendars
  • calendar objects (events)

CardDAV uses:

  • addressbooks
  • addressbooks entries (cards)

All of the above must be provided in classes extending base classes provided by SabreDAV.

Requirements

Download a release zip.

Extract it to a temporary directory, then copy:

  • SabreDAV/lib/Sabre into protected/vendors
  • SabreDAV/vendor/sabre/vobject/lib/Sabre/VObject into protected/vendors/Sabre

Save the rest for future reference, as the examples and tests will provide useful code templates.

Configuration

SabreDAV server provides its own url managment, so let's redirect all traffic prefixed with dav into one action running that server.

In protected/config/main.php adjust the rules in urlManager component:

'urlManager' => array(
    'urlFormat'     => 'path',
    'rules'         => array(
        'dav*' => array('dav/index', 'parsingOnly'=>true),
        // ... rest of your usual routes
        '<controller:\w+>/<action:\w+>/<id:\d+>' => '<controller>/<action>',
        '<controller:\w+>/<action:\w+>' => '<controller>/<action>',
    ),
    'showScriptName' => false,
),

Controller

SabreDAV classes are namespaced and they integrate with Yii's autoloader. Because a one controller's action is a single point of entry into all SabreDAVs code, we can create an alias for the autoloader directly in the controller's source file.

After configuring and starting the DAV server, it will take over and process the request further.

All project-specific classes are prefixed with My. They override classes provided by SabreDAV to adjust to a different database schema, use ActiveRecords instead of plain PDO and Yii's auth mechanisms.

This example starts a calendar server, but it's just a plain WebDAV server extended with some plugins.

<?php
 
Yii::setPathOfAlias('Sabre', Yii::getPathOfAlias('application.vendors.Sabre'));
 
use Sabre\DAV;
 
class DavController extends CController
{
    public function actionIndex()
    {
        // only 3 simple classes must be implemented by extending SabreDAVs base classes
        $principalBackend = new MyPrincipalBackend;
        $calendarBackend = new MyCalendarBackend;
        $authPlugin = new Sabre\DAV\Auth\Plugin(new MyAuth, 'example.com');
 
        // this defines the root of the tree, here just principals and calendars are stored
        $tree = array(
            //new Sabre\DAVACL\PrincipalCollection($principalBackend),
            new Sabre\CalDAV\Principal\Collection($principalBackend),
            new Sabre\CalDAV\CalendarRootNode($principalBackend, $calendarBackend),
        );
 
        $server = new DAV\Server($tree);
        $server->setBaseUri($this->createUrl('/dav/'));
        $server->addPlugin($authPlugin);
 
        $server->addPlugin(new Sabre\DAVACL\Plugin());
        $server->addPlugin(new Sabre\CalDAV\Plugin());
        // this is fun, try to open that action in a plain web browser
        $server->addPlugin(new Sabre\DAV\Browser\Plugin());
 
        $server->exec();
    }
}

Database

As stated in the introduction, following data models are needed, representing some database structures:

  • principals (users) and groups
  • locks - optionally
  • calendars and calendar objects (events) - optionally, if building a CalDAV
  • addressbooks and addressbooks entries (cards) - optionally, if building a CardDAV

You can use your existing database structures to store and read that data or create them from examples provided with SabreDAV.

Take a look at the extracted SabreDAV release zip, into the SabreDAV/examples/sql directory. Choose files for your database. Use them as a template for creating new tables or to get an idea of what is being used by provided example backends.

SabreDAV backends

Auth

Extending the AbstractBasic class provides us with a HTTP Basic auth mechanism for our DAV server.

To validate the username and password we can use the same UserIdentity class that is used to log into the Yii based project.

<?php
 
class MyAuth extends Sabre\DAV\Auth\Backend\AbstractBasic
{
    protected $_identity;
 
    protected function validateUserPass($username, $password)
    {
        if ($this->_identity === null) {
            $this->_identity=new UserIdentity($username,$password);
        }
        return $this->_identity->authenticate() && $this->_identity->errorCode == UserIdentity::ERROR_NONE;
    }
}

Principal

Principal backend just performes searches and returns results as arrays of simple string properties.

Take a look at the class in Sabre\DAVACL\PrincipalBackend\PDO.

Now create a similar class extending Sabre\DAVACL\PrincipalBackend\AbstractBackend, but use your ActiveRecord models instead of performing raw SQL queries through PDO.

Info: as principals represent users, groups and other resources, more than one ActiveRecord could be used in the PrincipalBackend creating a data union

Below is a simple, stripped from comments example of a principal backend, along with three methods in the User model class.

<?php
 
use Sabre\DAV;
use Sabre\DAVACL;
 
class MyPrincipalBackend extends Sabre\DAVACL\PrincipalBackend\AbstractBackend {
    /**
     * ActiveRecord class name for 'principals' model.
     * It must contain an 'uri' attribute, property or getter method.
     * It must contain following methods:
     * - getPrincipals() returning an array of property=>value.
     * - setPrincipals() accepting an array of property=>value.
     * - getPrincipalMap() returning an array of property=>attribute.
     *
     * @var string
     */
    protected $userClass;
 
    public function __construct($userClass = 'User') {
        $this->userClass = $userClass;
    }
 
    public function getPrincipalsByPrefix($prefixPath) {
        $models = CActiveRecord::model($this->userClass)->findAll();
 
        $principals = array();
        foreach($models as $model) {
            list($rowPrefix) = DAV\URLUtil::splitPath($model->uri);
            if ($rowPrefix !== $prefixPath) continue;
 
            $principals[] = $model->getPrincipal();
        }
 
        return $principals;
 
    }
 
    public function getPrincipalByPath($path) {
        $model = CActiveRecord::model($this->userClass)->findByAttributes(array('uri'=>$path));
        return $model === null ? null : $model->getPrincipal();
    }
 
    public function updatePrincipal($path, $mutations) {
        $model = CActiveRecord::model($this->userClass)->findByAttributes(array('uri'=>$path));
        if ($model === null)
            return false;
        $result = $model->setPrincipal($mutations);
        if (is_string($result)) {
            $response = array(
                403 => array($result => null),
                424 => array(),
            );
 
            // Adding the rest to the response as a 424
            foreach($mutations as $key=>$value) {
                if ($key !== $result) {
                    $response[424][$key] = null;
                }
            }
            return $response;
        }
        return $result;
    }
 
    public function searchPrincipals($prefixPath, array $searchProperties) {
        $map = CActiveRecord::model($this->userClass)->getPrincipalMap();
        $attributes = array();
        // translate keys in $searchProperties from property names to attribute names
        foreach($searchProperties as $property => $value) {
            if (isset($map[$property])) $attributes[$map[$property]] = $value;
        }
 
        $models = CActiveRecord::model($this->userClass)->findByAttributes($attributes);
 
        $principals = array();
        foreach($models as $model) {
            list($rowPrefix) = DAV\URLUtil::splitPath($model->uri);
            if ($rowPrefix !== $prefixPath) continue;
 
            $principals[] = $model->getPrincipal();
        }
 
        return $principals;
 
    }
 
    public function getGroupMemberSet($principal) {
        // not implemented, this could return all principals for a share-all calendar server
        return array();
    }
 
    public function getGroupMembership($principal) {
        // not implemented, this could return a list of all principals
        // with two subprincipals: calendar-proxy-read and calendar-proxy-write for a share-all calendar server
        return array();
 
    }
 
    public function setGroupMemberSet($principal, array $members) {
        throw new Exception\NotImplemented('Not Implemented');
    }
}

Three extra methods in the User model class, moving more business logic from the principal backend to the models.

<?php
class User extends BaseUser
{
    public static function model($className=__CLASS__) {
        return parent::model($className);
    }
 
    public function getPrincipalMap() {
        // adjust that to your User class
        return array(
            '{DAV:}displayname' => 'displayname',
            '{http://sabredav.org/ns}vcard-url' => 'vcardurl',
            '{http://sabredav.org/ns}email-address' => 'email',
        );
    }
 
    public function getPrincipal() {
        // maybe an uri getter method could generate it on the fly from primary key
        $result = array(
            'id' => $this->primaryKey,
            'uri' => $this->uri,
        );
        foreach($this->getPrincipalMap() as $property=>$attribute) {
            $result[$property] = $this->{$attribute};
        }
        return $result;
    }
 
    public function setPrincipal(array $principal) {
        // validate that all $principal keys are known properties
        $map = $this->getPrincipalMap();
        $validProperties = array_keys($map);
        foreach($principal as $property=>$value) {
            if (!in_array($property, $validProperties)) {
                return $property;
            }
            $this->{$map[$property]} = $value;
        }
        return $this->save();
    }
}

Others

Other backends, like Calendar or Card are more complex and tend to be very project-specific.

For a Calendar backend, look at Sabre\CalDAV\Backend\PDO and then create a class extending Sabre\CalDAV\Backend\AbstractBackend.

Do the same for others, if you need them.

Info: now the project specific business logic can be kept separated from the WebDAV implementation inside ActiveRecords as rules, scopes and scenarios

PHP client

After playing around with your new calendar server using Thunderbird with the Lightning extension it's time to write your own client in Yii.

Backend

SabreDAV again provides a basic example class that could be used as a starting point. Talking to a DAV server is just like any other REST service, using XML as the media type.

Info: if your DAV server is the same web application as the client, remember to stop the session before making a request to the DAV server. When a PHP process makes a request to the same server, the second process will wait for the first one to release the lock on the session file and the first one will wait for the request to complete, creating a deadlock.

Here's an example action fetching some calendar objects.

<?php
 
Yii::setPathOfAlias('Sabre', Yii::getPathOfAlias('application.vendors.Sabre'));
 
use Sabre\DAV;
 
class ClientController extends Controller {
    protected $davSettings = array(
        'baseUri' => 'dav/',
        // a special auth backend was provided for SabreDAV that authenticates users
        // already logged into Yii, so no username or password is sent
        'userName' => null,
        //'password' => null,
    );
 
    protected function beforeAction($action) {
        $this->davSettings['baseUri'] = $this->createAbsoluteUrl($this->davSettings['baseUri']) . '/';
        return true;
    }
 
    public function actionIndex($start = null, $end = null) {
        // we will be using same session to authenticate in SabreDAV
        $headers = array('Cookie'=>Yii::app()->session->sessionName.'='.Yii::app()->session->sessionID);
        Yii::app()->session->close();
 
        $client = new Sabre\DAV\Client($this->davSettings);
 
        // prepare request body
        $doc  = new DOMDocument('1.0', 'utf-8');
        $doc->formatOutput = true;
 
        $query = $doc->createElement('c:calendar-query');
        $query->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:c', 'urn:ietf:params:xml:ns:caldav');
        $query->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:d', 'DAV:');
 
        $prop = $doc->createElement('d:prop');
        $prop->appendChild($doc->createElement('d:getetag'));
        $prop->appendChild($doc->createElement('c:calendar-data'));
        $query->appendChild($prop);
        $doc->appendChild($query);
        $body = $doc->saveXML();
        $headers = array_merge(array(
            'Depth'=>'1',
            'Prefer'=>'return-minimal',
            'Content-Type'=>'application/xml; charset=utf-8',
        ), $headers);
        unset($doc);
 
        $response = $client->request('REPORT', 'calendars/somePrincipal', $body, $headers);
        header("Content-type: application/json");
        echo CJSON::encode($this->parseMultiStatus($response['body']));
    }
}

Frontend

A good starting point is to search the extensions catalog for wrappers for FullCalendar jQuery plugin.

Total 6 comments

#18583 report it
Arno S at 2014/11/21 08:04am
Implementation in Yii 2.0

Anyone looking to implement this in Yii 2.0; remember to disable CSRF validation for the controller. I.e.:

public function init()
{
    $this->enableCsrfValidation = false;
}

You'll also need an UrlManager rule. I.e. if you have a DavController with actionServer($path):

['pattern'=>'dav/<path:.+>', 'route'=>'dav/server', 'mode'=>\yii\web\UrlRule::PARSING_ONLY],
#16762 report it
gb5256 at 2014/03/25 05:43pm
MyCalendarBackend.php

Hello, could somebody share his MyCalendarBackend.php? I am a bit lost for that one... Thanks, gb5256

#16754 report it
nineinchnick at 2014/03/25 09:01am
example.com

I don't really remember, check out the source code. It's probably just the message displayed in basic auth when asking for username/password.

#16753 report it
gb5256 at 2014/03/25 08:51am
Very nice....

Hey, super tutorial. Working right now through it. One question: why is there the example.com $authPlugin = new Sabre\DAV\Auth\Plugin(new MyAuth, 'example.com');

gb5256

#13149 report it
nineinchnick at 2013/05/08 05:35am
client controller

There's a typo, change: $this->request

to: $client->request

I've prepared that example by extracting some code from my class overloading Sabre\DAV\Client.

#13148 report it
jwerner at 2013/05/08 05:23am
Nice Tutorial!

Thanks for this excellent tutorial!

When I open the Client controller, I get the error:

exception 'CException' with message 'Neither ClientController nor
attached Behavior have a scope "request".' in
C:\Frameworks\yii-git\framework\base\CComponent.php:266
Stack trace:
#0 [internal function]: CComponent->__call('request', Array)
#1
C:\xampp\htdocs\appname\protected\controllers\ClientController.php(48):
ClientController->request('REPORT', 'calendars/someP...', '<?xml
version="...', Array)

Thanks + regards,

Joachim

Leave a comment

Please to leave your comment.

Write new article