]> git.samba.org - jelmer/calypso.git/blob - calypso/xmlutils.py
implement principal resources
[jelmer/calypso.git] / calypso / xmlutils.py
1 # -*- coding: utf-8 -*-
2 #
3 # This file is part of Calypso - CalDAV/CardDAV/WebDAV Server
4 # Copyright © 2011 Keith Packard
5 # Copyright © 2008-2011 Guillaume Ayoub
6 # Copyright © 2008 Nicolas Kandel
7 # Copyright © 2008 Pascal Halter
8 #
9 # This library is free software: you can redistribute it and/or modify
10 # it under the terms of the GNU General Public License as published by
11 # the Free Software Foundation, either version 3 of the License, or
12 # (at your option) any later version.
13 #
14 # This library is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 # GNU General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with Calypso.  If not, see <http://www.gnu.org/licenses/>.
21
22 """
23 XML and iCal requests manager.
24
25 Note that all these functions need to receive unicode objects for full
26 iCal requests (PUT) and string objects with charset correctly defined
27 in them for XML requests (all but PUT).
28
29 """
30
31 import xml.etree.ElementTree as ET
32 import time
33 import dateutil
34 import dateutil.parser
35 import dateutil.rrule
36 import dateutil.tz
37 import datetime
38 import email.utils
39 import logging
40
41 from . import client, config, webdav, paths, principal
42
43 __package__ = 'calypso.xmlutils'
44
45 NAMESPACES = {
46     "C": "urn:ietf:params:xml:ns:caldav",
47     "A": "urn:ietf:params:xml:ns:carddav",
48     "D": "DAV:",
49     "E": "http://apple.com/ns/ical/",
50     "CS": "http://calendarserver.org/ns/"}
51
52 log = logging.getLogger(__name__)
53
54 def _tag(short_name, local):
55     """Get XML Clark notation {uri(``short_name``)}``local``."""
56     return "{%s}%s" % (NAMESPACES[short_name], local)
57
58
59 def _response(code):
60     """Return full W3C names from HTTP status codes."""
61     return "HTTP/1.1 %i %s" % (code, client.responses[code])
62
63 def delete(path, collection, context):
64     """Read and answer DELETE requests.
65
66     Read rfc4918-9.6 for info.
67
68     """
69     # Reading request
70     collection.remove(paths.resource_from_path(path), context=context)
71
72     # Writing answer
73     multistatus = ET.Element(_tag("D", "multistatus"))
74     response = ET.Element(_tag("D", "response"))
75     multistatus.append(response)
76
77     href = ET.Element(_tag("D", "href"))
78     href.text = path
79     response.append(href)
80
81     status = ET.Element(_tag("D", "status"))
82     status.text = _response(200)
83     response.append(status)
84
85     return ET.tostring(multistatus, config.get("encoding", "request"))
86
87 def identify_resource(path):
88     """Return a Resource object corresponding to the path (this is used for
89     everything that is not a collection, like Principal and HomeSet objects)"""
90
91     try:
92         left, right = config.get('server', 'user_principal').split('%(user)s')
93     except ValueError:
94         raise ValueError("user_principal setting must contain %(user)s.")
95
96     if not path.startswith(left):
97         return None
98
99     remainder = path[len(left):]
100     if right not in remainder:
101         return None
102
103     username = remainder[:remainder.index(right)]
104     remainder = remainder[remainder.index(right)+len(right):]
105
106     if remainder == principal.AddressbookHomeSet.type_dependent_suffix + "/":
107         return principal.AddressbookHomeSet(username)
108     elif remainder == principal.CalendarHomeSet.type_dependent_suffix + "/":
109         return principal.CalendarHomeSet(username)
110     elif remainder == "":
111         return principal.Principal(username)
112     else:
113         return None
114
115 def propfind(path, xml_request, collection, depth, context):
116     """Read and answer PROPFIND requests.
117
118     Read rfc4918-9.1 for info.
119
120     """
121
122     item_name = paths.resource_from_path(path)
123
124     if xml_request:
125         # Reading request
126         root = ET.fromstring(xml_request)
127
128         prop_element = root.find(_tag("D", "prop"))
129     else:
130         prop_element = None
131
132     if prop_element is not None:
133         prop_list = prop_element.getchildren()
134         props = [prop.tag for prop in prop_list]
135     else:
136         props = [_tag("D", "resourcetype"),
137                  _tag("D", "owner"),
138                  _tag("D", "getcontenttype"),
139                  _tag("D", "getetag"),
140                  _tag("D", "principal-collection-set"),
141                  _tag("C", "supported-calendar-component-set"),
142                  _tag("D", "supported-report-set"),
143                  _tag("D", "current-user-privilege-set"),
144                  _tag("D", "getcontentlength"),
145                  _tag("D", "getlastmodified")]
146
147     
148     # Writing answer
149     multistatus = ET.Element(_tag("D", "multistatus"))
150
151     resource = identify_resource(path)
152
153     if resource is not None:
154         items = resource.propfind_children(depth)
155     elif collection:
156         if item_name:
157             item = collection.get_item(item_name)
158             print "item_name %s item %s" % (item_name, item)
159             if item:
160                 items = [item]
161             else:
162                 items = []
163         else:
164             if depth == "0":
165                 items = [collection]
166             else:
167                 # depth is 1, infinity or not specified
168                 # we limit ourselves to depth == 1
169                 items = [collection] + collection.items
170     else:
171         items = []
172
173     for item in items:
174         is_collection = isinstance(item, webdav.Collection)
175         is_resource = isinstance(item, principal.Resource)
176
177         if is_collection:
178             # parentcollectionhack. this is not the way to do it, but much of
179             # the below code relies on items which are collection members to
180             # have their parent collection in the collection variable. get rid
181             # of the collection propfind-global variable, and this and all
182             # other occurrences of "parentcollectionhack" can be dropped.
183             collection = item
184
185         response = ET.Element(_tag("D", "response"))
186         multistatus.append(response)
187
188         href = ET.Element(_tag("D", "href"))
189         if is_collection:
190             href.text = item.urlpath
191         elif is_resource:
192             href.text = item.urlpath
193         else:
194             href.text = collection.urlpath + item.name
195         response.append(href)
196
197         propstat = ET.Element(_tag("D", "propstat"))
198         response.append(propstat)
199
200         prop = ET.Element(_tag("D", "prop"))
201         propstat.append(prop)
202
203         for tag in props:
204             element = ET.Element(tag)
205             if tag == _tag("D", "resourcetype") and is_collection:
206                 if collection.is_calendar:
207                     tag = ET.Element(_tag("C", "calendar"))
208                     element.append(tag)
209                 if collection.is_addressbook:
210                     tag = ET.Element(_tag("A", "addressbook"))
211                     element.append(tag)
212                 tag = ET.Element(_tag("D", "collection"))
213                 element.append(tag)
214             elif tag == _tag("D", "owner"):
215                 element.text = collection.owner
216             elif tag == _tag("D", "getcontenttype"):
217                 if item.tag == 'VCARD':
218                     element.text = "text/vcard"
219                 else:
220                     element.text = "text/calendar"
221             elif tag == _tag("CS", "getctag") and is_collection:
222                 element.text = item.ctag
223             elif tag == _tag("D", "getetag"):
224                 element.text = item.etag
225             elif tag == _tag("D", "displayname") and is_collection:
226                 element.text = collection.name
227             elif tag == _tag("E", "calendar-color") and is_collection:
228                 element.text = collection.color
229             elif tag == _tag("D", "principal-URL"):
230                 # TODO: use a real principal URL, read rfc3744-4.2 for info
231                 tag = ET.Element(_tag("D", "href"))
232                 tag.text = path
233                 element.append(tag)
234             elif tag in (
235                 _tag("D", "principal-collection-set"),
236                 _tag("C", "calendar-user-address-set"),
237                 ):
238                 # not meaningfully implemented yet
239                 tag = ET.Element(_tag("D", "href"))
240                 tag.text = path
241                 element.append(tag)
242             elif tag == _tag("C", "supported-calendar-component-set"):
243                 comp = ET.Element(_tag("C", "comp"))
244                 comp.set("name", "VTODO") # pylint: disable=W0511
245                 element.append(comp)
246                 comp = ET.Element(_tag("C", "comp"))
247                 comp.set("name", "VEVENT")
248                 element.append(comp)
249             elif tag == _tag("D", "supported-report-set"):
250                 tag = ET.Element(_tag("C", "calendar-multiget"))
251                 element.append(tag)
252                 tag = ET.Element(_tag("C", "filter"))
253                 element.append(tag)
254             elif tag == _tag("D", "current-user-privilege-set"):
255                 privilege = ET.Element(_tag("D", "privilege"))
256                 privilege.append(ET.Element(_tag("D", "all")))
257                 element.append(privilege)
258             elif tag == _tag("D", "getcontentlength"):
259                 element.text = item.length
260             elif tag == _tag("D", "getlastmodified"):
261 #                element.text = time.strftime("%a, %d %b %Y %H:%M:%S +0000", item.last_modified)
262 #                element.text = email.utils.formatdate(item.last_modified)
263                 element.text = email.utils.formatdate(time.mktime(item.last_modified))
264             elif tag == _tag("D", "current-user-principal"):
265                 tag = ET.Element(_tag("D", "href"))
266                 tag.text = config.get("server", "user_principal") % context
267                 element.append(tag)
268             elif tag in (_tag("A", "addressbook-description"),
269                          _tag("C", "calendar-description")) and is_collection:
270                 element.text = collection.get_description()
271             prop.append(element)
272
273         status = ET.Element(_tag("D", "status"))
274         status.text = _response(200)
275         propstat.append(status)
276
277     return ET.tostring(multistatus, config.get("encoding", "request"))
278
279
280 def put(path, webdav_request, collection, context):
281     """Read PUT requests."""
282     name = paths.resource_from_path(path)
283     log.debug('xmlutils put path %s name %s', path, name)
284     if name in (item.name for item in collection.items):
285         # PUT is modifying an existing item
286         log.debug('Replacing item named %s', name)
287         return collection.replace(name, webdav_request, context=context)
288     else:
289         # PUT is adding a new item
290         log.debug('Putting a new item, because name %s is not known', name)
291         return collection.append(name, webdav_request, context=context)
292
293
294 def match_filter_element(vobject, fe):
295     if fe.tag == _tag("C", "comp-filter"):
296         comp = fe.get("name")
297         if comp:
298             if comp == vobject.name:
299                 hassub = False
300                 submatch = False
301                 for fc in fe.getchildren():
302                     if match_filter_element(vobject, fc):
303                         submatch = True
304                         break
305                     for vc in vobject.getChildren():
306                         hassub = True
307                         if match_filter_element (vc, fc):
308                             submatch = True
309                             break
310                     if submatch:
311                         break
312                 if not hassub or submatch:
313                     return True
314         return False
315     elif fe.tag == _tag("C", "time-range"):
316         try:
317             rruleset = vobject.rruleset
318         except AttributeError:
319             return False
320         start = fe.get("start")
321         end = fe.get("end")
322         # According to RFC 4791, one of start and stop must be set,
323         # but the other can be empty.  If both are empty, the
324         # specification is violated.
325         if start is None and end is None:
326             msg = "time-range missing both start and stop attribute (required by RFC 4791)"
327             log.error(msg)
328             raise ValueError(msg)
329         # RFC 4791 state if start is missing, assume it is -infinity
330         if start is None:
331             start = "00010101T000000Z"  # start of year one
332         # RFC 4791 state if end is missing, assume it is +infinity
333         if end is None:
334             end = "99991231T235959Z"  # last date with four digit year
335         if rruleset is None:
336             rruleset = dateutil.rrule.rruleset()
337             dtstart = vobject.dtstart.value
338             try:
339                 dtstart = datetime.datetime.combine(dtstart, datetime.time())
340             except Exception:
341                 0
342             if dtstart.tzinfo is None:
343                 dtstart = dtstart.replace(tzinfo = dateutil.tz.tzlocal())
344             rruleset.rdate(dtstart)
345         start_datetime = dateutil.parser.parse(start)
346         if start_datetime.tzinfo is None:
347             start_datetime = start_datetime.replace(tzinfo = dateutil.tz.tzlocal())
348         end_datetime = dateutil.parser.parse(end)
349         if end_datetime.tzinfo is None:
350             end_datetime = end_datetime.replace(tzinfo = dateutil.tz.tzlocal())
351         try:
352             if rruleset.between(start_datetime, end_datetime, True):
353                 return True
354         except TypeError:
355             start_datetime = start_datetime.replace(tzinfo = None)
356             end_datetime = end_datetime.replace(tzinfo = None)
357             try:
358                 if rruleset.between(start_datetime, end_datetime, True):
359                     return True
360             except TypeError:
361                 return True
362         return False
363     return True
364
365 def match_filter(item, filter):
366     if filter is None:
367         return True
368     if filter.tag != _tag("C", "filter"):
369         return True
370     for fe in filter.getchildren():
371         if match_filter_element(item.object, fe):
372             return True
373     return False
374
375 def report(path, xml_request, collection):
376     """Read and answer REPORT requests.
377
378     Read rfc3253-3.6 for info.
379
380     """
381     # Reading request
382     root = ET.fromstring(xml_request)
383
384     prop_element = root.find(_tag("D", "prop"))
385     prop_list = prop_element.getchildren()
386     props = [prop.tag for prop in prop_list]
387
388     filter_element = root.find(_tag("C", "filter"))
389
390     if collection:
391         if root.tag == _tag("C", "calendar-multiget") or root.tag == _tag('A', 'addressbook-multiget'):
392             # Read rfc4791-7.9 for info
393             hreferences = set((href_element.text for href_element
394                                in root.findall(_tag("D", "href"))))
395         else:
396             hreferences = (path,)
397     else:
398         hreferences = ()
399
400     # Writing answer
401     multistatus = ET.Element(_tag("D", "multistatus"))
402
403     for hreference in hreferences:
404         # Check if the reference is an item or a collection
405         name = paths.resource_from_path(hreference)
406         if name:
407             # Reference is an item
408             path = paths.collection_from_path(hreference) + "/"
409             items = (item for item in collection.items if item.name == name)
410         else:
411             # Reference is a collection
412             path = hreference
413             items = collection.items
414
415         
416         for item in items:
417             if not match_filter(item, filter_element):
418                 continue
419
420             response = ET.Element(_tag("D", "response"))
421             multistatus.append(response)
422
423             href = ET.Element(_tag("D", "href"))
424             href.text = path.rstrip('/') + '/' + item.name
425             response.append(href)
426
427             propstat = ET.Element(_tag("D", "propstat"))
428             response.append(propstat)
429
430             prop = ET.Element(_tag("D", "prop"))
431             propstat.append(prop)
432
433             for tag in props:
434                 element = ET.Element(tag)
435                 if tag == _tag("D", "getetag"):
436                     element.text = item.etag
437                 elif tag == _tag("C", "calendar-data"):
438                     element.text = item.text
439                 elif tag == _tag("A", "address-data"):
440                     element.text = item.text
441                 prop.append(element)
442
443             status = ET.Element(_tag("D", "status"))
444             status.text = _response(200)
445             propstat.append(status)
446
447     reply = ET.tostring(multistatus, config.get("encoding", "request"))
448         
449     return reply