├── LICENSE ├── README.md ├── davvy ├── __init__.py ├── addressbook.py ├── admin.py ├── base.py ├── calendar.py ├── exceptions.py ├── models.py ├── storage.py ├── tests.py └── wellknown.py └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 unbit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Davvy 2 | 3 | A Django application for building WebDAV services. 4 | 5 | #Installation and Configuration 6 | 7 | Just add 'davvy' in `INSTALLED_APPS` and set the directory where you want to store WebDAV files via the `DAVVY_STORAGE_PATH` settings option: 8 | 9 | ```py 10 | DAVVY_STORAGE_PATH = '/var/www/davvy' 11 | ``` 12 | 13 | Now you can start configuring your application's urls. 14 | Davvy is class-view based, so you can extend it by simply subclassing the WebDAV class (as an example the included CardDAV and CalDAV classes inherit from it). 15 | 16 | The url regexp must obey to a simple rule: 17 | 18 | ```py 19 | from davvy.base import WebDAV 20 | from davvy.addressbook import CardDAV 21 | from davvy.calendar import CalDAV 22 | 23 | urlpatterns = patterns('', 24 | 25 | url(r'^principals/(\w+)/(.*)', WebDAV.as_view(root='storage')), 26 | url(r'^storage/(\w+)/(.*)', WebDAV.as_view(root='storage')), 27 | url(r'^addressbook/(\w+)/(.*)', CardDAV.as_view(root='addressbook001')), 28 | url(r'^calendars/(\w+)/(.*)', CalDAV.as_view(root='calendars')), 29 | 30 | url(r'^admin/', include(admin.site.urls)), 31 | ) 32 | ``` 33 | 34 | As you can see, the second part of the url must always be catched, as it contains the username owning the resource you want to use. 35 | 36 | ``/principals/foobar/`` will be the base for the 'foobar' user, as well as ``/principals/foobar/photos/2014/summer/1.jpg`` will map to the /photos/2014/summer/1.jpg resource for the 'foobar' user. 37 | 38 | ``/addressbook/foobar`` is instead the main storage for the CardDAV system (all of the collections will be automatically mapped to an addressbook resource). 39 | 40 | The `root` parameter in the class-based-view arguments is required, and you can see it as the _'disk'_ containing collections and objects. 41 | 42 | Internally, `/principals/foobar/photos/2014/summer/1.jpg` will be indeed mapped to `storage/photos/2014/summer/1.jpg` of the user `foobar`. 43 | The root directory is created for every user on-demand. 44 | 45 | #Davvy specific features 46 | 47 | 48 | ##Resource protection 49 | Protected resources won't be deleted by any of the client requests. 50 | You can mark protected resources by using, for instance, the Django Admin Interface. 51 | 52 | ##Resource sharing 53 | Resources can be shared among users. 54 | Specifically, you can assign resources to one or more groups. 55 | Then, those resources will be shared among every user belonging to the selected groups. 56 | 57 | As an example, you could share one of your calendars with the group "bars". 58 | To do so, you only need to select "bars" among the groups of your calendar, in the Django admin interface, or anywhere else. 59 | That's it, refresh your client and voilà! 60 | 61 | #Home sets autodiscovery (required for iOS/OSX clients) 62 | 63 | Moderns Dav clients automatically try to discover the home of specific resources. 64 | 65 | As an example a CardDAV client could get the home of a principal (see it as a username in Dav _slang_) addressbook, asking for the ``addressbook-home-set`` property (with a PROPFIND request). 66 | 67 | Davvy can be configured to return such path via Django settings.py: 68 | 69 | ```py 70 | DAVVY_CURRENT_USER_PRINCIPAL_BASE = '/principals' 71 | DAVVY_ADDRESSBOOK_HOME_SET_BASE = '/addressbook' 72 | DAVVY_CALENDAR_HOME_SET_BASE = '/calendars' 73 | ``` 74 | 75 | Remember: davvy will automatically append /username to every home. 76 | 77 | Thanks to this options you will be able to force your client to search for calendars in /calendars/foobar even if it has been configured for /principals/foobar. 78 | 79 | Furthermore, Apple clients will sporadically send PROPFIND requests to your server root. 80 | 81 | As a consequence, and in order to avoid unpleasant 404 errors, you could add to your `urlpatterns` the following lines: 82 | 83 | ```py 84 | from davvy.wellknown import WellKnownDAV 85 | 86 | urlpatterns = patterns('', 87 | # ... 88 | url(r'^$', 89 | WellKnownDAV.as_view(root='storage')), 90 | # ... 91 | ) 92 | ``` 93 | 94 | ## Apple's custom autodiscovery 95 | 96 | In addition to the [_home set_ requirements](#home-sets-autodiscovery-required-for-iososx-clients), Apple's clients can use a [custom autodiscovery protocol](https://tools.ietf.org/html/rfc6764) to automatically locate WebDAV extensions specific home sets and services. 97 | 98 | As a consequence, you'll be able to configure your client by only entering the server remote address, without caring about any of the home sets. 99 | 100 | Davvy can be configured to correctly return the required protocol components to any Apple client by simply editing your `urlpatterns` as follows: 101 | 102 | ```py 103 | from davvy.wellknown import WellKnownDAV 104 | 105 | urlpatterns = patterns('', 106 | # ... 107 | url(r'^.well[-_]?known/caldav/?$', 108 | WellKnownDAV.as_view(root='calendars')), 109 | url(r'^.well[-_]?known/carddav/?$', 110 | WellKnownDAV.as_view(root='addressbook001')), 111 | # ... 112 | ) 113 | ``` 114 | 115 | 116 | #Client configuration 117 | 118 | Client-side configuration largely varies. 119 | 120 | As an example, we provide here some configuration samples to be used in OS X Mavericks (10.9) Calendar/Contacts and in Mozilla Thunderbird. 121 | 122 | ##OS X Calendar 123 | 124 | If you have enabled [davvy's autodiscovery for Apple clients](#apples-custom-autodiscovery) you can simply setup a CalDAV account as follows: 125 | 126 | ```ini 127 | Account Type = Manual 128 | User Name = foo 129 | Password = bar 130 | Server Address = www.yourserver.org 131 | ``` 132 | 133 | Otherwise, you should use the following configuration: 134 | 135 | ```ini 136 | Account Type = Advanced 137 | User Name = foo 138 | Password = bar 139 | Server Address = www.yourserver.org 140 | Server Path = /calendars/foo 141 | Port = 8080 ; or any port you want 142 | ``` 143 | 144 | ##OS X Contacts 145 | 146 | If you have enabled [davvy's autodiscovery for Apple clients](#apples-custom-autodiscovery) you can simply setup a CardDAV account as follows: 147 | 148 | ```ini 149 | Account Type = CardDAV 150 | User Name = foo 151 | Password = bar 152 | Server Address = www.foo.org 153 | ``` 154 | 155 | Otherwise, your server address path should point to the specific user's addressbook: `www.foo.org/addressbooks/foo/`. 156 | 157 | Note: the trailing slash in the server address is mandatory when running in HTTPS mode. 158 | 159 | ##Mozilla Thunderbird 160 | 161 | Mozilla Thunderbird does not provide WebDAV support _out-of-the-box_, but provides service specific extensions. 162 | 163 | As a consequence, to manage your CalDAV calendars you'll need to install the [Lightning extension](https://addons.mozilla.org/it/thunderbird/addon/lightning/). 164 | 165 | Then, you can add a new "on the network" CalDAV calendar. 166 | Lightning does not provide any form of service discovery. So, you'll need to enter the full calendar path in the location field: _i.e._: 167 | ``http://remote:port/calendars/username/calendar_hash/``. 168 | 169 | #Testing 170 | 171 | This project makes use of the litmus tool for testing. 172 | -------------------------------------------------------------------------------- /davvy/__init__.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | from django.http import HttpResponse 3 | 4 | # global variables used for storing props callables and handlers 5 | props_get = {} 6 | props_set = {} 7 | 8 | 9 | def register_prop(name, handler_get, handler_set): 10 | """ 11 | register a property handler 12 | """ 13 | global props_get, props_set 14 | if handler_get: 15 | props_get[name] = handler_get 16 | if handler_set: 17 | props_set[name] = handler_set 18 | 19 | 20 | def retrieve_prop(name): 21 | """ 22 | retrieve a property handler 23 | """ 24 | handler_get, handler_set = None, None 25 | 26 | if name in props_get: 27 | handler_get = props_get[name] 28 | if name in props_set: 29 | handler_set = props_set[name] 30 | 31 | return (name, handler_get, handler_set) 32 | 33 | 34 | def xml_node(name, value=None): 35 | x = etree.Element(name) 36 | x.text = value 37 | return x 38 | 39 | 40 | def created(request): 41 | response = HttpResponse('Created', content_type='text/plain') 42 | response.status_code = 201 43 | response.reason_phrase = 'Created' 44 | response['Cache-Control'] = 'no-cache' 45 | return response 46 | 47 | 48 | def nocontent(request): 49 | response = HttpResponse('No Content', content_type='text/plain') 50 | response.status_code = 204 51 | response.reason_phrase = 'No Content' 52 | response['Cache-Control'] = 'no-cache' 53 | return response 54 | -------------------------------------------------------------------------------- /davvy/addressbook.py: -------------------------------------------------------------------------------- 1 | import davvy 2 | from davvy.base import WebDAV 3 | from django.http import HttpResponseForbidden, HttpResponse 4 | from django.conf import settings 5 | from lxml import etree 6 | 7 | 8 | class CardDAV(WebDAV): 9 | 10 | collection_type = [ 11 | '{urn:ietf:params:xml:ns:carddav}addressbook', '{DAV:}collection'] 12 | dav_extensions = ['addressbook'] 13 | 14 | def __init__(self, **kwargs): 15 | self.http_method_names += ['report'] 16 | super(CardDAV, self).__init__(**kwargs) 17 | 18 | def put(self, request, user, resource_name): 19 | if not request.META['CONTENT_TYPE'].startswith('text/vcard;') and request.META['CONTENT_TYPE'] != 'text/vcard': 20 | return HttpResponseForbidden() 21 | return super(CardDAV, self).put(request, user, resource_name) 22 | 23 | def _multiget_response(self, request, resource, href): 24 | try: 25 | scheme = request.scheme 26 | except: 27 | scheme = request.META['wsgi.url_scheme'] 28 | # temp hack, we need to find a better solution 29 | multistatus_response = davvy.xml_node('{DAV:}response') 30 | multistatus_response_href = davvy.xml_node( 31 | '{DAV:}href', scheme + '://' + request.META['HTTP_HOST'] + href) 32 | multistatus_response.append(multistatus_response_href) 33 | # add properties 34 | multistatus_response_propstat = davvy.xml_node('{DAV:}propstat') 35 | multistatus_response_propstat_prop = davvy.xml_node('{DAV:}prop') 36 | multistatus_response_propstat.append( 37 | multistatus_response_propstat_prop) 38 | 39 | multistatus_response_propstat_prop_address_data = davvy.xml_node( 40 | '{urn:ietf:params:xml:ns:carddav}address-data', ''.join(self.storage.retrieve(self, request, resource))) 41 | multistatus_response_propstat_prop.append( 42 | multistatus_response_propstat_prop_address_data) 43 | # contenttype 44 | multistatus_response_propstat_prop_get_contenttype = davvy.xml_node( 45 | '{DAV:}getcontenttype', resource.content_type) 46 | multistatus_response_propstat_prop.append( 47 | multistatus_response_propstat_prop_get_contenttype) 48 | 49 | # contenttype 50 | multistatus_response_propstat_prop_getetag = davvy.xml_node( 51 | '{DAV:}getetag', str(resource.updated_at.strftime('%s'))) 52 | multistatus_response_propstat_prop.append( 53 | multistatus_response_propstat_prop_getetag) 54 | 55 | # add status 56 | multistatus_response_propstat_status = davvy.xml_node( 57 | '{DAV:}status', request.META['SERVER_PROTOCOL'] + ' 200 OK') 58 | multistatus_response_propstat.append( 59 | multistatus_response_propstat_status) 60 | 61 | multistatus_response.append(multistatus_response_propstat) 62 | 63 | return multistatus_response 64 | 65 | def report(self, request, user, resource_name): 66 | resource = self.get_resource(request, user, resource_name) 67 | 68 | try: 69 | dom = etree.fromstring(request.read()) 70 | except: 71 | raise davvy.exceptions.BadRequest() 72 | 73 | # print etree.tostring(dom, pretty_print=True) 74 | 75 | doc = etree.Element('{DAV:}multistatus') 76 | 77 | if dom.tag == '{urn:ietf:params:xml:ns:carddav}addressbook-multiget': 78 | hrefs = dom.iterfind('{DAV:}href') 79 | for href in hrefs: 80 | resource = self.get_resource( 81 | request, user, href.text[len(request.path):]) 82 | if not resource.collection: 83 | doc.append( 84 | self._multiget_response(request, resource, href.text)) 85 | 86 | elif dom.tag in ('{urn:ietf:params:xml:ns:carddav}addressbook-query', '{DAV:}sync-collection'): 87 | for child in resource.resource_set.all(): 88 | doc.append(self._multiget_response( 89 | request, child, request.path.rstrip('/') + '/' + child.name)) 90 | else: 91 | raise davvy.exceptions.BadRequest() 92 | 93 | # print etree.tostring(doc, pretty_print=True) 94 | 95 | response = HttpResponse( 96 | etree.tostring(doc, pretty_print=True), content_type='text/xml; charset=utf-8') 97 | response.status_code = 207 98 | response.reason_phrase = 'Multi-Status' 99 | return response 100 | 101 | 102 | def prop_dav_addressbook_home_set(dav, request, resource): 103 | current_user_principal = getattr( 104 | settings, 'DAVVY_ADDRESSBOOK_HOME_SET_BASE', None) 105 | if current_user_principal is not None: 106 | if isinstance(current_user_principal, list) or isinstance(current_user_principal, tuple): 107 | for base in current_user_principal: 108 | yield davvy.xml_node('{DAV:}href', base.rstrip('/') + '/' + request.user.username) 109 | else: 110 | yield davvy.xml_node('{DAV:}href', current_user_principal.rstrip('/') + '/' + request.user.username) 111 | 112 | 113 | def prop_dav_supported_report_set(dav, request, resource): 114 | supported_reports = [] 115 | 116 | supported_report = davvy.xml_node('{DAV:}supported-report') 117 | report = davvy.xml_node('{DAV:}report') 118 | supported_report.append(report) 119 | addressbook_multiget = davvy.xml_node( 120 | '{urn:ietf:params:xml:ns:carddav}addressbook-multiget') 121 | report.append(addressbook_multiget) 122 | supported_reports.append(supported_report) 123 | 124 | supported_report = davvy.xml_node('{DAV:}supported-report') 125 | report = davvy.xml_node('{DAV:}report') 126 | supported_report.append(report) 127 | addressbook_query = davvy.xml_node( 128 | '{urn:ietf:params:xml:ns:carddav}addressbook-query') 129 | report.append(addressbook_query) 130 | supported_reports.append(supported_report) 131 | 132 | supported_report = davvy.xml_node('{DAV:}supported-report') 133 | report = davvy.xml_node('{DAV:}report') 134 | supported_report.append(report) 135 | calendar_query = davvy.xml_node( 136 | '{urn:ietf:params:xml:ns:caldav}calendar-query') 137 | report.append(calendar_query) 138 | supported_reports.append(supported_report) 139 | 140 | supported_report = davvy.xml_node('{DAV:}supported-report') 141 | report = davvy.xml_node('{DAV:}report') 142 | supported_report.append(report) 143 | calendar_multiget = davvy.xml_node( 144 | '{urn:ietf:params:xml:ns:caldav}calendar-multiget') 145 | report.append(calendar_multiget) 146 | supported_reports.append(supported_report) 147 | 148 | supported_report = davvy.xml_node('{DAV:}supported-report') 149 | report = davvy.xml_node('{DAV:}report') 150 | supported_report.append(report) 151 | sync_collection = davvy.xml_node('{DAV:}sync-collection') 152 | report.append(sync_collection) 153 | supported_reports.append(supported_report) 154 | 155 | return supported_reports 156 | 157 | davvy.register_prop('{urn:ietf:params:xml:ns:carddav}addressbook-home-set', 158 | prop_dav_addressbook_home_set, davvy.exceptions.Forbidden) 159 | davvy.register_prop('{DAV:}supported-report-set', 160 | prop_dav_supported_report_set, davvy.exceptions.Forbidden) 161 | -------------------------------------------------------------------------------- /davvy/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from davvy.models import * 3 | from django import forms 4 | 5 | 6 | class PropInline(admin.TabularInline): 7 | fields = ['resource', 'name', 'value'] 8 | model = Prop 9 | 10 | 11 | class ResourceAdminForm(forms.ModelForm): 12 | 13 | class Meta: 14 | model = Resource 15 | exclude = [] 16 | widgets = { 17 | 'file': forms.TextInput(attrs={'size': '64'}) 18 | } 19 | 20 | 21 | class ResourceAdmin(admin.ModelAdmin): 22 | list_display = ('__unicode__', 'displayname', 'user') 23 | form = ResourceAdminForm 24 | inlines = [PropInline] 25 | 26 | 27 | admin.site.register(Resource, ResourceAdmin) 28 | admin.site.register(Prop) 29 | -------------------------------------------------------------------------------- /davvy/base.py: -------------------------------------------------------------------------------- 1 | from django.http import StreamingHttpResponse, HttpResponse, HttpResponseForbidden 2 | from django.views.generic.base import View 3 | from django.views.decorators.csrf import csrf_exempt 4 | from django.contrib.auth.models import User 5 | from django.contrib.auth import authenticate, login 6 | import davvy 7 | from lxml import etree 8 | from django.utils.http import http_date 9 | from davvy.models import Resource 10 | import base64 11 | import types 12 | from django.conf import settings 13 | from storage import FSStorage 14 | from re import sub, compile 15 | from django.core.exceptions import ObjectDoesNotExist 16 | 17 | current_user_principals = [] 18 | user_regexp = compile(r"/(?P\w+)/$") 19 | 20 | import logging 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class WebDAV(View): 25 | 26 | http_method_names = ['get', 'put', 'propfind', 'delete', 27 | 'head', 'options', 'mkcol', 'proppatch', 'copy', 'move'] 28 | 29 | collection_type = '{DAV:}collection' 30 | subcollection_type = None 31 | 32 | # add your OPTIONS Dav header extensions here 33 | dav_extensions = [] 34 | 35 | root = None 36 | storage = None 37 | 38 | def __init__(self, **kwargs): 39 | super(WebDAV, self).__init__(**kwargs) 40 | if self.storage is None: 41 | self.storage = FSStorage() 42 | 43 | @csrf_exempt 44 | def dispatch(self, request, username, *args, **kwargs): 45 | user = None 46 | # REMOTE_USER should be always honored 47 | if 'REMOTE_USER' in request.META: 48 | user = User.objects.get(username=request.META['REMOTE_USER']) 49 | elif 'HTTP_AUTHORIZATION' in request.META: 50 | auth = request.META['HTTP_AUTHORIZATION'].split() 51 | if len(auth) == 2: 52 | if auth[0].lower() == "basic": 53 | uname, passwd = base64.b64decode(auth[1]).split(':') 54 | user = authenticate(username=uname, password=passwd) 55 | 56 | def _check_group_sharing(user, sharing_user): 57 | try: 58 | sharing_user = User.objects.get(username=username) 59 | return sharing_user.groups.all() & user.groups.all() 60 | except ObjectDoesNotExist: 61 | return None 62 | 63 | if (user and user.is_active) and ( 64 | user.username == username or _check_group_sharing(user, username)): 65 | login(request, user) 66 | request.user = user 67 | try: 68 | response = super(WebDAV, self).dispatch( 69 | request, username, *args, **kwargs 70 | ) 71 | dav_base = ['1'] 72 | dav_base += getattr(settings, 'DAVVY_EXTENSIONS', []) 73 | response['Dav'] = ','.join(dav_base + self.dav_extensions) 74 | except davvy.exceptions.DavException as e: 75 | code, phrase = e.status.split(' ', 1) 76 | response = HttpResponse(phrase, content_type='text/plain') 77 | response.status_code = int(code) 78 | response.reason_phrase = phrase 79 | else: 80 | response = HttpResponse('Unathorized', content_type='text/plain') 81 | response.status_code = 401 82 | response['WWW-Authenticate'] = 'Basic realm="davvy"' 83 | 84 | return response 85 | 86 | def options(self, request, user, resource_name): 87 | response = HttpResponse() 88 | response['Allow'] = ','.join( 89 | [method.upper() for method in self.http_method_names] 90 | ) 91 | return response 92 | 93 | def head(self, request, user, resource_name): 94 | resource = self.get_resource(request, user, resource_name) 95 | if resource.collection: 96 | return HttpResponseForbidden() 97 | response = HttpResponse(content_type=resource.content_type) 98 | response['Content-Length'] = resource.size 99 | response[ 100 | 'Content-Disposition'] = "attachment; filename=%s" % resource.name 101 | return response 102 | 103 | def get(self, request, user, resource_name): 104 | resource = self.get_resource(request, user, resource_name) 105 | if resource.collection: 106 | return HttpResponseForbidden() 107 | 108 | response = StreamingHttpResponse( 109 | self.storage.retrieve( 110 | self, request, resource 111 | ), 112 | content_type=resource.content_type 113 | ) 114 | 115 | response['Content-Length'] = resource.size 116 | response[ 117 | 'Content-Disposition'] = "attachment; filename=%s" % resource.name 118 | return response 119 | 120 | def delete(self, request, user, resource_name): 121 | resource = self.get_resource(request, user, resource_name) 122 | 123 | # you can't delete protected resources 124 | if resource.protected: 125 | return HttpResponseForbidden() 126 | 127 | depth = request.META.get('HTTP_DEPTH', 'infinity') 128 | # return forbidden if there are still items in the collection and 129 | # Depth is not 'infinity' 130 | # this is not standard-compliant, but should increase security 131 | if resource.collection and depth != 'infinity': 132 | if resource.resource_set.count() > 0: 133 | return HttpResponseForbidden() 134 | resource.delete() 135 | return HttpResponse(status=204) 136 | 137 | def _get_destination(self, request, user, resource_name): 138 | destination = request.META['HTTP_DESTINATION'] 139 | # ignore http(s) schema 140 | destination = sub(r"^http[s]*://", "", destination) 141 | 142 | base = request.META['HTTP_HOST'] + request.path[:-len(resource_name)] 143 | 144 | try: 145 | # destination user could be different 146 | destination_user = user_regexp.search( 147 | destination[:-len(resource_name)] 148 | ).group('user') 149 | 150 | # remove source user from base 151 | base = user_regexp.sub("/", base) 152 | if not destination.startswith(base): 153 | raise davvy.exceptions.BadGateway() 154 | except AttributeError: # if something goes wrong while catching the user 155 | destination_user = user 156 | finally: 157 | # return destination resource and related user 158 | return destination[len(base) + len(destination_user) + 1:].rstrip('/'), destination_user 159 | 160 | def move(self, request, user, resource_name): 161 | resource = self.get_resource(request, user, resource_name) 162 | # depth = request.META.get('HTTP_DEPTH', 'infinity') 163 | overwrite = request.META.get('HTTP_OVERWRITE', 'T') 164 | 165 | destination, destination_user = self._get_destination( 166 | request, user, resource_name) 167 | 168 | result = davvy.created 169 | 170 | try: 171 | resource2 = self.get_resource( 172 | request, destination_user, destination) 173 | if overwrite == 'F': 174 | raise davvy.exceptions.PreconditionFailed() 175 | elif overwrite == 'T': 176 | result = davvy.nocontent 177 | except davvy.exceptions.NotFound: 178 | resource2 = self.get_resource( 179 | request, destination_user, destination, create=True) 180 | 181 | # copy the resource 182 | resource2.collection = resource.collection 183 | resource2.uuid = resource.uuid 184 | resource2.size = resource.size 185 | resource2.content_type = resource.content_type 186 | resource2.created_at = resource.created_at 187 | resource2.save() 188 | 189 | # move properties 190 | for prop in resource.prop_set.all(): 191 | prop.resource = resource2 192 | prop.save() 193 | 194 | # move children 195 | if resource.collection: 196 | for child in resource.resource_set.all(): 197 | # first check for another child with the same attributes 198 | try: 199 | twin = Resource.objects.get( 200 | parent=resource2, name=child.name) 201 | if overwrite == 'T': 202 | twin.delete() 203 | raise Resource.DoesNotExist() 204 | else: 205 | raise davvy.exceptions.PreconditionFailed() 206 | except Resource.DoesNotExist: 207 | child.parent = resource2 208 | child.save() 209 | 210 | # destroy the old resource 211 | resource.delete() 212 | 213 | return result(request) 214 | 215 | def _copy_resource(self, request, resource, destination, overwrite): 216 | result = davvy.created 217 | 218 | try: 219 | resource2 = self.get_resource(request, resource.user, destination) 220 | if overwrite == 'F': 221 | raise davvy.exceptions.PreconditionFailed() 222 | elif overwrite == 'T': 223 | result = davvy.nocontent 224 | except davvy.exceptions.NotFound: 225 | resource2 = self.get_resource( 226 | request, resource.user, destination, create=True) 227 | 228 | # copy the resource 229 | resource2.collection = resource.collection 230 | resource2.uuid = resource.uuid 231 | resource2.size = resource.size 232 | resource2.content_type = resource.content_type 233 | resource2.created_at = resource.created_at 234 | resource2.save() 235 | 236 | # copy properties 237 | for prop in resource.prop_set.all(): 238 | prop.pk = None 239 | prop.parent = resource2 240 | prop.save() 241 | 242 | return result 243 | 244 | def _copy_coll(self, request, resource, destination, overwrite): 245 | result = self._copy_resource(request, resource, destination, overwrite) 246 | if resource.collection: 247 | for child in resource.resource_set.all(): 248 | self._copy_coll( 249 | request, child, destination + '/' + child.name, overwrite) 250 | return result 251 | 252 | def copy(self, request, user, resource_name): 253 | resource = self.get_resource(request, user, resource_name) 254 | overwrite = request.META.get('HTTP_OVERWRITE', 'T') 255 | depth = request.META.get('HTTP_DEPTH', 'infinity') 256 | 257 | destination = self._get_destination(request, resource_name) 258 | 259 | if resource.collection and depth == 'infinity': 260 | result = self._copy_coll(request, resource, destination, overwrite) 261 | else: 262 | result = self._copy_resource( 263 | request, resource, destination, overwrite) 264 | 265 | return result(request) 266 | 267 | def put(self, request, user, resource_name): 268 | resource = self.get_resource(request, user, resource_name, create=True) 269 | resource.content_type = request.META.get( 270 | 'CONTENT_TYPE', 'application/octet-stream' 271 | ) 272 | resource.size = request.META['CONTENT_LENGTH'] 273 | resource.save() 274 | self.storage.store(self, request, resource) 275 | return davvy.created(request) 276 | 277 | def mkcol(self, request, user, resource_name): 278 | cl = 0 279 | if 'CONTENT_LENGTH' in request.META and len(request.META['CONTENT_LENGTH']) > 0: 280 | cl = int(request.META['CONTENT_LENGTH']) 281 | if cl > 0: 282 | raise davvy.exceptions.UnsupportedMediaType() 283 | self.get_resource( 284 | request, user, resource_name, create=True, collection=True, strict=True 285 | ) 286 | return davvy.created(request) 287 | 288 | def _propfind_response(self, request, href, resource, requested_props): 289 | response_props = resource.properties(self, request, requested_props) 290 | multistatus_response = etree.Element('{DAV:}response') 291 | multistatus_response_href = etree.Element('{DAV:}href') 292 | 293 | if resource.collection: 294 | href = href.rstrip('/') + '/' 295 | try: 296 | scheme = request.scheme 297 | except: 298 | scheme = request.META['wsgi.url_scheme'] 299 | 300 | multistatus_response_href.text = scheme + \ 301 | '://' + request.META['HTTP_HOST'] + href 302 | multistatus_response.append(multistatus_response_href) 303 | 304 | for prop in response_props: 305 | propstat = etree.Element('{DAV:}propstat') 306 | multistatus_response.append(propstat) 307 | tag, value, status = prop 308 | prop_element = etree.Element('{DAV:}prop') 309 | prop_element_item = etree.Element(tag) 310 | if isinstance(value, etree._Element): 311 | prop_element_item.append(value) 312 | elif isinstance(value, list) or isinstance(value, types.GeneratorType): 313 | for item in value: 314 | prop_element_item.append(item) 315 | else: 316 | if value != '': 317 | prop_element_item.text = value 318 | prop_element.append(prop_element_item) 319 | propstat.append(prop_element) 320 | propstat_status = etree.Element('{DAV:}status') 321 | propstat_status.text = request.META[ 322 | 'SERVER_PROTOCOL'] + ' ' + status 323 | propstat.append(propstat_status) 324 | 325 | return multistatus_response 326 | 327 | def _proppatch_response(self, request, href, resource, requested_props): 328 | multistatus_response = etree.Element('{DAV:}response') 329 | multistatus_response_href = etree.Element('{DAV:}href') 330 | if resource.collection: 331 | href = href.rstrip('/') + '/' 332 | multistatus_response_href.text = href 333 | multistatus_response.append(multistatus_response_href) 334 | for prop in requested_props: 335 | propstat = etree.Element('{DAV:}propstat') 336 | multistatus_response.append(propstat) 337 | tag, status = prop 338 | prop_element = etree.Element('{DAV:}prop') 339 | prop_element.append(etree.Element(tag)) 340 | propstat.append(prop_element) 341 | propstat_status = etree.Element('{DAV:}status') 342 | propstat_status.text = request.META[ 343 | 'SERVER_PROTOCOL'] + ' ' + status 344 | propstat.append(propstat_status) 345 | 346 | return multistatus_response 347 | 348 | def propfind(self, request, user, resource_name): 349 | return self._propfinder(request, user, resource_name) 350 | 351 | def _propfinder(self, request, user, resource_name, shared=False): 352 | resource = self.get_resource(request, user, resource_name) 353 | 354 | try: 355 | dom = etree.fromstring(request.read()) 356 | except: 357 | raise davvy.exceptions.BadRequest() 358 | 359 | logger.debug(etree.tostring(dom, pretty_print=True)) 360 | 361 | props = dom.find('{DAV:}prop') 362 | requested_props = [prop.tag for prop in props] 363 | depth = request.META.get('HTTP_DEPTH', 'infinity') 364 | 365 | doc = etree.Element('{DAV:}multistatus') 366 | 367 | multistatus_response = self._propfind_response( 368 | request, 369 | request.path, 370 | resource, 371 | requested_props 372 | ) 373 | doc.append(multistatus_response) 374 | 375 | if depth == '1': 376 | resources = Resource.objects.filter(parent=resource) 377 | 378 | if shared: # we skip it if unnecessary 379 | # add shared resources from groups 380 | shared_resources = Resource.objects.filter( 381 | groups=request.user.groups.all() 382 | ) 383 | 384 | # consider only shared resources having the same progenitor 385 | # so, if resource is a calendar, only calendars, and so on... 386 | resource_progenitor = resource.progenitor.name if resource.progenitor else self.root 387 | shared_resources_id = [r.id 388 | for r in shared_resources 389 | if r.progenitor.name == resource_progenitor 390 | ] 391 | 392 | resources |= shared_resources.filter( 393 | id__in=shared_resources_id) 394 | 395 | for resource in resources: 396 | multistatus_response = self._propfind_response( 397 | request, 398 | sub( 399 | r"%s$" % (user), 400 | "%s" % (resource.user), 401 | request.path.rstrip("/") 402 | ) + "/" + resource.name, 403 | resource, 404 | requested_props 405 | ) 406 | doc.append(multistatus_response) 407 | 408 | logger.debug("%s", etree.tostring(doc, pretty_print=True)) 409 | 410 | response = HttpResponse( 411 | etree.tostring(doc, pretty_print=True), 412 | content_type='text/xml; charset=utf-8' 413 | ) 414 | 415 | response.status_code = 207 416 | response.reason_phrase = 'Multi-Status' 417 | return response 418 | 419 | def proppatch(self, request, user, resource_name): 420 | resource = self.get_resource(request, user, resource_name) 421 | 422 | try: 423 | dom = etree.fromstring(request.read()) 424 | except: 425 | raise davvy.exceptions.BadRequest() 426 | 427 | # print etree.tostring(dom, pretty_print=True) 428 | 429 | requested_props = [] 430 | 431 | for setremove_item in dom: 432 | props = setremove_item.find('{DAV:}prop') 433 | if props is None: 434 | props = [] 435 | # top-down must be respected 436 | for prop in props: 437 | if setremove_item.tag == '{DAV:}set': 438 | try: 439 | resource.set_prop(self, request, prop.tag, prop) 440 | requested_props.append((prop.tag, '200 OK')) 441 | except davvy.exceptions.DavException as e: 442 | requested_props.append((prop.tag, e.status)) 443 | elif setremove_item.tag == '{DAV:}remove': 444 | try: 445 | resource.del_prop(self, request, prop.tag) 446 | requested_props.append((prop.tag, '200 OK')) 447 | except davvy.exceptions.DavException as e: 448 | requested_props.append((prop.tag, e.status)) 449 | 450 | doc = etree.Element('{DAV:}multistatus') 451 | 452 | multistatus_response = self._proppatch_response( 453 | request, request.path, resource, requested_props) 454 | doc.append(multistatus_response) 455 | 456 | # print etree.tostring(doc, pretty_print=True) 457 | 458 | response = HttpResponse( 459 | etree.tostring(doc, pretty_print=True), content_type='text/xml; charset=utf-8') 460 | response.status_code = 207 461 | response.reason_phrase = 'Multi-Status' 462 | return response 463 | 464 | def _get_root(self, user): 465 | try: 466 | resource = Resource.objects.get( 467 | name=self.root, user=user, parent=None, collection=True) 468 | except: 469 | resource = Resource.objects.create( 470 | name=self.root, user=user, parent=None, collection=True) 471 | return resource 472 | 473 | def get_resource(self, request, user, name, create=False, collection=False, strict=False): 474 | resource_user = User.objects.get(username=user) 475 | # remove final slashes 476 | name = name.rstrip('/') 477 | parent = self._get_root(resource_user) 478 | 479 | if not name: 480 | return parent 481 | # split the name 482 | parts = name.split('/') 483 | 484 | # skip the last item 485 | # on error, returns conflict 486 | # returns root in case of '/' 487 | for part in parts[:-1]: 488 | try: 489 | resource_part = Resource.objects.get( 490 | user=resource_user, parent=parent, name=part 491 | ) 492 | if not resource_part.collection: 493 | raise Resource.DoesNotExist() 494 | except Resource.DoesNotExist: 495 | raise davvy.exceptions.Conflict() 496 | parent = resource_part 497 | 498 | # now check for the requested item 499 | try: 500 | resource = Resource.objects.get( 501 | user=resource_user, parent=parent, name=parts[-1] 502 | ) 503 | if strict and create: 504 | raise davvy.exceptions.AlreadyExists() 505 | except Resource.DoesNotExist: 506 | if create: 507 | resource = Resource.objects.create( 508 | user=resource_user, parent=parent, name=parts[ 509 | -1], collection=collection 510 | ) 511 | else: 512 | raise davvy.exceptions.NotFound() 513 | return resource 514 | 515 | 516 | def prop_dav_resourcetype(dav, request, resource): 517 | if resource.collection: 518 | # is it a subcollection ? 519 | if resource.parent and dav.subcollection_type is not None: 520 | if isinstance(dav.subcollection_type, list): 521 | rtypes = [] 522 | for rtype in dav.subcollection_type: 523 | rtypes.append(davvy.xml_node(rtype)) 524 | return rtypes 525 | return davvy.xml_node(dav.subcollection_type) 526 | if isinstance(dav.collection_type, list): 527 | rtypes = [] 528 | for rtype in dav.collection_type: 529 | rtypes.append(davvy.xml_node(rtype)) 530 | return rtypes 531 | return davvy.xml_node(dav.collection_type) 532 | return '' 533 | 534 | 535 | def prop_dav_getcontentlength(dav, request, resource): 536 | if not resource.collection: 537 | try: 538 | return str(resource.size) 539 | except: 540 | return '0' 541 | 542 | 543 | def prop_dav_getetag(dav, request, resource): 544 | return str(resource.updated_at.strftime('%s')) 545 | 546 | 547 | def prop_dav_getcontenttype(dav, request, resource): 548 | if not resource.collection: 549 | return resource.content_type 550 | return 'httpd/unix-directory' 551 | 552 | 553 | def prop_dav_getlastmodified(dav, request, resource): 554 | return http_date(int(resource.updated_at.strftime('%s'))) 555 | 556 | 557 | def prop_dav_creationdate(dav, request, resource): 558 | return http_date(int(resource.created_at.strftime('%s'))) 559 | 560 | 561 | def prop_dav_current_user_principal(dav, request, resource): 562 | current_user_principal = getattr( 563 | settings, 'DAVVY_CURRENT_USER_PRINCIPAL_BASE', None 564 | ) 565 | if current_user_principal is not None: 566 | if isinstance(current_user_principal, list) or isinstance(current_user_principal, tuple): 567 | for base in current_user_principal: 568 | yield davvy.xml_node( 569 | '{DAV:}href', 570 | base.rstrip('/') + '/' + request.user.username + '/' 571 | ) 572 | else: 573 | yield davvy.xml_node( 574 | '{DAV:}href', 575 | current_user_principal.rstrip( 576 | '/') + '/' + request.user.username + '/' 577 | ) 578 | 579 | 580 | def prop_dav_current_user_privilege_set(dav, request, resource): 581 | write = davvy.xml_node('{DAV:}privilege') 582 | write.append(davvy.xml_node('{DAV:}all')) 583 | write.append(davvy.xml_node('{DAV:}read')) 584 | write.append(davvy.xml_node('{DAV:}write')) 585 | write.append(davvy.xml_node('{DAV:}write-properties')) 586 | write.append(davvy.xml_node('{DAV:}write-content')) 587 | return write 588 | 589 | 590 | def prop_dav_acl(dav, request, resource): 591 | ace = davvy.xml_node('{DAV:}ace') 592 | ace_principal = davvy.xml_node('{DAV:}principal') 593 | ace_principal.append(davvy.xml_node('{DAV:}all')) 594 | #principals = prop_dav_current_user_principal(dav, request, resource) 595 | # for principal in principals: 596 | # ace_principal.append(principal) 597 | ace.append(ace_principal) 598 | grant = davvy.xml_node('{DAV:}grant') 599 | privilege = davvy.xml_node('{DAV:}privilege') 600 | privilege.append(davvy.xml_node('{DAV:}write')) 601 | grant.append(privilege) 602 | ace.append(grant) 603 | return ace 604 | 605 | 606 | def prop_dav_owner(dav, request, resource): 607 | return prop_dav_current_user_principal(dav, request, resource) 608 | 609 | davvy.register_prop( 610 | '{DAV:}resourcetype', 611 | prop_dav_resourcetype, 612 | davvy.exceptions.Forbidden) 613 | 614 | davvy.register_prop( 615 | '{DAV:}getcontentlength', 616 | prop_dav_getcontentlength, 617 | davvy.exceptions.Forbidden) 618 | 619 | davvy.register_prop( 620 | '{DAV:}getetag', 621 | prop_dav_getetag, 622 | davvy.exceptions.Forbidden) 623 | 624 | davvy.register_prop( 625 | '{DAV:}getcontenttype', 626 | prop_dav_getcontenttype, 627 | davvy.exceptions.Forbidden) 628 | 629 | davvy.register_prop( 630 | '{DAV:}getlastmodified', 631 | prop_dav_getlastmodified, 632 | davvy.exceptions.Forbidden) 633 | 634 | davvy.register_prop( 635 | '{DAV:}creationdate', 636 | prop_dav_creationdate, 637 | davvy.exceptions.Forbidden) 638 | 639 | davvy.register_prop( 640 | '{DAV:}current-user-principal', 641 | prop_dav_current_user_principal, 642 | davvy.exceptions.Forbidden) 643 | 644 | davvy.register_prop( 645 | '{DAV:}principal-URL', 646 | prop_dav_current_user_principal, 647 | davvy.exceptions.Forbidden) 648 | 649 | davvy.register_prop( 650 | '{DAV:}current-user-privilege-set', 651 | prop_dav_current_user_privilege_set, 652 | davvy.exceptions.Forbidden) 653 | 654 | davvy.register_prop( 655 | '{DAV:}acl', 656 | prop_dav_acl, 657 | davvy.exceptions.Forbidden) 658 | 659 | # davvy.register_prop( 660 | # '{DAV:}sync-token', 661 | # prop_dav_getetag, 662 | # davvy.exceptions.Forbidden) 663 | 664 | davvy.register_prop( 665 | '{DAV:}owner', 666 | prop_dav_owner, 667 | davvy.exceptions.Forbidden) 668 | -------------------------------------------------------------------------------- /davvy/calendar.py: -------------------------------------------------------------------------------- 1 | import davvy 2 | from davvy.base import WebDAV 3 | from django.http import HttpResponseForbidden, HttpResponse 4 | from django.conf import settings 5 | from lxml import etree 6 | 7 | import logging 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class CalDAV(WebDAV): 12 | 13 | #collection_type = ['{urn:ietf:params:xml:ns:caldav}calendar', '{DAV:}collection'] 14 | subcollection_type = [ 15 | '{urn:ietf:params:xml:ns:caldav}calendar', '{DAV:}collection'] 16 | dav_extensions = ['calendar-access', 'calendar'] 17 | 18 | def __init__(self, **kwargs): 19 | self.http_method_names = WebDAV.http_method_names + \ 20 | ['mkcalendar', 'report'] 21 | super(CalDAV, self).__init__(**kwargs) 22 | 23 | def propfind(self, request, user, resource_name): 24 | return super(CalDAV, self)._propfinder(request, user, resource_name, shared=True) 25 | 26 | def put(self, request, user, resource_name): 27 | if not request.META['CONTENT_TYPE'].startswith('text/calendar;') and request.META['CONTENT_TYPE'] != 'text/calendar': 28 | return HttpResponseForbidden() 29 | return super(CalDAV, self).put(request, user, resource_name) 30 | 31 | def mkcalendar(self, request, user, resource_name): 32 | 33 | resource = self.get_resource( 34 | request, user, resource_name, create=True, collection=True, strict=True 35 | ) 36 | 37 | cl = int(request.META.get('CONTENT_LENGTH', '0')) 38 | if cl > 0: 39 | try: 40 | dom = etree.fromstring(request.read()) 41 | except: 42 | raise davvy.exceptions.BadRequest() 43 | 44 | # logger.debug(etree.tostring(dom, pretty_print=True)) 45 | 46 | for prop in dom.find('{DAV:}set').find('{DAV:}prop'): 47 | try: 48 | resource.set_prop(self, request, prop.tag, prop) 49 | except davvy.exceptions.Forbidden: 50 | pass 51 | 52 | doc = etree.Element( 53 | '{urn:ietf:params:xml:ns:caldav}mkcalendar-response') 54 | doc_propstat = etree.Element('{DAV:}propstat') 55 | doc_propstat_status = etree.Element('{DAV:}status') 56 | doc_propstat_status.text = request.META[ 57 | 'SERVER_PROTOCOL'] + ' 200 OK' 58 | doc_propstat.append(doc_propstat_status) 59 | doc.append(doc_propstat) 60 | 61 | response = HttpResponse( 62 | etree.tostring(doc, pretty_print=True), content_type='text/xml; charset=utf-8') 63 | else: 64 | response = HttpResponse() 65 | response.status_code = 201 66 | response.reason_phrase = 'Created' 67 | response['Cache-Control'] = 'no-cache' 68 | return response 69 | 70 | def _multiget_response(self, request, resource, href, report_type='response'): 71 | try: 72 | scheme = request.scheme 73 | except: 74 | scheme = request.META['wsgi.url_scheme'] 75 | multistatus_response = davvy.xml_node('{DAV:}' + report_type) 76 | # temp hack, we need to find a better solution 77 | if resource.collection: 78 | href = href.rstrip('/') + '/' 79 | multistatus_response_href = davvy.xml_node( 80 | '{DAV:}href', scheme + '://' + request.META['HTTP_HOST'] + href 81 | ) 82 | multistatus_response.append(multistatus_response_href) 83 | # add properties 84 | multistatus_response_propstat = davvy.xml_node('{DAV:}propstat') 85 | multistatus_response_propstat_prop = davvy.xml_node('{DAV:}prop') 86 | multistatus_response_propstat.append( 87 | multistatus_response_propstat_prop) 88 | if not resource.collection: 89 | multistatus_response_propstat_prop_calendar_data = davvy.xml_node( 90 | '{urn:ietf:params:xml:ns:caldav}calendar-data', ''.join(self.storage.retrieve(self, request, resource))) 91 | multistatus_response_propstat_prop.append( 92 | multistatus_response_propstat_prop_calendar_data) 93 | multistatus_response_propstat_prop_get_contenttype = davvy.xml_node( 94 | '{DAV:}getcontenttype', resource.content_type) 95 | multistatus_response_propstat_prop.append( 96 | multistatus_response_propstat_prop_get_contenttype) 97 | # contenttype 98 | multistatus_response_propstat_prop_getetag = davvy.xml_node( 99 | '{DAV:}getetag', str(resource.updated_at.strftime('%s'))) 100 | multistatus_response_propstat_prop.append( 101 | multistatus_response_propstat_prop_getetag) 102 | else: 103 | multistatus_response_propstat_prop_get_contenttype = davvy.xml_node( 104 | '{DAV:}getcontenttype', 'httpd/unix-directory') 105 | multistatus_response_propstat_prop.append( 106 | multistatus_response_propstat_prop_get_contenttype) 107 | 108 | # add status 109 | multistatus_response_propstat_status = davvy.xml_node( 110 | '{DAV:}status', request.META['SERVER_PROTOCOL'] + ' 200 OK') 111 | multistatus_response_propstat.append( 112 | multistatus_response_propstat_status) 113 | 114 | multistatus_response.append(multistatus_response_propstat) 115 | 116 | return multistatus_response 117 | 118 | def get_href(self, href, resource_name): 119 | # find first occurrence of resource_name 120 | pos = href.find(resource_name) 121 | return href[pos:] 122 | 123 | def report(self, request, user, resource_name): 124 | resource = self.get_resource(request, user, resource_name) 125 | 126 | try: 127 | dom = etree.fromstring(request.read()) 128 | except: 129 | raise davvy.exceptions.BadRequest() 130 | 131 | # print etree.tostring(dom, pretty_print=True) 132 | 133 | doc = etree.Element('{DAV:}multistatus') 134 | 135 | if dom.tag == '{urn:ietf:params:xml:ns:caldav}calendar-query': 136 | doc.append( 137 | self._multiget_response(request, resource, request.path)) 138 | for child in resource.resource_set.all(): 139 | doc.append(self._multiget_response( 140 | request, child, request.path.rstrip('/') + '/' + child.name)) 141 | elif dom.tag == '{DAV:}sync-collection': 142 | doc.append(self._multiget_response( 143 | request, resource, request.path, 'sync-response')) 144 | for child in resource.resource_set.all(): 145 | doc.append(self._multiget_response( 146 | request, child, request.path.rstrip('/') + '/' + child.name, 'sync-response') 147 | ) 148 | 149 | doc.append(davvy.xml_node( 150 | '{DAV:}sync-token', prop_dav_calendar_getctag(self, request, resource)) 151 | ) 152 | elif dom.tag == '{urn:ietf:params:xml:ns:caldav}calendar-multiget': 153 | hrefs = dom.iterfind('{DAV:}href') 154 | for href in hrefs: 155 | child = self.get_resource( 156 | request, user, self.get_href(href.text, resource_name)) 157 | doc.append(self._multiget_response(request, child, href.text)) 158 | else: 159 | raise davvy.exceptions.BadRequest() 160 | 161 | # print etree.tostring(doc, pretty_print=True) 162 | 163 | response = HttpResponse( 164 | etree.tostring(doc, pretty_print=True), content_type='text/xml; charset=utf-8') 165 | response.status_code = 207 166 | response.reason_phrase = 'Multi-Status' 167 | return response 168 | 169 | 170 | def prop_dav_calendar_home_set(dav, request, resource): 171 | current_user_principal = getattr( 172 | settings, 'DAVVY_CALENDAR_HOME_SET_BASE', None 173 | ) 174 | if current_user_principal is not None: 175 | if isinstance(current_user_principal, list) or isinstance(current_user_principal, tuple): 176 | for base in current_user_principal: 177 | yield davvy.xml_node('{DAV:}href', base.rstrip('/') + '/' + request.user.username + '/') 178 | else: 179 | yield davvy.xml_node('{DAV:}href', current_user_principal.rstrip('/') + '/' + request.user.username + '/') 180 | 181 | 182 | def prop_dav_calendar_getctag(dav, request, resource): 183 | max_value = int(resource.updated_at.strftime('%s')) 184 | if resource.collection: 185 | for child in resource.resource_set.all(): 186 | new_value = int(child.updated_at.strftime('%s')) 187 | if new_value > max_value: 188 | max_value = new_value 189 | return str(max_value) 190 | 191 | 192 | def prop_dav_calendar_user_address_set(dav, request, resource): 193 | yield davvy.xml_node('{DAV:}href', 'mailto:' + request.user.email) 194 | 195 | 196 | davvy.register_prop( 197 | '{urn:ietf:params:xml:ns:caldav}calendar-home-set', 198 | prop_dav_calendar_home_set, 199 | davvy.exceptions.Forbidden) 200 | 201 | davvy.register_prop( 202 | '{http://calendarserver.org/ns/}getctag', 203 | prop_dav_calendar_getctag, 204 | davvy.exceptions.Forbidden) 205 | 206 | davvy.register_prop( 207 | '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set', 208 | prop_dav_calendar_user_address_set, 209 | davvy.exceptions.Forbidden) 210 | -------------------------------------------------------------------------------- /davvy/exceptions.py: -------------------------------------------------------------------------------- 1 | class DavException(Exception): 2 | status = "500 Internal Server Error" 3 | 4 | 5 | class NotFound(DavException): 6 | status = "404 Not Found" 7 | 8 | 9 | class Forbidden(DavException): 10 | status = "403 Forbidden" 11 | 12 | 13 | class AlreadyExists(DavException): 14 | status = "405 Method Not Allowed" 15 | 16 | 17 | class Conflict(DavException): 18 | status = "409 Conflict" 19 | 20 | 21 | class UnsupportedMediaType(DavException): 22 | status = "415 Unsupported Media Type" 23 | 24 | 25 | class BadRequest(DavException): 26 | status = "400 Bad Request" 27 | 28 | 29 | class BadGateway(DavException): 30 | status = "502 Bad Gateway" 31 | 32 | 33 | class PreconditionFailed(DavException): 34 | status = "412 Precondition Failed" 35 | -------------------------------------------------------------------------------- /davvy/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User, Group 3 | import uuid 4 | import davvy 5 | import davvy.exceptions 6 | from lxml import etree 7 | 8 | # Create your models here. 9 | 10 | 11 | class Resource(models.Model): 12 | 13 | def generate_uuid(): 14 | return str(uuid.uuid4()) 15 | 16 | user = models.ForeignKey(User) 17 | groups = models.ManyToManyField(Group, null=True, blank=True) 18 | parent = models.ForeignKey('Resource', null=True, blank=True) 19 | name = models.CharField(max_length=255) 20 | collection = models.BooleanField(default=False) 21 | uuid = models.CharField(max_length=36, default=generate_uuid) 22 | content_type = models.CharField(max_length=255, blank=True, null=True) 23 | created_at = models.DateTimeField(auto_now_add=True) 24 | updated_at = models.DateTimeField(auto_now=True) 25 | size = models.BigIntegerField(default=0) 26 | protected = models.BooleanField(default=False) 27 | 28 | # pretty ugly, but should help viewing the full names 29 | def __unicode__(self): 30 | parts = [] 31 | parent = self.parent 32 | while True: 33 | if not parent: 34 | break 35 | parts.insert(0, Resource.objects.get(pk=parent.id).name) 36 | parent = parent.parent 37 | parts.append(self.name) 38 | return '/' + '/'.join(parts) 39 | 40 | def del_prop(self, dav, request, name): 41 | try: 42 | model_prop = self.prop_set.get(name=name) 43 | model_prop.delete() 44 | except Prop.DoesNotExist: 45 | # removing a non existent property is not an error 46 | pass 47 | 48 | def get_prop(self, dav, request, name): 49 | if name in davvy.props_get: 50 | value = davvy.props_get[name](dav, request, self) 51 | if value is not None: 52 | return value 53 | raise davvy.exceptions.Forbidden() 54 | 55 | try: 56 | model_prop = self.prop_set.get(name=name) 57 | if model_prop.is_xml: 58 | return etree.fromstring(model_prop.value) 59 | return model_prop.value 60 | except Prop.DoesNotExist: 61 | raise davvy.exceptions.NotFound() 62 | 63 | def set_prop(self, dav, request, name, value): 64 | if name in davvy.props_set: 65 | e = davvy.props_set[name](dav, request, self, value) 66 | if isinstance(e, Exception): 67 | raise e 68 | else: 69 | try: 70 | prop = self.prop_set.get(name=name) 71 | except Prop.DoesNotExist: 72 | prop = self.prop_set.create(name=name) 73 | 74 | if len(value): 75 | prop.value = '\n'.join( 76 | [etree.tostring(children, pretty_print=True) 77 | for children 78 | in value] 79 | ) 80 | prop.is_xml = True 81 | elif value.text is not None: 82 | prop.value = value.text 83 | prop.is_xml = False 84 | 85 | prop.save() 86 | return self.get_prop(dav, request, name) 87 | 88 | @property 89 | def displayname(self): 90 | try: 91 | prop = self.prop_set.get(name='{DAV:}displayname') 92 | return prop.value 93 | except: 94 | return '' 95 | 96 | @property 97 | def progenitor(self): 98 | parent = self.parent 99 | while parent and parent.parent: 100 | parent = parent.parent 101 | return parent 102 | 103 | def properties(self, dav, request, requested_props): 104 | propstat = [] 105 | for prop in requested_props: 106 | try: 107 | value = self.get_prop(dav, request, prop) 108 | status = '200 OK' 109 | except Exception as e: 110 | value = None 111 | if hasattr(e, 'status'): 112 | status = e.status 113 | else: 114 | status = '500 Internal Server Error' 115 | propstat.append((prop, ) + (value, status)) 116 | return propstat 117 | 118 | class Meta: 119 | unique_together = ('user', 'parent', 'name') 120 | 121 | 122 | class Prop(models.Model): 123 | 124 | resource = models.ForeignKey(Resource) 125 | name = models.CharField(max_length=255) 126 | value = models.TextField(blank=True, null=True) 127 | is_xml = models.BooleanField(default=False) 128 | 129 | def __unicode__(self): 130 | return self.name 131 | 132 | class Meta: 133 | unique_together = ('resource', 'name') 134 | -------------------------------------------------------------------------------- /davvy/storage.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | import os.path 3 | 4 | 5 | class FSStorage(object): 6 | 7 | def __init__(self, home=None): 8 | self.home = home 9 | if not self.home: 10 | self.home = settings.DAVVY_STORAGE_PATH 11 | 12 | def store(self, dav, request, resource, chunk_size=32768): 13 | directory = os.path.join(self.home, str(resource.user.pk)) 14 | if not os.path.exists(directory): 15 | os.mkdir(directory) 16 | filename = os.path.join(directory, resource.uuid) 17 | f = open(filename, 'w') 18 | cl = long(resource.size) 19 | while cl > 0: 20 | chunk = request.read(min(cl, chunk_size)) 21 | if len(chunk) == 0: 22 | break 23 | f.write(chunk) 24 | cl -= len(chunk) 25 | f.close() 26 | 27 | def retrieve(self, dav, request, resource, chunk_size=32768): 28 | class FSIterable(object): 29 | 30 | def __init__(self, path, size, chunk_size): 31 | self.file = open(path, 'r') 32 | self.size = size 33 | self.chunk_size = chunk_size 34 | 35 | def __iter__(self): 36 | return self 37 | 38 | def next(self): 39 | if self.size <= 0: 40 | raise StopIteration 41 | chunk = self.file.read(min(self.size, self.chunk_size)) 42 | self.size -= len(chunk) 43 | return chunk 44 | __next__ = next 45 | filename = os.path.join( 46 | self.home, str(resource.user.pk), resource.uuid) 47 | return FSIterable(filename, resource.size, chunk_size) 48 | -------------------------------------------------------------------------------- /davvy/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from django.test import RequestFactory 4 | from davvy.base import WebDAV 5 | 6 | from django.test.utils import override_settings 7 | from django.contrib.auth.models import User 8 | import base64 9 | from django.contrib.sessions.middleware import SessionMiddleware 10 | import os.path 11 | 12 | # Create your tests here. 13 | 14 | 15 | @override_settings(DAVVY_STORAGE_PATH='/tmp') 16 | class WebDAVTestCase(TestCase): 17 | 18 | def setUp(self): 19 | self.username = 'tester' 20 | self.view = WebDAV.as_view(root='tests') 21 | self.user = User.objects.create_user( 22 | self.username, 'test@test', self.username) 23 | self.auth = 'Basic ' + \ 24 | base64.b64encode('{}:{}'.format(self.username, self.username)) 25 | self.middleware = SessionMiddleware() 26 | self.factory = RequestFactory() 27 | self.base = '/principal/' + self.username 28 | 29 | def test_putandget(self, resource_name='services'): 30 | uri = os.path.join(self.base, resource_name) 31 | request = self.factory.put(uri, 32 | content_type='text/plain', 33 | data=open('/etc/services').read(), 34 | HTTP_AUTHORIZATION=self.auth, 35 | ) 36 | self.middleware.process_request(request) 37 | response = self.view(request, self.username, resource_name) 38 | self.assertEqual(response.status_code, 201) 39 | 40 | request = self.factory.get(uri, HTTP_AUTHORIZATION=self.auth) 41 | self.middleware.process_request(request) 42 | response = self.view(request, self.username, resource_name) 43 | self.assertEqual(response.status_code, 200) 44 | self.assertEqual(response['Content-Type'], 'text/plain') 45 | self.assertEqual( 46 | ''.join(response.streaming_content), open('/etc/services').read()) 47 | 48 | def test_mkcol(self, resource_name='coll'): 49 | uri = os.path.join(self.base, resource_name) 50 | request = self.factory.generic('MKCOL', uri, 51 | HTTP_AUTHORIZATION=self.auth, 52 | ) 53 | self.middleware.process_request(request) 54 | response = self.view(request, self.username, resource_name) 55 | self.assertEqual(response.status_code, 201) 56 | 57 | self.test_putandget(resource_name + '/services') 58 | 59 | def test_delete_not_empty_coll(self): 60 | self.test_mkcol('coll2') 61 | uri = os.path.join(self.base, 'coll2') 62 | request = self.factory.generic('DELETE', uri, 63 | HTTP_AUTHORIZATION=self.auth, 64 | HTTP_DEPTH='0', 65 | ) 66 | self.middleware.process_request(request) 67 | response = self.view(request, self.username, 'coll2') 68 | self.assertEqual(response.status_code, 403) 69 | 70 | def test_delete_coll(self): 71 | self.test_mkcol('coll3') 72 | uri = os.path.join(self.base, 'coll3') 73 | request = self.factory.generic('DELETE', uri, 74 | HTTP_AUTHORIZATION=self.auth, 75 | ) 76 | self.middleware.process_request(request) 77 | response = self.view(request, self.username, 'coll3') 78 | self.assertEqual(response.status_code, 200) 79 | 80 | def test_delete_resource(self, resource_name='deleteme'): 81 | self.test_putandget(resource_name) 82 | uri = os.path.join(self.base, resource_name) 83 | request = self.factory.generic('DELETE', uri, 84 | HTTP_AUTHORIZATION=self.auth, 85 | ) 86 | self.middleware.process_request(request) 87 | response = self.view(request, self.username, resource_name) 88 | self.assertEqual(response.status_code, 200) 89 | -------------------------------------------------------------------------------- /davvy/wellknown.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.conf import settings 3 | from django.views.decorators.csrf import csrf_exempt 4 | from django.contrib.auth import authenticate, login 5 | from django.contrib.auth.models import User 6 | import base64 7 | 8 | import davvy 9 | from davvy.base import WebDAV 10 | 11 | import logging 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class WellKnownDAV(WebDAV): 16 | 17 | @csrf_exempt 18 | def dispatch(self, request, *args, **kwargs): 19 | 20 | user = None 21 | if 'REMOTE_USER' in request.META: 22 | user = User.objects.get(username=request.META['REMOTE_USER']) 23 | elif 'HTTP_AUTHORIZATION' in request.META: 24 | auth = request.META['HTTP_AUTHORIZATION'].split() 25 | if len(auth) == 2: 26 | if auth[0].lower() == "basic": 27 | uname, passwd = base64.b64decode(auth[1]).split(':') 28 | user = authenticate(username=uname, password=passwd) 29 | 30 | if (user and user.is_active): 31 | login(request, user) 32 | request.user = user 33 | 34 | # choose the correct current-user-principal handler (calendars/addressbooks) 35 | if self.root == "calendars": 36 | from davvy.calendar import prop_dav_calendar_home_set as prop_dav_resource_home_set 37 | elif self.root == "addressbook001": 38 | from davvy.addressbook import prop_dav_addressbook_home_set as prop_dav_resource_home_set 39 | else: 40 | from davvy.base import prop_dav_current_user_principal as prop_dav_resource_home_set 41 | 42 | old_cup_prop = None 43 | if prop_dav_resource_home_set: 44 | # let's backup current-user-principal handler 45 | cup = '{DAV:}current-user-principal' 46 | old_cup_prop = davvy.retrieve_prop( 47 | cup 48 | ) 49 | 50 | # let's modify it 51 | davvy.register_prop( 52 | cup, 53 | prop_dav_resource_home_set, 54 | davvy.exceptions.Forbidden) 55 | 56 | try: 57 | response = super(WebDAV, self).dispatch( 58 | request, user.username, "/", *args, **kwargs 59 | ) 60 | dav_base = ['1'] 61 | dav_base += getattr(settings, 'DAVVY_EXTENSIONS', []) 62 | response['Dav'] = ','.join(dav_base + self.dav_extensions) 63 | except Exception as e: 64 | logger.debug(e) 65 | code, phrase = e.status.split(' ', 1) 66 | response = HttpResponse(phrase, content_type='text/plain') 67 | response.status_code = int(code) 68 | response.reason_phrase = phrase 69 | finally: 70 | if old_cup_prop: 71 | # restore current-user-principal 72 | davvy.register_prop( 73 | *old_cup_prop 74 | ) 75 | 76 | else: 77 | response = HttpResponse('Unathorized', content_type='text/plain') 78 | response.status_code = 401 79 | response['WWW-Authenticate'] = 'Basic realm="davvy"' 80 | 81 | return response 82 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | author="Unbit", 5 | author_email="info@unbit.com", 6 | name='davvy', 7 | version='0.1.1', 8 | description='A Django application for building WebDAV services', 9 | url="https://github.com/unbit/davvy", 10 | license='MIT License', 11 | install_requires=[ 12 | 'django', 13 | 'lxml', 14 | ], 15 | packages=find_packages(), 16 | ) 17 | --------------------------------------------------------------------------------