Implementing a WebDAV server using SabreDAV

You are viewing revision #4 of this wiki article.
This version may not be up to date with the latest version.
You may want to view the differences to the latest version.

next (#5) »

  1. Short introduction to WebDAV
  2. Requirements
  3. Configuration
  4. Controller
  5. Database
  6. SabreDAV backends
  7. PHP client

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.

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.

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
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 = $this->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.

5 0
8 followers
Viewed: 43 883 times
Version: Unknown (update)
Category: Tutorials
Written by: nineinchnick
Last updated by: nineinchnick
Created on: May 3, 2013
Last updated: 11 years ago
Update Article

Revisions

View all history

Related Articles