Implementing WebDAV with PHP

The WebDAV has been around since 1999 and is natively supported by OS/X, Windows and Linux. WebDAV allows you to create remote filesystems through HTTP, much like how FTP works.

WebDAV has a bunch of advantages over FTP. I’d say the most important advantage, is that we can leverage existing server-side web technologies and easily bring web applications to the operating system.

Even though it has been around for years now, its not used a lot in the wild. Some of the reasons are:

  • It’s a complex standard.
  • There is not a lot of documentation out there (and most of it crap).
  • Every single client implementation does things differently.

But because it allows us to integrate web applications with the operation systems, I’m still very interested in this. I’m going to try to create a good WebDAV library in PHP, which should hide most the complexities for the actual implementor. Because there’s next to nothing documentation out there, I hope this will become a good document for future WebDAV implementors.

HTTP Recap

WebDAV works over HTTP. HTTP defines urls as resources, and a bunch of methods to interact with them. HTTP and the web was originally invented as a read/write environment. Each URL could be read (GET), written to or created (PUT) or deleted (DELETE). If urls acted as application endpoints POST was used. PUT and DELETE never became popular.

2017 note: All of these methods are now in use a lot more in REST services.

I’ll quickly recap and iterate over the various standard HTTP methods.

OPTIONS

The OPTIONS method allows an http client to find out what HTTP methods are supported on a specific url.

The methods are returned within the ‘Allows’ HTTP method. PHP example:

<?php
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
  header('Allows: OPTIONS GET HEAD POST DELETE');
  die();
?>

GET

GET is used in the browser to fetch the contents of URI’s. I’ll assume you know how this works.

PUT

PUT is used to either overwrite, or create new resources. Atom makes use of this HTTP method as well. In PHP this data can be obtained using:

<?php
  $data = file_get_contents('php://input');
?>

If a new file is created with a PUT request, you should return HTTP status code 201, or a standard “200 Ok” if you updated an existing file.

<?php
  header('HTTP/1.1 201 Created');
?>

DELETE

DELETE removes the requested uri. Generally DELETE also returns HTTP/1.1 200 Ok.

WebDAV comes in

Whereas HTTP only defines the concept uri or resource, WebDAV adds the following concepts:

  • File meta-data in the form of properties.
  • Collections of resources (a.k.a. directories with files).

WebDAV does that with the following HTTP methods:

  • MKCOL - Creates a new collection/directory.
  • PROPFIND - Returns a list of properties of a file, a directory of a directory and its files.
  • PROPPATCH - Updates properties of a resource or collection.
  • MOVE (optional) - Moves a resource or collection to a different location, or renames it.
  • COPY (optional) - Copies a resource to a new location.

2017 note: COPY and MOVE really aren’t optional if support for most WebDAV clients is desired.

This is also where the headaches start. PROPFIND and PROPPATCH both work with XML, and its a rather nasty beast.

WebDAV xml documents make use of xml namespaces. However, at the time the WebDAV standard was created, the XML Namespaces standard was not yet fully stable.

This is an example of a PROPFIND request made by OS/X:

PROPFIND /services/dav/ HTTP/1.1
User-Agent: WebDAVFS/1.5 (01508000) Darwin/9.1.0 (i386)
Accept: */*
Content-Type: text/xml
Depth: 0
Content-Length: 161
Connection: keep-alive
Host: dav.example.com

<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
  <D:prop>
    <D:getlastmodified/>
    <D:getcontentlength/>
    <D:resourcetype/>
  </D:prop>
</D:propfind>

The DAV: namespace used here, is now invalid. libxml (which is used by simplexml) will throw a warning on this and ignore all elements in this namespace.

2017 note: Most operating systems ship a libxml version that doesn’t have this problem anymore. There’s some holdovers from OS’s that are terrible at keeping up like CentOS, but realisticaly not something you have to worry about.

Class 2

WebDAV comes in 2 flavours. Class 1, supporting the methods mentioned above, and class 2 servers which add a locking mechanism using the HTTP methods:

  • LOCK
  • UNLOCK

Locking a file makes sure that when you are working on a file, nobody else can modify it.

Lets get on with it.

We’ll start with a basic implementation of a WebDAV server. Note that I’m putting everything in a ‘Sabre’ namespace, because, well, thats what I usually do.

The basic framework will be a PHP class, that loads checks the HTTP method called, and will call a corresponding class-method.

<?php

class Sabre_DAV_Server {

   function exec() {

      try {

        // Invoke the HTTP method
        $this->invoke(strtolower($_SERVER['REQUEST_METHOD']));

      } catch (Sabre_DAV_Exception $e) {

         // We caught a DAV exception, so we'll send back a HTTP status code and re-throw
         $this->sendHTTPStatus($e->getHTTPCode());
         throw $e;

      }

   }

   protected function invoke($method) {

      // Make sure this is a HTTP method we support
      if (in_array($method,$this->getAllowedMethods())) {

        $this->$method();

      } else {

        // Unsupported method
        throw new Sabre_DAV_MethodNotImplementedException();

      }

   }

   protected function getAllowedMethods() {

     return array('options','get','head','post','delete','trace','propfind','proppatch','copy','mkcol','put');

   }


   protected function sendHTTPStatus($code) {

      header($this->getHTTPStatus($code));

   }

   protected function getHTTPStatus($code) {

     $msg = array(
        200 => 'Ok',
        201 => 'Created',
        204 => 'No Content',
        207 => 'Multi-Status',
        403 => 'Forbidden',
        404 => 'Not Found',
        409 => 'Conflict',
        415 => 'Unsupported Media Type',
        500 => 'Internal Server Error',
        501 => 'Method not implemented',
     );

     return 'HTTP/1.1 ' . $code . ' ' . $msg[$code];

   }

}

?>

You can run this code by instantiating the class and calling the exec() method.

<?php

$server = new Sabre_DAV_Server();
$server->exec();

?>

End-user API

We want to allow users to integrate with WebDAV as easy as possible, so we’ll try to define the end-user API before we do anything else.

WebDAV deals with Files and Directories, so we’ll need interfaces and classes to represent those.

<?php

interface Sabre_DAV_IFile {
  function delete();
  function put();
  function get();
  function getName();
  function getSize();
  function getLastModified();
}

interface Sabre_DAV_IDirectory extends Sabre_DAV_IFile {

  function createFile($name,$data);
  function createDirectory($name);
  // This method will return an array with objects that all implement the IFile class
  function getChildren();
  // This method will return a single child
  function getChild($name);

}
?>

We’ll also create some convenience classes, which, by default always emit ‘Permission Denied’ This allows you to easily build WebDAV directory structures, without having to implement every single method.

<?php

abstract class Sabre_DAV_File implements Sabre_DAV_IFile {

    function delete() {

        throw new Sabre_DAV_PermissionDeniedException();

    }

    function put($data) {

        throw new Sabre_DAV_PermissionDeniedException();

    }

    function get() {

        throw new Sabre_DAV_PermissionDeniedException();

    }

    function getSize() {

        return 0;

    }

    function getLastModified() {

        return time();

    }

}

abstract class Sabre_DAV_Directory extends Sabre_DAV_File implements Sabre_DAV_IDirectory {

    function createFile($filename,$data) {

        throw new Sabre_DAV_PermissionDeniedException();

    }

    function createDirectory($name) {

        throw new Sabre_DAV_PermissionDeniedException();

    }

    function getChildren() {

        throw new Sabre_DAV_PermissionDeniedException();

    }

    function getChild($path) {

        foreach($this->getChildren() as $child) {

            if ($child->getName()==$path) return $child;

        }
        throw new Sabre_DAV_FileNotFoundException();
    }

}

?>

Sample implementation

I hope you’re still with me :). Now, we’ll build a sample/testing implementation that simply hooks a directory of the regular filesystem to our WebDAV server.

<?php

class Sabre_DAV_FS_File extends Sabre_DAV_File {

    private $myPath;

    function __construct($myPath) {

        $this->myPath = $myPath;

    }

    function getName() {

        return basename($this->myPath);

    }

    function get() {

        readfile($this->myPath);

    }

    function delete() {

        unlink($this->myPath);

    }

    function put($data) {

        file_put_contents($this->myPath,$data);

    }

    function getSize() {

        return filesize($this->myPath);

    }

    function getLastModified() {

        return filemtime($this->myPath);

    }

}

class Sabre_DAV_FS_Directory extends Sabre_DAV_Directory {

    function __construct($myPath) {

        $this->myPath = $myPath;

    }

    function getName() {

        return basename($this->myPath);

    }

    function getChildren() {

        $children = array();

        foreach(scandir($this->myPath) as $file) {

            if ($file=='.' || $file=='..') continue;
            if (is_dir($this->myPath . '/' . $file)) {

                $children[] = new self($this->myPath . '/' . $file);

            } else {

                $children[] = new Sabre_DAV_FS_File($this->myPath . '/' . $file);

            }

        }

        return $children;

    }

    function createDirectory($name) {

        mkdir($this->myPath . '/' . $name);

    }

    function createFile($name,$data) {

        file_put_contents($this->myPath . '/' . basename($name),$data);

    }

    function delete() {

        foreach($this->children() as $child) $child->delete();
        unlink($this->myPath);

    }

}

?>

Take a deep breath…

Now lets get back to the WebDAV protocol. The first thing any WebDAV client will do when its establishing a connection is an ‘OPTIONS’ request. The server should reply with a list of possible methods and the WebDAV classes it supports. We’ll only going to focus on the class 1 server first.

The implementation is easy, we’ll just have to add the following methods to our Server class:

<?php

protected function options() {

   $this->addHeader('Allows',strtoupper(implode(' ',$this->getAllowedMethods())));
   $this->addHeader('DAV','1');

}

function addHeader($name,$value) {

  header($name . ': ' . str_replace(array("\n","\r"),array('\n','\r'),$value));

}

?>

The next method it will usually call, is PROPFIND, this is where all hell breaks loose. An example of a PROPFIND request is the following:

PROPFIND /services/dav/ HTTP/1.1
User-Agent: WebDAVFS/1.5 (01508000) Darwin/9.1.0 (i386)
Accept: */*
Content-Type: text/xml
Depth: 0
Content-Length: 161
Connection: keep-alive
Host: dav.example.com

<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
  <D:prop>
    <D:getlastmodified/>
    <D:getcontentlength/>
    <D:resourcetype/>
  </D:prop>
</D:propfind>

This tells us:

  • The client wants to know the properties from the url /services/dav/.
  • Depth: 0, which means it only wants to know the properties of the top-level directory. If depth was 1 it would also want to know information of all the files within the directory. The depth can be any number, or the word infinity. Its safe to only support 0 and 1 here, as much clients won’t care.
  • It only wants (or understands) the getlastmodified, getcontentlength and resourcetype properties.

PROPFIND implementation

Since this method will also involve our Directory/File API, we’ll need to start integrating with this as well.

<?php

  private $root;

  function __construct(Sabre_DAV_IDirectory $root) {

    $this->root = $root;

  }

?>

Next, we’ll need to write a parser for the request, to find out what properties were being requested.

<?php

function getRequestedProperties($data) {

    // We'll need to change the DAV namespace declaration to something else in order to make it parsable
    $data = preg_replace("/xmlns(:[A-Za-z0-9_])?=\"DAV:\"/","xmlns\\1=\"urn:DAV\"",$data);

    $xml = simplexml_load_string($data);
    $xml = $xml->children('urn:DAV');

    $props = array();

    $propertyTypes = array(
        'getlastmodified',
        'getcontentlength',
        'resourcetype',
    );

    foreach($propertyTypes as $propType) if (isset($xml->prop->$propType)) $props[] = $propType;

    return $props;

}

?>

As you can see, we first run a regex to change xml namespace declarations from DAV: to urn:DAV. This is needed to make the request valid, and to make sure libxml won’t complain.

2017 note: So yea, this conversion is not needed with modern libxml versions. Also… why did this post end so abrubtly? Did I get bored with it? We’ll never know I guess.