Code Repositories xandikos / 49a74d2
Extract Xandikos repo from Dystros source. Jelmer Vernooij 2 years ago
50 changed file(s) with 3874 addition(s) and 5034 deletion(s). Raw diff Collapse all Expand all
00 [DEFAULT]
1 test_command=PYTHONPATH=. python3 -m subunit.run $IDOPTION $LISTOPT dystros.tests.test_suite
1 test_command=PYTHONPATH=. python3 -m subunit.run $IDOPTION $LISTOPT xandikos.tests.test_suite
22 test_id_option=--load-list $IDFILE
33 test_list_option=--list
1010 - $HOME/.cache/pip
1111 script:
1212 - pip install pip --upgrade
13 - python -m unittest dystros.tests.test_suite
13 - python -m unittest xandikos.tests.test_suite
1414
00 PYTHON ?= python3
11
22 check:
3 $(PYTHON) -m unittest dystros.tests.test_suite
3 $(PYTHON) -m unittest xandikos.tests.test_suite
44
55 web:
6 $(PYTHON) -m dystros.web
6 $(PYTHON) -m xandikos.web
77
88 check-compat:
99 cd compat && ./all.sh
0 Dystros is a set of command-line utilities for working with calendars and
1 addressbooks that are stored as iCalendar and vCard files in a git repository.
0 Xandikos is a CardDAV/CalDAV server that backs onto a Git repository.
21
3 Dystros (Δύστρος) takes its name from the name of the February month in the ancient
4 Macedonian calendar, used in Macedon in the first millennium BC.
2 Xandikos (Ξανδικός or Ξανθικός) takes its name from the name of the March month
3 in the ancient Macedonian calendar, used in Macedon in the first millennium BC.
00 - newtravel: Handle items with unknown or vague date ("Nov?", "?", "Oct/Nov?")
11 - fix-songkick: Get time somehow
22 - export-freebusy
3 - add dystros command-line tool
43
54 - import tfl2ics
65 - import from untappd?
+0
-53
check.py less more
0 #!/usr/bin/python3
1 # encoding: utf-8
2 #
3 # Dystros
4 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
5 #
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; version 2
9 # of the License or (at your option) any later version of
10 # the License.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with this program; if not, write to the Free Software
19 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
20 # MA 02110-1301, USA.
21
22 import logging
23 import optparse
24 import os
25 import sys
26 from icalendar.cal import Calendar
27
28 sys.path.insert(0, os.path.dirname(__file__))
29
30 from dystros import utils
31 from dystros.store import ExtractUID
32
33 parser = optparse.OptionParser("check")
34 parser.add_option_group(utils.CalendarOptionGroup(parser))
35 opts, args = parser.parse_args()
36
37 invalid = set()
38 uids = {}
39
40 for href, cal in utils.get_all_calendars(opts.url):
41 try:
42 uid = ExtractUID(href, cal)
43 except KeyError:
44 logging.error(
45 'File %s does not have a UID set.',
46 href)
47 else:
48 if uid in uids:
49 logging.error(
50 'UID %s is used by both %s and %s',
51 uid, uids[uid], href)
52 uids[uid] = href
00 This directory contains scripts to run Apple's caldav-tester against the
1 dystros web server.
1 xandikos web server.
22
33 to run:
44
+0
-22
dystros/__init__.py less more
0 # encoding: utf-8
1 #
2 # Dystros
3 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
4 #
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; version 2
8 # of the License or (at your option) any later version of
9 # the License.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
19 # MA 02110-1301, USA.
20
21
+0
-60
dystros/access.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Access control.
20
21 See http://www.webdav.org/specs/rfc3744.html
22 """
23
24 from defusedxml.ElementTree import fromstring as xmlparse
25 from xml.etree import ElementTree as ET
26
27 from dystros import webdav
28
29
30 class CurrentUserPrivilegeSetProperty(webdav.DAVProperty):
31 """current-user-privilege-set property
32
33 See http://www.webdav.org/specs/rfc3744.html, section 3.7
34 """
35
36 name = '{DAV:}current-user-privilege-set'
37 in_allprops = False
38 protected = True
39
40 def get_value(self, resource, el):
41 privilege = ET.SubElement(el, '{DAV:}privilege')
42 # TODO(jelmer): Use something other than all
43 priv_all = ET.SubElement(privilege, '{DAV:}all')
44
45
46 class OwnerProperty(webdav.DAVProperty):
47 """owner property.
48
49 See http://www.webdav.org/specs/rfc3744.html, section 5.1
50 """
51
52 name = '{DAV:}owner'
53 in_allprops = False
54
55 def get_value(self, resource, el):
56 owner_href = resource.get_owner()
57 if owner_href is not None:
58 ET.SubElement(el, '{DAV:}href').href = owner_href
59
+0
-562
dystros/caldav.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Simple CalDAV server.
20
21 https://tools.ietf.org/html/rfc4791
22 """
23 import datetime
24 import defusedxml.ElementTree
25 import logging
26 import pytz
27 from xml.etree import ElementTree as ET
28
29 from icalendar.cal import Calendar as ICalendar
30 from icalendar.prop import vDDDTypes
31
32 from dystros import davcommon
33 from dystros.webdav import (
34 DAVBackend,
35 DAVCollection,
36 DAVProperty,
37 DAVReporter,
38 DAVResource,
39 DAVStatus,
40 WebDAVApp,
41 get_properties,
42 traverse_resource,
43 )
44
45
46 WELLKNOWN_CALDAV_PATH = "/.well-known/caldav"
47
48 # https://tools.ietf.org/html/rfc4791, section 4.2
49 CALENDAR_RESOURCE_TYPE = '{urn:ietf:params:xml:ns:caldav}calendar'
50
51 NAMESPACE = 'urn:ietf:params:xml:ns:caldav'
52
53
54 class Calendar(DAVCollection):
55
56 resource_types = DAVCollection.resource_types + [CALENDAR_RESOURCE_TYPE]
57
58 def get_calendar_description(self):
59 raise NotImplementedError(self.get_calendar_description)
60
61 def get_calendar_color(self):
62 raise NotImplementedError(self.get_calendar_color)
63
64 def get_calendar_timezone(self):
65 """Return calendar timezone property.
66
67 This should be an iCalendar object with exactly one
68 VTIMEZONE component.
69 """
70 raise NotImplementedError(self.get_calendar_timezone)
71
72 def set_calendar_timezone(self):
73 """Set calendar timezone property.
74
75 This should be an iCalendar object with exactly one
76 VTIMEZONE component.
77 """
78 raise NotImplementedError(self.set_calendar_timezone)
79
80 def get_supported_calendar_components(self):
81 """Return set of supported calendar components in this calendar.
82
83 :return: iterable over component names
84 """
85 raise NotImplementedError(self.get_supported_calendar_components)
86
87 def get_supported_calendar_data_types(self):
88 """Return supported calendar data types.
89
90 :return: iterable over (content_type, version) tuples
91 """
92 raise NotImplementedError(self.get_supported_calendar_data_types)
93
94 def get_min_date_time(self):
95 """Return minimum datetime property.
96 """
97 raise NotImplementedError(self.get_min_date_time)
98
99 def get_max_date_time(self):
100 """Return maximum datetime property.
101 """
102 raise NotImplementedError(self.get_min_date_time)
103
104
105 class PrincipalExtensions:
106 """CalDAV-specific extensions to DAVPrincipal."""
107
108 def get_calendar_home_set(self):
109 """Get the calendar home set.
110
111 :return: a set of URLs
112 """
113 raise NotImplementedError(self.get_calendar_home_set)
114
115 def get_calendar_user_address_set(self):
116 """Get the calendar user address set.
117
118 :return: a set of URLs (usually mailto:...)
119 """
120 raise NotImplementedError(self.get_calendar_user_address_set)
121
122
123 class CalendarHomeSetProperty(DAVProperty):
124 """calendar-home-set property
125
126 See https://www.ietf.org/rfc/rfc4791.txt, section 6.2.1.
127 """
128
129 name = '{urn:ietf:params:xml:ns:caldav}calendar-home-set'
130 resource_type = '{DAV:}principal'
131 in_allprops = False
132
133 def get_value(self, resource, el):
134 for href in resource.get_calendar_home_set():
135 ET.SubElement(el, '{DAV:}href').text = href
136
137
138 class CalendarUserAddressSetProperty(DAVProperty):
139 """calendar-user-address-set property
140
141 See https://tools.ietf.org/html/rfc6638, section 2.4.1
142 """
143
144 name = '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set'
145 resource_type = '{DAV:}principal'
146 in_allprops = False
147
148 def get_value(self, resource, el):
149 for href in resource.get_calendar_user_address_set():
150 ET.SubElement(el, '{DAV:}href').text = href
151
152
153 class CalendarDescriptionProperty(DAVProperty):
154 """Provides calendar-description property.
155
156 https://tools.ietf.org/html/rfc4791, section 5.2.1
157 """
158
159 name = '{urn:ietf:params:xml:ns:caldav}calendar-description'
160 resource_type = CALENDAR_RESOURCE_TYPE
161
162 def get_value(self, resource, el):
163 el.text = resource.get_calendar_description()
164
165 # TODO(jelmer): allow modification of this property
166 # protected = True
167
168
169 class CalendarDataProperty(DAVProperty):
170 """calendar-data property
171
172 See https://tools.ietf.org/html/rfc4791, section 5.2.4
173
174 Note that this is not technically a DAV property, and
175 it is thus not registered in the regular webdav server.
176 """
177
178 name = '{%s}calendar-data' % NAMESPACE
179
180 def get_value(self, resource, el):
181 # TODO(jelmer): Support other kinds of calendar
182 if resource.get_content_type() != 'text/calendar':
183 raise KeyError
184 # TODO(jelmer): Support subproperties
185 # TODO(jelmer): Don't hardcode encoding
186 el.text = b''.join(resource.get_body()).decode('utf-8')
187
188
189 class CalendarMultiGetReporter(davcommon.MultiGetReporter):
190
191 name = '{urn:ietf:params:xml:ns:caldav}calendar-multiget'
192
193 data_property_kls = CalendarDataProperty
194
195
196 def apply_prop_filter(el, comp, tzify):
197 name = el.get('name')
198 # From https://tools.ietf.org/html/rfc4791, 9.7.2:
199 # A CALDAV:comp-filter is said to match if:
200
201 # The CALDAV:prop-filter XML element contains a CALDAV:is-not-defined XML
202 # element and no property of the type specified by the "name" attribute
203 # exists in the enclosing calendar component;
204 if [subel.tag for subel in el] == ['{urn:ietf:params:xml:ns:caldav}is-not-defined']:
205 return name not in comp
206
207 try:
208 prop = comp[name]
209 except KeyError:
210 return False
211
212 for subel in el:
213 if subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range':
214 if not apply_time_range(subel, prop, tzify):
215 return False
216 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match':
217 if not apply_text_match(subel, prop):
218 return False
219 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}param-filter':
220 if not apply_param_filter(subel, prop):
221 return False
222 return True
223
224
225 # see https://tools.ietf.org/html/rfc4790
226
227 collations = {
228 'i;ascii-casemap': lambda a, b: a.decode('ascii').upper() == b.decode('ascii').upper(),
229 'i;octet': lambda a, b: a == b,
230 }
231
232
233 def apply_text_match(el, value):
234 collation = el.get('collation', 'i;ascii-casemap')
235 negate_condition = el.get('negate-condition', 'no')
236 matches = collations[collation](el.text, value)
237
238 if negate_condition == 'yes':
239 return (not matches)
240 else:
241 return matches
242
243
244 def apply_param_filter(el, prop):
245 name = el.get('name')
246 if [subel.tag for subel in el] == ['{urn:ietf:params:xml:ns:caldav}is-not-defined']:
247 return name not in prop.params
248
249 try:
250 value = prop.params[name]
251 except KeyError:
252 return False
253
254 for subel in el:
255 if subel.tag == '{urn:ietf:params:xml:ns:caldav}text-match':
256 if not apply_text_match(subel, value):
257 return False
258 else:
259 raise AssertionError('unknown tag %r in param-filter', subel.tag)
260 return True
261
262
263 def _parse_time_range(el):
264 start = el.get('start')
265 end = el.get('end')
266 # Either start OR end OR both need to be specified.
267 # https://tools.ietf.org/html/rfc4791, section 9.9
268 assert start is not None or end is not None
269 if start is None:
270 start = "00010101T000000Z"
271 if end is None:
272 end = "99991231T235959Z"
273 start = vDDDTypes.from_ical(start)
274 end = vDDDTypes.from_ical(end)
275 assert end > start
276 assert end.tzinfo
277 assert start.tzinfo
278 return (start, end)
279
280
281 def as_tz_aware_ts(dt, default_timezone):
282 if not getattr(dt, 'time', None):
283 dt = datetime.datetime.combine(dt, datetime.time())
284 if dt.tzinfo is None:
285 # TODO(jelmer): Use user-supplied tzid
286 dt = dt.replace(tzinfo=default_timezone)
287 assert dt.tzinfo
288 return dt
289
290
291 def apply_time_range_vevent(start, end, comp, tzify):
292 if not (end > tzify(comp['DTSTART'].dt)):
293 return False
294
295 if 'DTEND' in comp:
296 if tzify(comp['DTEND'].dt) < tzify(comp['DTSTART'].dt):
297 logging.debug('Invalid DTEND < DTSTART')
298 return (start < tzify(comp['DTEND'].dt))
299
300 if 'DURATION' in comp:
301 return (start < tzify(comp['DTSTART'].dt) + comp['DURATION'].dt)
302 if getattr(comp['DTSTART'].dt, 'time', None) is not None:
303 return (start < (tzify(comp['DTSTART'].dt) + datetime.timedelta(1)))
304 else:
305 return (start <= comp['DTSTART'].dt)
306
307
308 def apply_time_range_vjournal(start, end, comp, tzify):
309 raise NotImplementedError(apply_time_range_vjournal)
310
311
312 def apply_time_range_vtodo(start, end, comp, tzify):
313 if 'DTSTART' in comp:
314 if 'DURATION' in comp and not 'DUE' in comp:
315 return (start <= tzify(comp['DTSTART'].dt)+comp['DURATION'].dt and
316 (end > tzify(comp['DTSTART'].dt) or
317 end >= tzify(comp['DTSTART'].dt)+comp['DURATION'].dt))
318 elif 'DUE' in comp and not 'DURATION' in comp:
319 return ((start <= tzify(comp['DTSTART'].dt) or start < tzify(comp['DUE'].dt)) and
320 (end > tzify(comp['DTSTART'].dt) or end < tzify(comp['DUE'].dt)))
321 else:
322 return (start <= tzify(comp['DTSTART'].dt) and end > tzify(comp['DTSTART'].dt))
323 elif 'DUE' in comp:
324 return (start < tzify(comp['DUE'].dt)) and (end >= tzify(comp['DUE'].dt))
325 elif 'COMPLETED' in comp:
326 if 'CREATED' in comp:
327 return ((start <= tzify(comp['CREATED'].dt) or start <= tzify(comp['COMPLETED'].dt)) and
328 (end >= tzify(comp['CREATED'].dt) or end >= tzify(comp['COMPLETED'].dt)))
329 else:
330 return (start <= tzify(comp['COMPLETED'].dt) and end >= tzify(comp['COMPLETED'].dt))
331 elif 'CREATED' in comp:
332 return (end >= tzify(comp['CREATED'].dt))
333 else:
334 return True
335
336
337 def apply_time_range_vfreebusy(start, end, comp, tzify):
338 raise NotImplementedError(apply_time_range_vfreebusy)
339
340
341 def apply_time_range_valarm(start, end, comp, tzify):
342 raise NotImplementedError(apply_time_range_valarm)
343
344
345 def apply_time_range_comp(el, comp, tzify):
346 # According to https://tools.ietf.org/html/rfc4791, section 9.9 these are
347 # the properties to check.
348 (start, end) = _parse_time_range(el)
349 component_handlers = {
350 'VEVENT': apply_time_range_vevent,
351 'VTODO': apply_time_range_vtodo,
352 'VJOURNAL': apply_time_range_vjournal,
353 'VFREEBUSY': apply_time_range_vfreebusy,
354 'VALARM': apply_time_range_valarm}
355 try:
356 component_handler = component_handlers[comp.name]
357 except KeyError:
358 logging.warning('unknown component %r in time-range filter',
359 comp.name)
360 return False
361 return component_handler(start, end, comp, tzify)
362
363
364 def apply_time_range(el, val, tzify):
365 (start, end) = _parse_time_range(el)
366 raise NotImplementedError(apply_time_range)
367
368
369 def apply_comp_filter(el, comp, tzify):
370 """Compile a comp-filter element into a Python function.
371 """
372 name = el.get('name')
373 # From https://tools.ietf.org/html/rfc4791, 9.7.1:
374 # A CALDAV:comp-filter is said to match if:
375
376 # 2. The CALDAV:comp-filter XML element contains a CALDAV:is-not-defined XML
377 # element and the calendar object or calendar component type specified by
378 # the "name" attribute does not exist in the current scope;
379 if [subel.tag for subel in el] == ['{urn:ietf:params:xml:ns:caldav}is-not-defined']:
380 return comp.name != name
381
382 # 1: The CALDAV:comp-filter XML element is empty and the calendar object or
383 # calendar component type specified by the "name" attribute exists in the
384 # current scope;
385 if comp.name != name:
386 return False
387
388 # 3. The CALDAV:comp-filter XML element contains a CALDAV:time-range XML
389 # element and at least one recurrence instance in the targeted calendar
390 # component is scheduled to overlap the specified time range, and all
391 # specified CALDAV:prop-filter and CALDAV:comp-filter child XML elements
392 # also match the targeted calendar component;
393 subchecks = []
394 for subel in el:
395 if subel.tag == '{urn:ietf:params:xml:ns:caldav}comp-filter':
396 if not any(apply_comp_filter(subel, c, tzify) for c in comp.subcomponents):
397 return False
398 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}prop-filter':
399 if not apply_prop_filter(subel, comp, tzify):
400 return False
401 elif subel.tag == '{urn:ietf:params:xml:ns:caldav}time-range':
402 if not apply_time_range_comp(subel, comp, tzify):
403 return False
404 else:
405 raise AssertionError('unknown filter tag %r' % subel.tag)
406 return True
407
408
409 def apply_filter(el, resource, tzify):
410 """Compile a filter element into a Python function.
411 """
412 try:
413 if resource.get_content_type() != 'text/calendar':
414 return False
415 except KeyError:
416 return False
417 if el is None:
418 # Empty filter, let's not bother parsing
419 return lambda x: True
420 c = ICalendar.from_ical(b''.join(resource.get_body()))
421 return apply_comp_filter(list(el)[0], c, tzify)
422
423
424 def extract_tzid(cal):
425 return cal.subcomponents[0]['TZID']
426
427
428 class CalendarQueryReporter(DAVReporter):
429
430 name = '{urn:ietf:params:xml:ns:caldav}calendar-query'
431
432 def report(self, body, resources_by_hrefs, properties, base_href,
433 base_resource, depth):
434 # TODO(jelmer): Verify that resource is an addressbook
435 requested = None
436 filter_el = None
437 tzid = None
438 for el in body:
439 if el.tag == '{DAV:}prop':
440 requested = el
441 elif el.tag == '{urn:ietf:params:xml:ns:caldav}filter':
442 filter_el = el
443 elif el.tag == '{urn:ietf:params:xml:ns:caldav}timezone':
444 tzid = extract_tzid(ICalendar.from_ical(el.text))
445 else:
446 raise NotImplementedError(tag.name)
447 if tzid is None:
448 try:
449 tzid = extract_tzid(ICalendar.from_ical(base_resource.get_calendar_timezone()))
450 except KeyError:
451 # TODO(jelmer): Or perhaps the servers' local timezone?
452 tzid = 'UTC'
453 tzify = lambda dt: as_tz_aware_ts(dt, pytz.timezone(tzid))
454 properties = dict(properties)
455 properties[CalendarDataProperty.name] = CalendarDataProperty()
456 for (href, resource) in traverse_resource(
457 base_resource, depth, base_href):
458 if not apply_filter(filter_el, resource, tzify):
459 continue
460 propstat = get_properties(
461 resource, properties, requested)
462 yield DAVStatus(href, '200 OK', propstat=list(propstat))
463
464
465 class CalendarColorProperty(DAVProperty):
466 """calendar-color property
467
468 This contains a HTML #RRGGBB color code, as CDATA.
469 """
470
471 name = '{http://apple.com/ns/ical/}calendar-color'
472 resource_type = CALENDAR_RESOURCE_TYPE
473
474 def get_value(self, resource, el):
475 el.text = resource.get_calendar_color()
476
477
478 class SupportedCalendarComponentSetProperty(DAVProperty):
479 """supported-calendar-component-set property
480
481 Set of supported calendar components by this calendar.
482
483 See https://www.ietf.org/rfc/rfc4791.txt, section 5.2.3
484 """
485
486 name = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'
487 resource_type = CALENDAR_RESOURCE_TYPE
488 in_allprops = False
489 protected = True
490
491 def get_value(self, resource, el):
492 for component in resource.get_supported_calendar_components():
493 subel = ET.SubElement(el, '{urn:ietf:params:xml:ns:caldav}comp')
494 subel.set('name', component)
495
496
497 class SupportedCalendarDataProperty(DAVProperty):
498 """supported-calendar-data property.
499
500 See https://tools.ietf.org/html/rfc4791, section 5.2.4
501 """
502
503 name = '{urn:ietf:params:xml:ns:caldav}supported-calendar-data'
504 resource_type = CALENDAR_RESOURCE_TYPE
505 in_allprops = False
506 protected = True
507
508 def get_value(self, resource, el):
509 for (content_type, version) in (
510 resource.get_supported_calendar_data_types()):
511 subel = ET.SubElement(
512 el, '{urn:ietf:params:xml:ns:caldav}calendar-data')
513 subel.set('content-type', content_type)
514 subel.set('version', version)
515
516
517 class CalendarTimezoneProperty(DAVProperty):
518 """calendar-timezone property.
519
520 See https://tools.ietf.org/html/rfc4791, section 5.2.2
521 """
522
523 name = '{urn:ietf:params:xml:ns:caldav}calendar-timezone'
524 resource_type = CALENDAR_RESOURCE_TYPE
525 in_allprops = False
526
527 def get_value(self, resource, el):
528 el.text = resource.get_calendar_timezone()
529
530 def set_value(self, resource, el):
531 resource.set_calendar_timezone(el.text)
532
533
534 class MinDateTimeProperty(DAVProperty):
535 """min-date-time property.
536
537 See https://tools.ietf.org/html/rfc4791, section 5.2.6
538 """
539
540 name = '{urn:ietf:params:xml:ns:caldav}min-date-time'
541 resource_type = CALENDAR_RESOURCE_TYPE
542 in_allprops = False
543 protected = True
544
545 def get_value(self, resource, el):
546 el.text = resource.get_min_date_time()
547
548
549 class MaxDateTimeProperty(DAVProperty):
550 """max-date-time property.
551
552 See https://tools.ietf.org/html/rfc4791, section 5.2.7
553 """
554
555 name = '{urn:ietf:params:xml:ns:caldav}max-date-time'
556 resource_type = CALENDAR_RESOURCE_TYPE
557 in_allprops = False
558 protected = True
559
560 def get_value(self, resource, el):
561 el.text = resource.get_max_date_time()
+0
-212
dystros/carddav.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """CardDAV support.
20
21 https://tools.ietf.org/html/rfc6352
22 """
23 import defusedxml.ElementTree
24 from xml.etree import ElementTree as ET
25
26 from dystros import davcommon, webdav
27
28 WELLKNOWN_CARDDAV_PATH = "/.well-known/carddav"
29
30 NAMESPACE = 'urn:ietf:params:xml:ns:carddav'
31 ADDRESSBOOK_RESOURCE_TYPE = '{%s}addressbook' % NAMESPACE
32
33
34 class AddressbookHomeSetProperty(webdav.DAVProperty):
35 """addressbook-home-set property
36
37 See https://tools.ietf.org/html/rfc6352, section 7.1.1
38 """
39
40 name = '{%s}addressbook-home-set' % NAMESPACE
41 resource_type = '{DAV:}principal'
42 in_allprops = False
43
44 def get_value(self, resource, el):
45 for href in resource.get_addressbook_home_set():
46 ET.SubElement(el, '{DAV:}href').text = href
47
48
49 class AddressDataProperty(webdav.DAVProperty):
50 """address-data property
51
52 See https://tools.ietf.org/html/rfc6352, section 10.4
53
54 Note that this is not technically a DAV property, and
55 it is thus not registered in the regular webdav server.
56 """
57
58 name = '{%s}address-data' % NAMESPACE
59
60 def get_value(self, resource, el):
61 # TODO(jelmer): Support subproperties
62 # TODO(jelmer): Don't hardcode encoding
63 el.text = b''.join(resource.get_body()).decode('utf-8')
64
65
66 class AddressbookDescriptionProperty(webdav.DAVProperty):
67 """Provides calendar-description property.
68
69 https://tools.ietf.org/html/rfc6352, section 6.2.1
70 """
71
72 name = '{%s}addressbook-description' % NAMESPACE
73 resource_type = ADDRESSBOOK_RESOURCE_TYPE
74
75 def get_value(self, resource, el):
76 el.text = resource.get_addressbook_description()
77
78 # TODO(jelmer): allow modification of this property
79 # protected = True
80
81
82 class AddressbookMultiGetReporter(davcommon.MultiGetReporter):
83
84 name = '{%s}addressbook-multiget' % NAMESPACE
85
86 data_property_kls = AddressDataProperty
87
88
89 class Addressbook(webdav.DAVCollection):
90
91 resource_types = (
92 webdav.DAVCollection.resource_types + [ADDRESSBOOK_RESOURCE_TYPE])
93
94 def get_addressbook_description(self):
95 raise NotImplementedError(self.get_addressbook_description)
96
97 def get_addressbook_color(self):
98 raise NotImplementedError(self.get_addressbook_color)
99
100 def get_supported_address_data_types(self):
101 """Get list of supported data types.
102
103 :return: List of tuples with content type and version
104 """
105 raise NotImplementedError(self.get_supported_address_data_types)
106
107 def get_max_resource_size(self):
108 """Get maximum object size this address book will store (in bytes)
109
110 Absence indicates no maximum.
111 """
112 raise NotImplementedError(self.get_max_resource_size)
113
114 def get_max_image_size(self):
115 """Get maximum image size this address book will store (in bytes)
116
117 Absence indicates no maximum.
118 """
119 raise NotImplementedError(self.get_max_image_size)
120
121
122 class PrincipalExtensions:
123 """Extensions to webdav.Principal."""
124
125 def get_addressbook_home_set(self):
126 """Return set of addressbook home URLs.
127
128 :return: set of URLs
129 """
130 raise NotImplementedError(self.get_addressbook_home_set)
131
132 def get_principal_address(self):
133 """Return URL to principal address vCard."""
134 raise NotImplementedError(self.get_principal_address)
135
136
137 class PrincipalAddressProperty(webdav.DAVProperty):
138 """Provides the principal-address property.
139
140 https://tools.ietf.org/html/rfc6352, section 7.1.2
141 """
142
143 name = '{%s}principal-address' % NAMESPACE
144 resource_type = '{DAV:}principal'
145 in_allprops = False
146
147 def get_value(self, resource, el):
148 ET.SubElement(el, '{DAV:}href').text = resource.get_principal_address()
149
150
151 class SupportedAddressDataProperty(webdav.DAVProperty):
152 """Provides the supported-address-data property.
153
154 https://tools.ietf.org/html/rfc6352, section 6.2.2
155 """
156
157 name = '{%s}supported-address-data' % NAMESPACE
158 resource_type = ADDRESSBOOK_RESOURCE_TYPE
159 in_allprops = False
160 protected = True
161
162 def get_value(self, resource, el):
163 for (content_type, version) in resource.get_supported_address_data_types():
164 subel = ET.SubElement(el, '{%s}content-type' % NAMESPACE)
165 subel.set('content-type', content_type)
166 subel.set('version', version)
167
168
169 class MaxResourceSizeProperty(webdav.DAVProperty):
170 """Provides the max-resource-size property.
171
172 See https://tools.ietf.org/html/rfc6352, section 6.2.3.
173 """
174
175 name = '{%s}max-resource-size' % NAMESPACE
176 resource_type = ADDRESSBOOK_RESOURCE_TYPE
177 in_allprops = False
178 protected = True
179
180 def get_value(self, resource, el):
181 el.text = str(resource.get_max_resource_size())
182
183
184 class MaxImageSizeProperty(webdav.DAVProperty):
185 """Provides the max-image-size property.
186
187 This seems to be a carddav extension used by iOS and caldavzap.
188 """
189
190 name = '{%s}max-image-size' % NAMESPACE
191 resource_type = ADDRESSBOOK_RESOURCE_TYPE
192 in_allprops = False
193 protected = True
194
195 def get_value(self, resource, el):
196 el.text = str(resource.get_max_image_size())
197
198
199 class AddressbookColorProperty(webdav.DAVProperty):
200 """Provides the addressbook-color property.
201
202 This is an inf-it extension.
203 """
204
205 name = '{http://inf-it.com/ns/ab/}addressbook-color'
206 resource_type = ADDRESSBOOK_RESOURCE_TYPE
207 in_allprops = False
208 protected = False
209
210 def get_value(self, resource, el):
211 el.text = resource.get_addressbook_color()
+0
-52
dystros/davcommon.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Common functions for DAV implementations."""
20
21 from dystros import webdav
22
23 class MultiGetReporter(webdav.DAVReporter):
24 """Abstract base class for multi-get reporters."""
25
26 name = None
27
28 data_property_kls = None
29
30 def report(self, body, resources_by_hrefs, properties, base_href, resource,
31 depth):
32 # TODO(jelmer): Verify that depth == "0"
33 # TODO(jelmer): Verify that resource is an addressbook
34 requested = None
35 hrefs = []
36 for el in body:
37 if el.tag == '{DAV:}prop':
38 requested = el
39 elif el.tag == '{DAV:}href':
40 hrefs.append(el.text)
41 else:
42 raise NotImplementedError(tag.name)
43 properties = dict(properties)
44 properties[self.data_property_kls.name] = self.data_property_kls()
45 for (href, resource) in resources_by_hrefs(hrefs):
46 if resource is None:
47 yield webdav.DAVStatus(href, '404 Not Found', propstat=[])
48 else:
49 propstat = webdav.get_properties(
50 resource, properties, requested)
51 yield webdav.DAVStatus(href, '200 OK', propstat=list(propstat))
+0
-42
dystros/filters.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19
20 def extract_vevents(calendars):
21 """Filter out vevents from an iterator over calendars.
22
23 :param calendars: Iterator over (href, calendar) tuples
24 :return: Iterator over Calendar subcomponents
25 """
26 for href, calendar in calendars:
27 for component in calendar.subcomponents:
28 if component.name == 'VEVENT':
29 yield component
30
31
32 def extract_vtodos(calendars):
33 """Filter out vtodos from an iterator over calendars.
34
35 :param calendars: Iterator over (href, calendar) tuples
36 :return: Iterator over Calendar subcomponents
37 """
38 for href, calendar in calendars:
39 for component in calendar.subcomponents:
40 if component.name == 'VTODO':
41 yield component
+0
-630
dystros/store.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Stores and store sets.
20
21 ETags (https://en.wikipedia.org/wiki/HTTP_ETag) used in this file
22 are always strong, and should be returned without wrapping quotes.
23 """
24
25 import logging
26 import os
27 import stat
28
29 from icalendar.cal import Calendar
30
31 from dulwich.objects import Blob, Tree
32 import dulwich.repo
33
34 _DEFAULT_COMMITTER_IDENTITY = b'Dystros <dystros>'
35 ICALENDAR_EXTENSION = '.ics'
36 VCARD_EXTENSION = '.vcf'
37
38 STORE_TYPE_ADDRESSBOOK = 'addressbook'
39 STORE_TYPE_CALENDAR = 'calendar'
40 STORE_TYPE_OTHER = 'other'
41 VALID_STORE_TYPES = (
42 STORE_TYPE_ADDRESSBOOK,
43 STORE_TYPE_CALENDAR,
44 STORE_TYPE_OTHER)
45
46
47 DEFAULT_ENCODING = 'utf-8'
48
49
50 logger = logging.getLogger(__name__)
51
52
53 def ExtractCalendarUID(cal):
54 """Extract the UID from a VCalendar file.
55
56 :param cal: Calendar, possibly serialized.
57 :return: UID
58 """
59 if type(cal) in (bytes, str):
60 cal = Calendar.from_ical(cal)
61 for component in cal.subcomponents:
62 try:
63 return component["UID"]
64 except KeyError:
65 pass
66 raise KeyError
67
68
69 def ExtractUID(name, data):
70 """Extract UID from a file.
71
72 :param name: Name of the file
73 :param data: Data (possibly serialized)
74 :return: UID
75 """
76 if name.endswith(ICALENDAR_EXTENSION):
77 return ExtractCalendarUID(data)
78 else:
79 return None
80
81
82 class DuplicateUidError(Exception):
83 """UID already exists in store."""
84
85 def __init__(self, uid, existing_name, new_name):
86 self.uid = uid
87 self.existing_name = existing_name
88 self.new_name = new_name
89
90
91 class NoSuchItem(Exception):
92 """No such item."""
93
94 def __init__(self, name):
95 self.name = name
96
97
98 class InvalidETag(Exception):
99 """Unexpected value for etag."""
100
101 def __init__(self, name, expected_etag, got_etag):
102 self.name = name
103 self.expected_etag = expected_etag
104 self.got_etag = got_etag
105
106
107 class NotStoreError(Exception):
108 """Not a store."""
109
110 def __init__(self, path):
111 self.path = path
112
113
114 class Store(object):
115 """A object store."""
116
117 def iter_with_etag(self):
118 """Iterate over all items in the store with etag.
119
120 :yield: (name, etag) tuples
121 """
122 raise NotImplementedError(self.iter_with_etag)
123
124 def get_raw(self, name, etag):
125 """Get the raw contents of an object.
126
127 :return: raw contents
128 """
129 raise NotImplementedError(self.get_raw)
130
131 def iter_raw(self):
132 """Iterate over raw object contents.
133
134 :yield: (name, etag, data) tuples
135 """
136 for (name, etag) in self.iter_with_etag():
137 data = self.get_raw(name, etag)
138 yield (name, etag, data)
139
140 def iter_calendars(self):
141 """Iterate over all calendars.
142
143 :yield: (name, Calendar) tuples
144 """
145 for (name, etag, data) in self.iter_raw():
146 if not name.endswith(ICALENDAR_EXTENSION):
147 continue
148 yield (name, etag, Calendar.from_ical(data))
149
150 def get_ctag(self):
151 """Return the ctag for this store."""
152 raise NotImplementedError(self.get_ctag)
153
154 def import_one(self, name, data, replace_etag=None):
155 """Import a single object.
156
157 :param name: Name of the object
158 :param data: serialized object as bytes
159 :param replace_etag: Etag to replace
160 :raise NameExists: when the name already exists
161 :raise DuplicateUidError: when the uid already exists
162 :return: etag
163 """
164 raise NotImplementedError(self.import_one)
165
166 def delete_one(self, name, etag=None):
167 """Delete an item.
168
169 :param name: Filename to delete
170 :param etag: Optional mandatory etag of object to remove
171 :raise NoSuchItem: when the item doesn't exist
172 :raise InvalidETag: If the specified ETag doesn't match the current
173 """
174 raise NotImplementedError(self.delete_one)
175
176 def lookup_uid(self, uid):
177 """Lookup an item by UID.
178
179 :param uid: UID to look up as string
180 :raise KeyError: if no such uid exists
181 :return: (name, etag) tuple
182 """
183 raise NotImplementedError(self.lookup_uid)
184
185 def get_type(self):
186 """Get type of this store.
187
188 :return: one of [STORE_TYPE_ADDRESSBOOK, STORE_TYPE_CALENDAR, STORE_TYPE_OTHER]
189 """
190 ret = STORE_TYPE_OTHER
191 for (name, etag) in self.iter_with_etag():
192 if name.endswith(ICALENDAR_EXTENSION):
193 ret = STORE_TYPE_CALENDAR
194 elif name.endswith(VCARD_EXTENSION):
195 ret = STORE_TYPE_ADDRESSBOOK
196 return ret
197
198 def set_description(self, description):
199 """Set the extended description of this store.
200
201 :param description: String with description
202 """
203 raise NotImplementedError(self.set_description)
204
205 def get_description(self):
206 """Get the extended description of this store.
207 """
208 raise NotImplementedError(self.get_description)
209
210 def get_displayname(self):
211 """Get the display name of this store.
212 """
213 raise NotImplementedError(self.get_displayname)
214
215 def get_color(Self):
216 """Get the color code for this store."""
217 raise NotImplementedError(self.get_color)
218
219 def iter_changes(self, old_ctag, new_ctag):
220 """Get changes between two versions of this store.
221
222 :param old_ctag: Old ctag (None for empty Store)
223 :param new_ctag: New ctag
224 :return: Iterator over (name, old_etag, new_etag)
225 """
226 raise NotImplementedError(self.iter_changes)
227
228
229 class GitStore(Store):
230 """A Store backed by a Git Repository.
231 """
232
233 def __init__(self, repo, ref=b'refs/heads/master'):
234 self.ref = ref
235 self.repo = repo
236 # Maps uids to (sha, fname)
237 self._uid_to_fname = {}
238 # Set of blob ids that have already been scanned
239 self._fname_to_uid = {}
240
241 def __repr__(self):
242 return "%s(%r, ref=%r)" % (type(self).__name__, self.repo, self.ref)
243
244 def lookup_uid(self, uid):
245 """Lookup an item by UID.
246
247 :param uid: UID to look up as string
248 :raise KeyError: if no such uid exists
249 :return: (name, etag) tuple
250 """
251 self._scan_ids()
252 return self._uid_to_fname[uid]
253
254 def _check_duplicate(self, uid, name, replace_etag):
255 self._scan_ids()
256 if uid is not None:
257 try:
258 (existing_name, _) = self.lookup_uid(uid)
259 except KeyError:
260 pass
261 else:
262 if existing_name != name:
263 raise DuplicateUidError(uid, existing_name, name)
264
265 try:
266 etag = self._get_etag(name)
267 except KeyError:
268 etag = None
269 if replace_etag is not None and etag != replace_etag:
270 raise InvalidETag(name, etag, replace_etag)
271
272 def get_raw(self, name, etag=None):
273 """Get the raw contents of an object.
274
275 :param name: Name of the item
276 :param etag: Optional etag
277 :return: raw contents
278 """
279 if etag is None:
280 etag = self._get_etag(name)
281 blob = self.repo.object_store[etag.encode('ascii')]
282 return blob.data
283
284 def _scan_ids(self):
285 removed = set(self._fname_to_uid.keys())
286 for (name, mode, sha) in self._iterblobs():
287 etag = sha.decode('ascii')
288 if name in removed:
289 removed.remove(name)
290 if (name in self._fname_to_uid and
291 self._fname_to_uid[name][0] == etag):
292 continue
293 blob = self.repo.object_store[sha]
294 try:
295 uid = ExtractUID(name, blob.data)
296 except KeyError:
297 logger.warning('No UID found in file %s', name)
298 uid = None
299 self._fname_to_uid[name] = (etag, uid)
300 if uid is not None:
301 self._uid_to_fname[uid] = (name, etag)
302 for name in removed:
303 (unused_etag, uid) = self._fname_to_uid[name]
304 if uid is not None:
305 del self._uid_to_fname[uid]
306 del self._fname_to_uid[name]
307
308 def _iterblobs(self, ctag=None):
309 raise NotImplementedError(self._iterblobs)
310
311 def iter_with_etag(self, ctag=None):
312 """Iterate over all items in the store with etag.
313
314 :param ctag: Ctag to iterate for
315 :yield: (name, etag) tuples
316 """
317 for (name, mode, sha) in self._iterblobs(ctag):
318 yield (name, sha.decode('ascii'))
319
320 @classmethod
321 def create(cls, path):
322 """Create a new store backed by a Git repository on disk.
323
324 :return: A `GitStore`
325 """
326 raise NotImplementedError(self.create)
327
328 @classmethod
329 def open_from_path(cls, path):
330 """Open a GitStore from a path.
331
332 :param path: Path
333 :return: A `GitStore`
334 """
335 try:
336 return cls.open(dulwich.repo.Repo(path))
337 except dulwich.repo.NotGitRepository:
338 raise NotStoreError(path)
339
340 @classmethod
341 def open(cls, repo):
342 """Open a GitStore given a Repo object.
343
344 :param repo: A Dulwich `Repo`
345 :return: A `GitStore`
346 """
347 if repo.has_index():
348 return TreeGitStore(repo)
349 else:
350 return BareGitStore(repo)
351
352 def get_description(self):
353 """Get extended description.
354
355 :return: repository description as string
356 """
357 desc = self.repo.get_description()
358 if desc is not None:
359 desc = desc.decode(DEFAULT_ENCODING)
360 return desc
361
362 def set_description(self, description):
363 """Set extended description.
364
365 :param description: repository description as string
366 """
367 return self.repo.set_description(description.encode(DEFAULT_ENCODING))
368
369 def get_color(self):
370 """Get color.
371
372 :return: A Color code, or None
373 """
374 config = self.repo.get_config()
375 try:
376 color = config.get(b'dystros', b'color')
377 except KeyError:
378 return None
379 else:
380 return color.decode(DEFAULT_ENCODING)
381
382 def get_displayname(self):
383 """Get display name.
384
385 :return: The display name, or None if not set
386 """
387 config = self.repo.get_config()
388 try:
389 displayname = config.get(b'dystros', b'displayname')
390 except KeyError:
391 return None
392 else:
393 return displayname.decode(DEFAULT_ENCODING)
394
395 def get_type(self):
396 """Get store type.
397
398 This looks in git config first, then falls back to guessing.
399 """
400 config = self.repo.get_config()
401 try:
402 store_type = config.get(b'dystros', b'type')
403 except KeyError:
404 return super(GitStore, self).get_type()
405 else:
406 store_type = store_type.decode(DEFAULT_ENCODING)
407 if store_type not in VALID_STORE_TYPES:
408 logging.warning(
409 'Invalid store type %s set for %r.',
410 store_type, self.repo)
411 return store_type
412
413 def iter_changes(self, old_ctag, new_ctag):
414 """Get changes between two versions of this store.
415
416 :param old_ctag: Old ctag (None for empty Store)
417 :param new_ctag: New ctag
418 :return: Iterator over (name, old_etag, new_etag)
419 """
420 if old_ctag is None:
421 t = Tree()
422 self.repo.object_store.add_object(t)
423 old_ctag = t.id.decode('ascii')
424 previous = dict(self.iter_with_etag(old_ctag))
425 for (name, new_etag) in self.iter_with_etag(new_ctag):
426 old_etag = previous.get(name)
427 if old_etag != new_etag:
428 yield (name, old_etag, new_etag)
429 if old_etag is not None:
430 del previous[name]
431 for (name, old_etag) in previous.items():
432 yield (name, old_etag, None)
433
434
435 class BareGitStore(GitStore):
436 """A Store backed by a bare git repository."""
437
438 def _get_current_tree(self):
439 try:
440 ref_object = self.repo[self.ref]
441 except KeyError:
442 return Tree()
443 if isinstance(ref_object, Tree):
444 return ref_object
445 else:
446 return self.repo.object_store[ref_object.tree]
447
448 def _get_etag(self, name):
449 tree = self._get_current_tree()
450 name = name.encode(DEFAULT_ENCODING)
451 return tree[name][1].decode('ascii')
452
453 def get_ctag(self):
454 """Return the ctag for this store."""
455 return self._get_current_tree().id.decode('ascii')
456
457 def _iterblobs(self, ctag=None):
458 if ctag is None:
459 tree = self._get_current_tree()
460 else:
461 tree = self.repo.object_store[ctag.encode('ascii')]
462 for (name, mode, sha) in tree.iteritems():
463 name = name.decode(DEFAULT_ENCODING)
464 yield (name, mode, sha)
465
466 @classmethod
467 def create_memory(cls):
468 """Create a new store backed by a memory repository.
469
470 :return: A `GitStore`
471 """
472 return cls(dulwich.repo.MemoryRepo())
473
474 def _commit_tree(self, tree_id, message):
475 try:
476 committer = self.repo._get_user_identity()
477 except KeyError:
478 committer = _DEFAULT_COMMITTER_IDENTITY
479 return self.repo.do_commit(message=message, tree=tree_id,
480 ref=self.ref, committer=committer)
481
482 def import_one(self, name, data, replace_etag=None):
483 """Import a single object.
484
485 :param name: Name of the object
486 :param data: serialized object as bytes
487 :param etag: optional etag of object to replace
488 :raise InvalidETag: when the name already exists but with different etag
489 :raise DuplicateUidError: when the uid already exists
490 :return: etag
491 """
492 uid = ExtractUID(name, data)
493 self._check_duplicate(uid, name, replace_etag)
494 # TODO(jelmer): Verify that 'data' actually represents a valid object
495 b = Blob.from_string(data)
496 tree = self._get_current_tree()
497 name_enc = name.encode(DEFAULT_ENCODING)
498 tree[name_enc] = (0o644|stat.S_IFREG, b.id)
499 self.repo.object_store.add_objects([(tree, ''), (b, name_enc)])
500 self._commit_tree(tree.id, b"Add " + name_enc)
501 return b.id.decode('ascii')
502
503 def delete_one(self, name, etag=None):
504 """Delete an item.
505
506 :param name: Filename to delete
507 :param etag: Optional mandatory etag of object to remove
508 :raise NoSuchItem: when the item doesn't exist
509 :raise InvalidETag: If the specified ETag doesn't match the curren
510 """
511 tree = self._get_current_tree()
512 name_enc = name.encode(DEFAULT_ENCODING)
513 if not name_enc in tree:
514 raise NoSuchItem(name)
515 if etag is not None:
516 current_sha = tree[name.encode(DEFAULT_ENCODING)][1]
517 if current_sha != etag.encode('ascii'):
518 raise InvalidETag(name, etag, current_sha.decode('ascii'))
519 del tree[name_enc]
520 self.repo.object_store.add_objects([(tree, '')])
521 self._commit_tree(tree.id, b"Delete " + name_enc)
522
523 @classmethod
524 def create(cls, path):
525 """Create a new store backed by a Git repository on disk.
526
527 :return: A `GitStore`
528 """
529 os.mkdir(path)
530 return cls(dulwich.repo.Repo.init_bare(path))
531
532
533 class TreeGitStore(GitStore):
534 """A Store that backs onto a treefull Git repository."""
535
536 @classmethod
537 def create(cls, path, bare=True):
538 """Create a new store backed by a Git repository on disk.
539
540 :return: A `GitStore`
541 """
542 os.mkdir(path)
543 return cls(dulwich.repo.Repo.init(path))
544
545 def _get_etag(self, name):
546 index = self.repo.open_index()
547 name = name.encode(DEFAULT_ENCODING)
548 return index[name].sha.decode('ascii')
549
550 def _commit_tree(self, message):
551 try:
552 committer = self.repo._get_user_identity()
553 except KeyError:
554 committer = _DEFAULT_COMMITTER_IDENTITY
555 return self.repo.do_commit(message=message, committer=committer)
556
557 def import_one(self, name, data, replace_etag=None):
558 """Import a single object.
559
560 :param name: name of the object
561 :param data: serialized object as bytes
562 :param replace_etag: optional etag of object to replace
563 :raise InvalidETag: when the name already exists but with different etag
564 :raise DuplicateUidError: when the uid already exists
565 :return: etag
566 """
567 uid = ExtractUID(name, data)
568 self._check_duplicate(uid, name, replace_etag)
569 # TODO(jelmer): Verify that 'data' actually represents a valid object
570 p = os.path.join(self.repo.path, name)
571 with open(p, 'wb') as f:
572 f.write(data)
573 self.repo.stage(name)
574 etag = self.repo.open_index()[name.encode(DEFAULT_ENCODING)].sha
575 message = b'Add ' + name.encode(DEFAULT_ENCODING)
576 self._commit_tree(message)
577 return etag.decode('ascii')
578
579 def delete_one(self, name, etag=None):
580 """Delete an item.
581
582 :param name: Filename to delete
583 :param etag: Optional mandatory etag of object to remove
584 :raise NoSuchItem: when the item doesn't exist
585 :raise InvalidETag: If the specified ETag doesn't match the curren
586 """
587 p = os.path.join(self.repo.path, name)
588 if not os.path.exists(p):
589 raise NoSuchItem(name)
590 if etag is not None:
591 with open(p, 'rb') as f:
592 current_etag = Blob.from_string(f.read()).id
593 if etag.encode('ascii') != current_etag:
594 raise InvalidETag(name, etag, current_etag.decode('ascii'))
595 os.unlink(p)
596 self.repo.stage(name)
597 message = b'Delete ' + name.encode(DEFAULT_ENCODING)
598 self._commit_tree(message)
599
600 def get_ctag(self):
601 """Return the ctag for this store."""
602 index = self.repo.open_index()
603 return index.commit(self.repo.object_store).decode('ascii')
604
605 def _iterblobs(self, ctag=None):
606 """Iterate over all items in the store with etag.
607
608 :yield: (name, etag) tuples
609 """
610 if ctag is not None:
611 tree = self.repo.object_store[ctag.encode('ascii')]
612 for (name, mode, sha) in tree.iteritems():
613 name = name.decode(DEFAULT_ENCODING)
614 yield (name, mode, sha)
615 else:
616 index = self.repo.open_index()
617 for (name, sha, mode) in index.iterblobs():
618 name = name.decode(DEFAULT_ENCODING)
619 yield (name, mode, sha)
620
621
622 def open_store(location):
623 """Open store from a location string.
624
625 :param location: Location string to open
626 :return: A `Store`
627 """
628 # For now, just support opening git stores
629 return GitStore.open_from_path(location)
+0
-115
dystros/sync.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Calendar synchronisation.
20
21 See https://tools.ietf.org/html/rfc6578
22 """
23
24 import urllib.parse
25 from xml.etree import ElementTree as ET
26
27 from dystros import webdav
28
29
30 class SyncToken(object):
31 """A sync token wrapper."""
32
33 def __init__(self, token):
34 self.token = token
35
36 def aselement(self):
37 ret = ET.Element('{DAV:}sync-token')
38 ret.text = self.token
39 return ret
40
41
42 class SyncCollectionReporter(webdav.DAVReporter):
43 """sync-collection reporter implementation.
44
45 See https://tools.ietf.org/html/rfc6578, section 3.2.
46 """
47
48 name = '{DAV:}sync-collection'
49
50 def report(self, request_body, resources_by_hrefs, properties, href,
51 resource, depth):
52 old_token = None
53 sync_level = None
54 limit = None
55 requested = None
56 for el in request_body:
57 if el.tag == '{DAV:}sync-token':
58 old_token = el.text
59 elif el.tag == '{DAV:}sync-level':
60 sync_level = el.text
61 elif el.tag == '{DAV:}limit':
62 limit = el.text
63 elif el.tag == '{DAV:}prop':
64 requested = list(el)
65 else:
66 assert 'unknown tag %s', el.tag
67 assert sync_level in ("1", "infinite"), "sync level is %r" % sync_level
68 # TODO(jelmer): Implement sync_level infinite
69 # TODO(jelmer): Support limit
70
71 new_token = resource.get_sync_token()
72 try:
73 diff_iter = resource.iter_differences_since(old_token, new_token)
74 except NotImplementedError:
75 return DAVStatus(
76 href, '403 Forbidden',
77 error=ET.Element('{DAV:}sync-traversal-supported'))
78
79 for (name, old_resource, new_resource) in diff_iter:
80 propstat = []
81 if new_resource is None:
82 for prop in requested:
83 propstat.append(
84 webdav.PropStatus('404 Not Found', None,
85 ET.Element(prop.tag)))
86 else:
87 for prop in requested:
88 if old_resource is not None:
89 old_propstat = webdav.get_property(
90 old_resource, properties, prop.tag)
91 else:
92 old_propstat = None
93 new_propstat = webdav.get_property(
94 new_resource, properties, prop.tag)
95 if old_propstat != new_propstat:
96 propstat.append(new_propstat)
97 yield webdav.DAVStatus(
98 urllib.parse.urljoin(href+'/', name), propstat=propstat)
99 # TODO(jelmer): This is a bit of a hack..
100 yield SyncToken(new_token)
101
102
103 class SyncTokenProperty(webdav.DAVProperty):
104 """sync-token property.
105
106 See https://tools.ietf.org/html/rfc6578, section 4
107 """
108
109 name = '{DAV:}sync-token'
110 protected = True
111 in_allprops = False
112
113 def get_value(self, resource, el):
114 el.text = resource.get_sync_token()
+0
-32
dystros/tests/__init__.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 import unittest
20
21
22 def test_suite():
23 names = [
24 'caldav',
25 'filters',
26 'store',
27 'webdav',
28 ]
29 module_names = ['dystros.tests.test_' + name for name in names]
30 loader = unittest.TestLoader()
31 return loader.loadTestsFromNames(module_names)
+0
-21
dystros/tests/test_caldav.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 import unittest
20
+0
-71
dystros/tests/test_filters.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 from icalendar.cal import Calendar
20 from dystros import filters
21
22 import unittest
23
24 EXAMPLE_VEVENT1 = b"""\
25 BEGIN:VCALENDAR
26 VERSION:2.0
27 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN
28 BEGIN:VEVENT
29 CREATED:20150314T223512Z
30 DTSTAMP:20150527T221952Z
31 LAST-MODIFIED:20150314T223512Z
32 STATUS:NEEDS-ACTION
33 SUMMARY:do something
34 UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff
35 END:VEVENT
36 END:VCALENDAR
37 """
38
39 EXAMPLE_VTODO1 = b"""\
40 BEGIN:VCALENDAR
41 VERSION:2.0
42 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN
43 BEGIN:VTODO
44 CREATED:20120314T223512Z
45 DTSTAMP:20130527T221952Z
46 LAST-MODIFIED:20150314T223512Z
47 STATUS:NEEDS-ACTION
48 SUMMARY:do something else
49 UID:bdc22764-b9e1-42c9-89c2-a85405d8fbff
50 END:VTODO
51 END:VCALENDAR
52 """
53
54
55 class FilterTests(unittest.TestCase):
56
57 def test_extract_vevents(self):
58 event1 = Calendar.from_ical(EXAMPLE_VEVENT1)
59 todo1 = Calendar.from_ical(EXAMPLE_VTODO1)
60 self.assertEqual(
61 [event1.subcomponents[0]],
62 list(filters.extract_vevents([('name.ics', event1), ('foo.ics', todo1)])))
63
64 def test_extract_vtodo(self):
65 event1 = Calendar.from_ical(EXAMPLE_VEVENT1)
66 todo1 = Calendar.from_ical(EXAMPLE_VTODO1)
67 self.assertEqual(
68 [todo1.subcomponents[0]],
69 list(filters.extract_vtodos([('foo.ics', event1), ('bar.ics', todo1)])))
70
+0
-337
dystros/tests/test_store.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 import logging
20 import os
21 import tempfile
22 import shutil
23 import stat
24 import unittest
25
26 from icalendar.cal import Calendar
27
28 from dulwich.objects import Blob, Commit, Tree
29 from dulwich.repo import Repo
30
31 from dystros.store import (
32 GitStore, BareGitStore, TreeGitStore, DuplicateUidError,
33 ExtractCalendarUID, InvalidETag, NoSuchItem,
34 logger as store_logger)
35
36 EXAMPLE_VCALENDAR1 = b"""\
37 BEGIN:VCALENDAR
38 VERSION:2.0
39 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN
40 BEGIN:VTODO
41 CREATED:20150314T223512Z
42 DTSTAMP:20150527T221952Z
43 LAST-MODIFIED:20150314T223512Z
44 STATUS:NEEDS-ACTION
45 SUMMARY:do something
46 UID:bdc22720-b9e1-42c9-89c2-a85405d8fbff
47 END:VTODO
48 END:VCALENDAR
49 """
50
51 EXAMPLE_VCALENDAR2 = b"""\
52 BEGIN:VCALENDAR
53 VERSION:2.0
54 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN
55 BEGIN:VTODO
56 CREATED:20120314T223512Z
57 DTSTAMP:20130527T221952Z
58 LAST-MODIFIED:20150314T223512Z
59 STATUS:NEEDS-ACTION
60 SUMMARY:do something else
61 UID:bdc22764-b9e1-42c9-89c2-a85405d8fbff
62 END:VTODO
63 END:VCALENDAR
64 """
65
66 EXAMPLE_VCALENDAR_NO_UID = b"""\
67 BEGIN:VCALENDAR
68 VERSION:2.0
69 PRODID:-//bitfire web engineering//DAVdroid 0.8.0 (ical4j 1.0.x)//EN
70 BEGIN:VTODO
71 CREATED:20120314T223512Z
72 DTSTAMP:20130527T221952Z
73 LAST-MODIFIED:20150314T223512Z
74 STATUS:NEEDS-ACTION
75 SUMMARY:do something without uid
76 END:VTODO
77 END:VCALENDAR
78 """
79
80
81 class BaseStoreTest(object):
82
83 def test_import_one(self):
84 gc = self.create_store()
85 etag = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
86 self.assertIsInstance(etag, str)
87 self.assertEqual([('foo.ics', etag)], list(gc.iter_with_etag()))
88
89 def test_import_one_duplicate_uid(self):
90 gc = self.create_store()
91 etag = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
92 self.assertRaises(
93 DuplicateUidError, gc.import_one, 'bar.ics',
94 EXAMPLE_VCALENDAR1)
95
96 def test_import_one_duplicate_name(self):
97 gc = self.create_store()
98 etag = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
99 etag = gc.import_one('foo.ics', EXAMPLE_VCALENDAR2, etag)
100 etag = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
101 self.assertRaises(InvalidETag, gc.import_one, 'foo.ics',
102 EXAMPLE_VCALENDAR2, 'invalidetag')
103
104 def test_iter_calendars(self):
105 gc = self.create_store()
106 etag1 = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
107 etag2 = gc.import_one('bar.ics', EXAMPLE_VCALENDAR2)
108 ret = {n: (etag, cal) for (n, etag, cal) in gc.iter_calendars()}
109 self.assertEqual(ret,
110 {'bar.ics': (etag2, Calendar.from_ical(EXAMPLE_VCALENDAR2)),
111 'foo.ics': (etag1, Calendar.from_ical(EXAMPLE_VCALENDAR1)),
112 })
113
114 def test_iter_raw(self):
115 gc = self.create_store()
116 etag1 = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
117 etag2 = gc.import_one('bar.ics', EXAMPLE_VCALENDAR2)
118 ret = {n: (etag, cal) for (n, etag, cal) in gc.iter_raw()}
119 self.assertEqual(ret,
120 {'bar.ics': (etag2, EXAMPLE_VCALENDAR2),
121 'foo.ics': (etag1, EXAMPLE_VCALENDAR1),
122 })
123
124 def test_get_raw(self):
125 gc = self.create_store()
126 etag1 = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
127 etag2 = gc.import_one('bar.ics', EXAMPLE_VCALENDAR2)
128 self.assertEqual(
129 EXAMPLE_VCALENDAR1,
130 gc.get_raw('foo.ics', etag1))
131 self.assertEqual(
132 EXAMPLE_VCALENDAR2,
133 gc.get_raw('bar.ics', etag2))
134 self.assertRaises(
135 KeyError,
136 gc.get_raw, 'missing.ics', '01' * 20)
137
138 def test_iter_calendars_extension(self):
139 gc = self.create_store()
140 etag1 = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
141 etag2 = gc.import_one('bar.txt', EXAMPLE_VCALENDAR2)
142 ret = {n: (etag, cal) for (n, etag, cal) in gc.iter_calendars()}
143 self.assertEqual(ret,
144 {'foo.ics': (etag1, Calendar.from_ical(EXAMPLE_VCALENDAR1))})
145
146 def test_delete_one(self):
147 gc = self.create_store()
148 self.assertEqual([], list(gc.iter_with_etag()))
149 etag1 = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
150 self.assertEqual([('foo.ics', etag1)], list(gc.iter_with_etag()))
151 gc.delete_one('foo.ics')
152 self.assertEqual([], list(gc.iter_with_etag()))
153
154 def test_delete_one_with_etag(self):
155 gc = self.create_store()
156 self.assertEqual([], list(gc.iter_with_etag()))
157 etag1 = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
158 self.assertEqual([('foo.ics', etag1)], list(gc.iter_with_etag()))
159 gc.delete_one('foo.ics', etag1)
160 self.assertEqual([], list(gc.iter_with_etag()))
161
162 def test_delete_one_nonexistant(self):
163 gc = self.create_store()
164 self.assertRaises(NoSuchItem, gc.delete_one, 'foo.ics')
165
166 def test_delete_one_invalid_etag(self):
167 gc = self.create_store()
168 self.assertEqual([], list(gc.iter_with_etag()))
169 etag1 = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
170 etag2 = gc.import_one('bar.ics', EXAMPLE_VCALENDAR2)
171 self.assertEqual(
172 set([('foo.ics', etag1), ('bar.ics', etag2)]),
173 set(gc.iter_with_etag()))
174 self.assertRaises(InvalidETag, gc.delete_one, 'foo.ics', etag2)
175 self.assertEqual(
176 set([('foo.ics', etag1), ('bar.ics', etag2)]),
177 set(gc.iter_with_etag()))
178
179 def test_lookup_uid_nonexistant(self):
180 gc = self.create_store()
181 self.assertRaises(KeyError, gc.lookup_uid, 'someuid')
182
183 def test_lookup_uid(self):
184 gc = self.create_store()
185 etag = gc.import_one('foo.ics', EXAMPLE_VCALENDAR1)
186 self.assertEqual(
187 ('foo.ics', etag),
188 gc.lookup_uid('bdc22720-b9e1-42c9-89c2-a85405d8fbff'))
189
190
191 class BaseGitStoreTest(BaseStoreTest):
192
193 kls = None
194
195 def create_store(self):
196 raise NotImplementedError(self.create_store)
197
198 def add_blob(self, gc, name, contents):
199 raise NotImplementedError(self.add_blob)
200
201 def test_create(self):
202 d = tempfile.mkdtemp()
203 self.addCleanup(shutil.rmtree, d)
204 gc = self.kls.create(os.path.join(d, 'store'))
205 self.assertIsInstance(gc, GitStore)
206 self.assertEqual(gc.repo.path, os.path.join(d, 'store'))
207
208 def test_iter_with_etag_missing_uid(self):
209 logging.getLogger('').setLevel(logging.ERROR)
210 gc = self.create_store()
211 bid = self.add_blob(gc, 'foo.ics', EXAMPLE_VCALENDAR_NO_UID)
212 self.assertEqual([('foo.ics', bid)], list(gc.iter_with_etag()))
213 gc._scan_ids()
214 logging.getLogger('').setLevel(logging.NOTSET)
215
216 def test_iter_with_etag(self):
217 gc = self.create_store()
218 bid = self.add_blob(gc, 'foo.ics', EXAMPLE_VCALENDAR1)
219 self.assertEqual([('foo.ics', bid)], list(gc.iter_with_etag()))
220 self.assertEqual(
221 ('foo.ics', bid),
222 gc.lookup_uid('bdc22720-b9e1-42c9-89c2-a85405d8fbff'))
223
224 def test_get_description(self):
225 gc = self.create_store()
226 try:
227 gc.repo.set_description(b'a repo description')
228 except NotImplementedError:
229 self.skipTest('old dulwich version without MemoryRepo.set_description')
230 self.assertEqual(gc.get_description(), 'a repo description')
231
232 def test_displayname(self):
233 gc = self.create_store()
234 self.assertIs(None, gc.get_color())
235 c = gc.repo.get_config()
236 c.set(b'dystros', b'displayname', b'a name')
237 if getattr(c, 'path', None):
238 c.write_to_path()
239 self.assertEqual('a name', gc.get_displayname())
240
241 def test_get_color(self):
242 gc = self.create_store()
243 self.assertIs(None, gc.get_color())
244 c = gc.repo.get_config()
245 c.set(b'dystros', b'color', b'334433')
246 if getattr(c, 'path', None):
247 c.write_to_path()
248 self.assertEqual('334433', gc.get_color())
249
250
251 class GitStoreTest(unittest.TestCase):
252
253 def test_open_from_path_bare(self):
254 d = tempfile.mkdtemp()
255 self.addCleanup(shutil.rmtree, d)
256 Repo.init_bare(d)
257 gc = GitStore.open_from_path(d)
258 self.assertIsInstance(gc, BareGitStore)
259 self.assertEqual(gc.repo.path, d)
260
261 def test_open_from_path_tree(self):
262 d = tempfile.mkdtemp()
263 self.addCleanup(shutil.rmtree, d)
264 Repo.init(d)
265 gc = GitStore.open_from_path(d)
266 self.assertIsInstance(gc, TreeGitStore)
267 self.assertEqual(gc.repo.path, d)
268
269
270 class BareGitStoreTest(BaseGitStoreTest,unittest.TestCase):
271
272 kls = BareGitStore
273
274 def create_store(self):
275 return BareGitStore.create_memory()
276
277 def test_create_memory(self):
278 gc = BareGitStore.create_memory()
279 self.assertIsInstance(gc, GitStore)
280
281 def add_blob(self, gc, name, contents):
282 b = Blob.from_string(contents)
283 t = Tree()
284 t.add(name.encode('utf-8'), 0o644|stat.S_IFREG, b.id)
285 c = Commit()
286 c.tree = t.id
287 c.committer = c.author = b'Somebody <foo@example.com>'
288 c.commit_time = c.author_time = 800000
289 c.commit_timezone = c.author_timezone = 0
290 c.message = b'do something'
291 gc.repo.object_store.add_objects([(b, None), (t, None), (c, None)])
292 gc.repo[gc.ref] = c.id
293 return b.id.decode('ascii')
294
295 def test_get_ctag(self):
296 gc = self.create_store()
297 self.assertEqual(Tree().id.decode('ascii'), gc.get_ctag())
298 self.add_blob(gc, 'foo.ics', EXAMPLE_VCALENDAR1)
299 self.assertEqual(
300 gc._get_current_tree().id.decode('ascii'),
301 gc.get_ctag())
302
303
304 class TreeGitStoreTest(BaseGitStoreTest,unittest.TestCase):
305
306 kls = TreeGitStore
307
308 def create_store(self):
309 d = tempfile.mkdtemp()
310 self.addCleanup(shutil.rmtree, d)
311 return self.kls.create(os.path.join(d, 'store'))
312
313 def add_blob(self, gc, name, contents):
314 with open(os.path.join(gc.repo.path, name), 'wb') as f:
315 f.write(contents)
316 gc.repo.stage(name.encode('utf-8'))
317 return Blob.from_string(contents).id.decode('ascii')
318
319
320 class ExtractCalendarUIDTests(unittest.TestCase):
321
322 def test_extract_str(self):
323 self.assertEqual(
324 'bdc22720-b9e1-42c9-89c2-a85405d8fbff',
325 ExtractCalendarUID(EXAMPLE_VCALENDAR1))
326
327 def test_extract_cal(self):
328 cal = Calendar.from_ical(EXAMPLE_VCALENDAR1)
329 self.assertEqual(
330 'bdc22720-b9e1-42c9-89c2-a85405d8fbff',
331 ExtractCalendarUID(cal))
332
333 def test_extract_no_uid(self):
334 self.assertRaises(
335 KeyError,
336 ExtractCalendarUID, EXAMPLE_VCALENDAR_NO_UID)
+0
-319
dystros/tests/test_webdav.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 from io import BytesIO
20 import logging
21 import unittest
22 import defusedxml.ElementTree
23 from wsgiref.util import request_uri, setup_testing_defaults
24 from xml.etree import ElementTree as ET
25
26 from dystros.webdav import (
27 DAVCollection,
28 DAVProperty,
29 DAVResource,
30 WebDAVApp,
31 WellknownResource,
32 )
33
34
35 class WebTests(unittest.TestCase):
36
37 def setUp(self):
38 super(WebTests, self).setUp()
39 logging.disable(logging.WARNING)
40 self.addCleanup(logging.disable, logging.NOTSET)
41
42 def makeApp(self, resources, properties):
43 class Backend(object):
44 get_resource = resources.get
45 app = WebDAVApp(Backend())
46 app.register_properties(properties)
47 return app
48
49 def _method(self, app, method, path):
50 environ = {'PATH_INFO': path, 'REQUEST_METHOD': method,
51 'SCRIPT_NAME': ''}
52 setup_testing_defaults(environ)
53 _code = []
54 _headers = []
55 def start_response(code, headers):
56 _code.append(code)
57 _headers.extend(headers)
58 contents = b''.join(app(environ, start_response))
59 return _code[0], _headers, contents
60
61 def lock(self, app, path):
62 return self._method(app, 'LOCK', path)
63
64 def mkcol(self, app, path):
65 environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'MKCOL',
66 'SCRIPT_NAME': ''}
67 setup_testing_defaults(environ)
68 _code = []
69 _headers = []
70 def start_response(code, headers):
71 _code.append(code)
72 _headers.extend(headers)
73 contents = b''.join(app(environ, start_response))
74 return _code[0], _headers, contents
75
76 def delete(self, app, path):
77 environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'DELETE',
78 'SCRIPT_NAME': ''}
79 setup_testing_defaults(environ)
80 _code = []
81 _headers = []
82 def start_response(code, headers):
83 _code.append(code)
84 _headers.extend(headers)
85 contents = b''.join(app(environ, start_response))
86 return _code[0], _headers, contents
87
88 def get(self, app, path):
89 environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'GET',
90 'SCRIPT_NAME': ''}
91 setup_testing_defaults(environ)
92 _code = []
93 _headers = []
94 def start_response(code, headers):
95 _code.append(code)
96 _headers.extend(headers)
97 contents = b''.join(app(environ, start_response))
98 return _code[0], _headers, contents
99
100 def put(self, app, path, contents):
101 environ = {
102 'PATH_INFO': path,
103 'REQUEST_METHOD': 'PUT',
104 'wsgi.input': BytesIO(contents),
105 'SCRIPT_NAME': '',
106 }
107 setup_testing_defaults(environ)
108 _code = []
109 _headers = []
110 def start_response(code, headers):
111 _code.append(code)
112 _headers.extend(headers)
113 list(app(environ, start_response))
114 return _code[0], _headers
115
116 def propfind(self, app, path, body):
117 environ = {
118 'PATH_INFO': path,
119 'REQUEST_METHOD': 'PROPFIND',
120 'wsgi.input': BytesIO(body),
121 'SCRIPT_NAME': ''}
122 setup_testing_defaults(environ)
123 _code = []
124 _headers = []
125 def start_response(code, headers):
126 _code.append(code)
127 _headers.extend(headers)
128 contents = b''.join(app(environ, start_response))
129 return _code[0], _headers, contents
130
131 def test_not_found(self):
132 app = self.makeApp({}, [])
133 code, headers, contents = self.get(app, '/.well-known/carddav')
134 self.assertEqual('404 Not Found', code)
135
136 def test_get_body(self):
137 class TestResource(DAVResource):
138
139 def get_body(self):
140 return [b'this is content']
141
142 def get_content_length(self):
143 return len('this is content')
144
145 def get_etag(self):
146 return "myetag"
147
148 def get_content_type(self):
149 return 'text/plain'
150 app = self.makeApp({'/.well-known/carddav': TestResource()}, [])
151 code, headers, contents = self.get(app, '/.well-known/carddav')
152 self.assertEqual('200 OK', code)
153 self.assertEqual(b'this is content', contents)
154
155 def test_set_body(self):
156 new_body = []
157 class TestResource(DAVResource):
158
159 def set_body(self, body, replace_etag=None):
160 new_body.extend(body)
161
162 def get_etag(self):
163 return '"blala"'
164 app = self.makeApp({'/.well-known/carddav': TestResource()}, [])
165 code, headers = self.put(
166 app, '/.well-known/carddav', b'New contents')
167 self.assertEqual('204 No Content', code)
168 self.assertEqual([b'New contents'], new_body)
169
170 def test_lock_not_allowed(self):
171 app = self.makeApp({}, [])
172 code, headers, contents = self.lock(app, '/resource')
173 self.assertEqual('405 Method Not Allowed', code)
174 self.assertIn(
175 ('Allow', 'DELETE, GET, MKCOL, OPTIONS, PUT, PROPFIND, PROPPATCH, REPORT'),
176 headers)
177 self.assertEqual(b'', contents)
178
179 def test_mkcol_not_allowed(self):
180 class TestResource(DAVResource):
181
182 def create_collection(self, name):
183 pass
184
185 app = self.makeApp({'/resource': TestResource()}, [])
186 code, headers, contents = self.mkcol(app, '/resource/bla')
187 self.assertEqual('201 Created', code)
188 self.assertEqual(b'', contents)
189
190 def test_mkcol_exists(self):
191 app = self.makeApp({
192 '/resource': DAVResource(),
193 '/resource/bla': DAVResource()}, [])
194 code, headers, contents = self.mkcol(app, '/resource/bla')
195 self.assertEqual('405 Method Not Allowed', code)
196 self.assertEqual(b'', contents)
197
198 def test_delete(self):
199 class TestResource(DAVCollection):
200
201 def get_etag(self):
202 return '"foo"'
203
204 def delete_member(unused_self, name, etag=None):
205 self.assertEqual(name, 'resource')
206 app = self.makeApp({'/': TestResource(), '/resource': TestResource()}, [])
207 code, headers, contents = self.delete(app, '/resource')
208 self.assertEqual('204 No Content', code)
209 self.assertEqual(b'', contents)
210
211 def test_delete_not_found(self):
212 class TestResource(DAVCollection):
213 pass
214
215 app = self.makeApp({'/resource': TestResource()}, [])
216 code, headers, contents = self.delete(app, '/resource')
217 self.assertEqual('404 Not Found', code)
218 self.assertTrue(contents.endswith(b'/resource not found.'))
219
220 def test_propfind_prop_does_not_exist(self):
221 app = self.makeApp({'/resource': DAVResource()}, [])
222 code, headers, contents = self.propfind(app, '/resource', b"""\
223 <d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype /></d:prop></d:propfind>""")
224 self.assertMultiLineEqual(
225 contents.decode('utf-8'),
226 '<ns0:propstat xmlns:ns0="DAV:"><ns0:status>HTTP/1.1 404 Not Found</ns0:status>'
227 '<ns0:prop><ns0:resourcetype /></ns0:prop></ns0:propstat>')
228 self.assertEqual(code, '200 OK')
229
230 def test_propfind_prop_not_present(self):
231 class TestProperty(DAVProperty):
232 name = '{DAV:}current-user-principal'
233
234 def get_value(self, resource, ret):
235 raise KeyError
236 app = self.makeApp({'/resource': DAVResource()}, [TestProperty()])
237 code, headers, contents = self.propfind(app, '/resource', b"""\
238 <d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype /></d:prop></d:propfind>""")
239 self.assertMultiLineEqual(
240 contents.decode('utf-8'),
241 '<ns0:propstat xmlns:ns0="DAV:"><ns0:status>HTTP/1.1 404 Not Found</ns0:status>'
242 '<ns0:prop><ns0:resourcetype /></ns0:prop></ns0:propstat>')
243 self.assertEqual(code, '200 OK')
244
245 def test_propfind_found(self):
246 class TestProperty(DAVProperty):
247 name = '{DAV:}current-user-principal'
248
249 def get_value(self, resource, ret):
250 ET.SubElement(ret, '{DAV:}href').text = '/user/'
251 app = self.makeApp({'/resource': DAVResource()}, [TestProperty()])
252 code, headers, contents = self.propfind(app, '/resource', b"""\
253 <d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/>\
254 </d:prop></d:propfind>""")
255 self.assertMultiLineEqual(
256 contents.decode('utf-8'),
257 '<ns0:propstat xmlns:ns0="DAV:"><ns0:status>HTTP/1.1 200 OK</ns0:status>'
258 '<ns0:prop><ns0:current-user-principal><ns0:href>/user/</ns0:href>'
259 '</ns0:current-user-principal></ns0:prop></ns0:propstat>')
260 self.assertEqual(code, '200 OK')
261
262 def test_propfind_found_multi(self):
263 class TestProperty1(DAVProperty):
264 name = '{DAV:}current-user-principal'
265 def get_value(self, resource, el):
266 ET.SubElement(el, '{DAV:}href').text = '/user/'
267 class TestProperty2(DAVProperty):
268 name = '{DAV:}somethingelse'
269 def get_value(self, resource, el):
270 pass
271 app = self.makeApp(
272 {'/resource': DAVResource()},
273 [TestProperty1(), TestProperty2()])
274 code, headers, contents = self.propfind(app, '/resource', b"""\
275 <d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/>\
276 <d:somethingelse/></d:prop></d:propfind>""")
277 self.maxDiff = None
278 self.assertMultiLineEqual(
279 contents.decode('utf-8'),
280 '<ns0:propstat xmlns:ns0="DAV:"><ns0:status>HTTP/1.1 200 OK</ns0:status>'
281 '<ns0:prop><ns0:current-user-principal><ns0:href>/user/</ns0:href>'
282 '</ns0:current-user-principal><ns0:somethingelse /></ns0:prop>'
283 '</ns0:propstat>')
284 self.assertEqual(code, '200 OK')
285
286 def test_propfind_found_multi_status(self):
287 class TestProperty(DAVProperty):
288 name = '{DAV:}current-user-principal'
289 def get_value(self, resource, ret):
290 ET.SubElement(ret, '{DAV:}href').text = '/user/'
291 app = self.makeApp({'/resource': DAVResource()}, [TestProperty()])
292 code, headers, contents = self.propfind(app, '/resource', b"""\
293 <d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/>\
294 <d:somethingelse/></d:prop></d:propfind>""")
295 self.maxDiff = None
296 self.assertEqual(code, '207 Multi-Status')
297 self.assertMultiLineEqual(
298 contents.decode('utf-8'), """\
299 <ns0:multistatus xmlns:ns0="DAV:"><ns0:response><ns0:href>/resource</ns0:href>\
300 <ns0:status>HTTP/1.1 200 OK</ns0:status>\
301 <ns0:propstat><ns0:status>HTTP/1.1 200 OK</ns0:status><ns0:prop>\
302 <ns0:current-user-principal><ns0:href>/user/</ns0:href>\
303 </ns0:current-user-principal></ns0:prop></ns0:propstat><ns0:propstat>\
304 <ns0:status>HTTP/1.1 404 Not Found</ns0:status><ns0:prop>\
305 <ns0:somethingelse /></ns0:prop></ns0:propstat>\
306 </ns0:response>\
307 </ns0:multistatus>""")
308
309
310 class WellknownResourceTests(unittest.TestCase):
311
312 def test_get_body(self):
313 r = WellknownResource('/some/root')
314 self.assertEqual(b'/some/root', b''.join(r.get_body()))
315
316 def test_resource_types(self):
317 r = WellknownResource('/some/root')
318 self.assertEqual([], r.resource_types)
+0
-265
dystros/utils.py less more
0 #!/usr/bin/python
1 #
2 # Dystros
3 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
4 #
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; version 2
8 # of the License or (at your option) any later version of
9 # the License.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
19 # MA 02110-1301, USA.
20
21 from defusedxml.ElementTree import fromstring as xmlparse
22 # Hmm, defusedxml doesn't have XML generation functions? :(
23 from xml.etree import ElementTree as ET
24
25 import datetime
26 from icalendar.cal import Calendar
27 import optparse
28 import os
29 import urllib.parse
30 import urllib.request
31
32 DEFAULT_URL = 'https://www.rinze.eu/dav/jelmer/calendars/calendar'
33
34 class CalendarOptionGroup(optparse.OptionGroup):
35 """Return a optparser OptionGroup.
36
37 :param parser: An OptionParser
38 :param default_kind: Default kind
39 :return: An OptionGroup
40 """
41
42 def __init__(self, parser):
43 optparse.OptionGroup.__init__(self, parser, "Calendar Settings")
44 self.add_option('--url', type=str, dest="url", help="Default calendar URL.",
45 default=DEFAULT_URL)
46
47
48 def statuschar(evstatus):
49 """Convert an event status to a single status character.
50
51 :param evstatus: Event status description
52 :return: A single character, empty string if the status is unknown
53 """
54 return {'TENTATIVE': '?',
55 'CONFIRMED': '.',
56 'CANCELLED': '-'}.get(evstatus, '')
57
58
59 def format_month(dt):
60 return dt.strftime("%b")
61
62
63 def format_daterange(start, end):
64 if end is None:
65 return "%d %s-?" % (start.day, format_month(start))
66 if start.month == end.month:
67 if start.day == end.day:
68 return "%d %s" % (start.day, format_month(start))
69 return "%d-%d %s" % (start.day, end.day, format_month(start))
70 return "%d %s-%d %s" % (start.day, format_month(start), end.day, format_month(end))
71
72
73 def asdate(dt):
74 if getattr(dt, "date", None):
75 a_date = dt.date()
76 else:
77 a_date = dt
78 return dt
79
80
81 def keyEvent(a):
82 """Create key for an event
83
84 :param a: First event
85 """
86 a = a['DTSTART'].dt
87 if getattr(a, "date", None):
88 a_date = a.date()
89 a = (a.hour, a.minute)
90 else:
91 a_date = a
92 a = (0, 0)
93 return (a_date, a)
94
95
96 DEFAULT_PRIORITY = 10
97 DEFAULT_DUE_DATE = datetime.date(datetime.MAXYEAR, 1, 1)
98
99
100 def keyTodo(a):
101 priority = a.get('PRIORITY')
102 if priority is not None:
103 priority = int(priority)
104 else:
105 priority = DEFAULT_PRIORITY
106 due = a.get('DUE')
107 if due:
108 if getattr(due.dt, "date", None):
109 due_date = due.dt.date()
110 due_time = (due.dt.hour, due.dt.minute)
111 else:
112 due_date = due.dt
113 due_time = (0, 0)
114 else:
115 due_date = DEFAULT_DUE_DATE
116 due_time = None
117 return (priority, due_date, due_time, a['SUMMARY'])
118
119
120 def report(url, req, depth=None):
121 if depth is None:
122 depth = '1'
123 req = urllib.request.Request(url=url, data=ET.tostring(req), method='REPORT')
124 req.add_header('Depth', depth)
125 with urllib.request.urlopen(req) as f:
126 assert f.status == 207, f.status
127 return xmlparse(f.read())
128
129
130 def _extend_inner_filter(et, inner_filter):
131 if inner_filter is None:
132 return
133 if not isinstance(inner_filter, list):
134 inner_filter = [inner_filter]
135 for f in inner_filter:
136 et.append(f)
137
138
139 def comp_filter(name, inner_filter=None):
140 ret = ET.Element('{urn:ietf:params:xml:ns:caldav}comp-filter')
141 if name is not None:
142 ret.set('name', name)
143 _extend_inner_filter(ret, inner_filter)
144 return ret
145
146
147 def prop_filter(name, inner_filter=None):
148 ret = ET.Element('{urn:ietf:params:xml:ns:caldav}prop-filter')
149 if name is not None:
150 ret.set('name', name)
151 _extend_inner_filter(ret, inner_filter)
152 return ret
153
154
155 def text_match(text, collation):
156 ret = ET.Element('{urn:ietf:params:xml:ns:caldav}text-match')
157 ret.text = text
158 ret.set('collation', collation)
159 return ret
160
161
162 def multistat_extract_responses(multistatus):
163 assert multistatus.tag == '{DAV:}multistatus', repr(multistatus)
164 for response in multistatus:
165 assert response.tag == '{DAV:}response'
166 href = None
167 status = None
168 propstat = None
169 for responsesub in response:
170 if responsesub.tag == '{DAV:}href':
171 href = responsesub.text
172 elif responsesub.tag == '{DAV:}propstat':
173 propstat = responsesub
174 elif responsesub.tag == '{DAV:}status':
175 status = responsesub.text
176 else:
177 assert False, 'invalid %r' % responsesub.tag
178 yield (href, status, propstat)
179
180
181 def calendar_query(url, props, filter=None, depth=None):
182 reqxml = ET.Element('{urn:ietf:params:xml:ns:caldav}calendar-query')
183 propxml = ET.SubElement(reqxml, '{DAV:}prop')
184 for prop in props:
185 if isinstance(prop, str):
186 ET.SubElement(propxml, prop)
187 else:
188 propxml.append(prop)
189
190 if filter is not None:
191 filterxml = ET.SubElement(reqxml, '{urn:ietf:params:xml:ns:caldav}filter')
192 filterxml.append(filter)
193
194 respxml = report(url, reqxml, depth)
195 return multistat_extract_responses(respxml)
196
197
198 def get_all_calendars(url, depth=None, filter=None):
199 for (href, status, propstat) in calendar_query(
200 url, ['{DAV:}getetag', '{urn:ietf:params:xml:ns:caldav}calendar-data'], filter):
201 by_status = {}
202 for propstatsub in propstat:
203 if propstatsub.tag == '{DAV:}status':
204 status = propstatsub.text
205 elif propstatsub.tag == '{DAV:}prop':
206 by_status[status] = propstatsub
207 else:
208 assert False, 'invalid %r' % propstatsub.tag
209 data = None
210 for prop in by_status.get('HTTP/1.1 200 OK', []):
211 if prop.tag == '{urn:ietf:params:xml:ns:caldav}calendar-data':
212 data = prop.text
213 assert data is not None, "data missing for %r" % href
214 yield href, Calendar.from_ical(data)
215
216
217 def get(url):
218 req = urllib.request.Request(url=url, method='GET')
219 with urllib.request.urlopen(req) as f:
220 assert f.status == 200, f.status
221 return (f.get_header('ETag'), f.read())
222
223
224 def put(url, data, if_match=None):
225 req = urllib.request.Request(url=url, data=data, method='PUT')
226 if if_match is not None:
227 req.add_header('If-None-Match', ', '.join(if_match))
228 with urllib.request.urlopen(req) as f:
229 pass
230 assert f.status in (201, 204, 200), f.status
231
232
233 def get_vevent_by_uid(url, uid, depth='1'):
234 uidprop = ET.Element('{urn:ietf:params:xml:ns:caldav}calendar-data')
235 uidprop.set('name', 'UID')
236 dataprop = ET.Element('{urn:ietf:params:xml:ns:caldav}calendar-data')
237 ret = calendar_query(
238 url, props=[uidprop, dataprop, '{DAV:}getetag'], depth=depth,
239 filter=comp_filter("VCALENDAR",
240 comp_filter("VEVENT",
241 prop_filter("UID", text_match(text=uid, collation="i;octet")))))
242
243 for (href, status, propstat) in ret:
244 if status == 'HTTP/1.1 404 Not Found':
245 raise KeyError(uid)
246 by_status = {}
247 for propstatsub in propstat:
248 if propstatsub.tag == '{DAV:}status':
249 if propstatsub.text == 'HTTP/1.1 404 Not Found':
250 raise KeyError(uid)
251 elif propstatsub.tag == '{DAV:}prop':
252 by_status[status] = propstatsub
253 else:
254 assert False, 'invalid %r' % propstatsub.tag
255 etag = None
256 data = None
257 for prop in by_status.get('HTTP/1.1 200 OK', []):
258 if prop.tag == '{urn:ietf:params:xml:ns:caldav}calendar-data':
259 data = prop.text
260 if prop.tag == '{DAV:}getetag':
261 etag = prop.text
262 assert data is not None, "data missing for %r" % href
263 return (href, etag, Calendar.from_ical(data))
264 raise KeyError(uid)
+0
-476
dystros/web.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Web server implementation..
20
21 This is the concrete web server implementation. It provides the
22 high level application logic that combines the WebDAV server,
23 the carddav support, the caldav support and the DAV store.
24 """
25
26 import functools
27 import os
28 import posixpath
29
30 from dystros import access, caldav, carddav, sync, webdav
31 from dystros.store import (
32 BareGitStore,
33 GitStore,
34 NotStoreError,
35 STORE_TYPE_ADDRESSBOOK,
36 STORE_TYPE_CALENDAR,
37 STORE_TYPE_OTHER,
38 )
39
40 WELLKNOWN_DAV_PATHS = set([caldav.WELLKNOWN_CALDAV_PATH, carddav.WELLKNOWN_CARDDAV_PATH])
41
42 RESOURCE_CACHE_SIZE = 128
43 # TODO(jelmer): Make these configurable/dynamic
44 CALENDAR_HOME = 'calendars'
45 ADDRESSBOOK_HOME = 'contacts'
46 USER_ADDRESS_SET = ['mailto:jelmer@jelmer.uk']
47
48 ROOT_PAGE_CONTENTS = b"""\
49 <html>
50 <body>
51 This is a Dystros WebDAV server. See
52 <a href="https://github.com/jelmer/dystros">
53 https://github.com/jelmer/dystros</a>.
54 </body>
55 </html>"""
56
57
58 def create_strong_etag(etag):
59 """Create strong etags.
60
61 :param etag: basic etag
62 :return: A strong etag
63 """
64 return '"' + etag + '"'
65
66
67 def extract_strong_etag(etag):
68 """Extract a strong etag from a string."""
69 if etag is None:
70 return etag
71 return etag.strip('"')
72
73
74 class ObjectResource(webdav.DAVResource):
75 """Object resource."""
76
77 def __init__(self, store, name, etag, content_type):
78 self.store = store
79 self.name = name
80 self.etag = etag
81 self.content_type = content_type
82
83 def __repr__(self):
84 return "%s(%r, %r, %r, %r)" % (
85 type(self).__name__, self.store, self.name, self.etag,
86 self.content_type)
87
88 def get_body(self):
89 return [self.store.get_raw(self.name, self.etag)]
90
91 def set_body(self, data, replace_etag=None):
92 etag = self.store.import_one(
93 self.name, b''.join(data), extract_strong_etag(replace_etag))
94 return create_strong_etag(etag)
95
96 def get_content_type(self):
97 return self.content_type
98
99 def get_content_length(self):
100 return len(b''.join(self.get_body()))
101
102 def get_etag(self):
103 return create_strong_etag(self.etag)
104
105 def get_supported_locks(self):
106 return []
107
108 def get_active_locks(self):
109 return []
110
111 def get_owner(self):
112 return None
113
114
115 class StoreBasedCollection(object):
116
117 def __init__(self, store):
118 self.store = store
119
120 def _get_resource(self, name, etag):
121 return ObjectResource(
122 self.store, name, etag, self._object_content_type)
123
124 def get_displayname(self):
125 displayname = self.store.get_displayname()
126 if displayname is None:
127 return os.path.basename(self.store.repo.path)
128 return displayname
129
130 def get_sync_token(self):
131 return self.store.get_ctag()
132
133 def get_ctag(self):
134 return self.store.get_ctag()
135
136 def get_etag(self):
137 return create_strong_etag(self.store.get_ctag())
138
139 def members(self):
140 ret = []
141 for (name, etag) in self.store.iter_with_etag():
142 resource = self._get_resource(name, etag)
143 ret.append((name, resource))
144 return ret
145
146 def get_member(self, name):
147 assert name != ''
148 for (fname, fetag) in self.store.iter_with_etag():
149 if name == fname:
150 return self._get_resource(name, fetag)
151 else:
152 raise KeyError(name)
153
154 def delete_member(self, name, etag=None):
155 self.store.delete_one(name, extract_strong_etag(etag))
156
157 def create_member(self, name, contents):
158 etag = self.store.import_one(name, b''.join(contents))
159 return create_strong_etag(etag)
160
161 def iter_differences_since(self, old_token, new_token):
162 for (name, old_etag, new_etag) in self.store.iter_changes(
163 old_token, new_token):
164 if old_etag is not None:
165 old_resource = self._get_resource(name, old_etag)
166 else:
167 old_resource = None
168 if new_etag is not None:
169 new_resource = self._get_resource(name, new_etag)
170 else:
171 new_resource = None
172 yield (name, old_resource, new_resource)
173
174 def get_owner(self):
175 return None
176
177 def get_supported_locks(self):
178 return []
179
180 def get_active_locks(self):
181 return []
182
183
184 class Collection(StoreBasedCollection,caldav.Calendar):
185 """A generic WebDAV collection."""
186
187 _object_content_type = 'application/unknown'
188
189 def __init__(self, store):
190 self.store = store
191
192
193 class CalendarResource(StoreBasedCollection,caldav.Calendar):
194
195 _object_content_type = 'text/calendar'
196
197 def get_calendar_description(self):
198 return self.store.get_description()
199
200 def get_calendar_color(self):
201 color = self.store.get_color()
202 if not color:
203 raise KeyError
204 if color and color[0] != '#':
205 color = '#' + color
206 return color
207
208 def get_calendar_timezone(self):
209 # TODO(jelmer): Read a magic file from the store?
210 raise KeyError
211
212 def set_calendar_timezone(self, content):
213 raise NotImplementedError(self.set_calendar_timezone)
214
215 def get_supported_calendar_components(self):
216 return ["VEVENT", "VTODO", "VJOURNAL", "VFREEBUSY"]
217
218 def get_supported_calendar_data_types(self):
219 return [('text/calendar', '1.0'),
220 ('text/calendar', '2.0')]
221
222 def get_content_type(self):
223 # TODO
224 raise KeyError
225
226 def get_max_date_time(self):
227 return "99991231T235959Z"
228
229 def get_min_date_time(self):
230 return "00010101T000000Z"
231
232
233 class AddressbookResource(StoreBasedCollection,carddav.Addressbook):
234
235 _object_content_type = 'text/vcard'
236
237 def get_content_type(self):
238 # TODO
239 raise KeyError
240
241 def get_addressbook_description(self):
242 return self.store.get_description()
243
244 def get_supported_address_data_types(self):
245 return [('text/vcard', '3.0')]
246
247 def get_max_resource_size(self):
248 # No resource limit
249 raise KeyError
250
251 def get_max_image_size(self):
252 # No resource limit
253 raise KeyError
254
255 def get_addressbook_color(self):
256 color = self.store.get_color()
257 if not color:
258 raise KeyError
259 if color and color[0] != '#':
260 color = '#' + color
261 return color
262
263
264 class CollectionSetResource(webdav.DAVCollection):
265 """Resource for calendar sets."""
266
267 def __init__(self, backend, relpath):
268 self.backend = backend
269 self.relpath = relpath
270
271 def get_displayname(self):
272 return posixpath.basename(self.relpath)
273
274 def get_sync_token(self):
275 raise KeyError
276
277 def get_etag(self):
278 raise KeyError
279
280 def get_supported_locks(self):
281 return []
282
283 def get_active_locks(self):
284 return []
285
286 def members(self):
287 ret = []
288 p = self.backend._map_to_file_path(self.relpath)
289 for name in os.listdir(p):
290 resource = self.get_member(name)
291 ret.append((name, resource))
292 return ret
293
294 def create_collection(self, name):
295 relpath = posixpath.join(self.relpath, name)
296 p = self.backend._map_to_file_path(relpath)
297 # Why bare store, not a tree store?
298 return BareGitStore.create(p)
299
300 def get_member(self, name):
301 assert name != ''
302 relpath = posixpath.join(self.relpath, name)
303 p = self.backend._map_to_file_path(relpath)
304 if not os.path.isdir(p):
305 raise KeyError(name)
306 return self.backend.get_resource(relpath)
307
308
309 class RootPage(webdav.DAVResource):
310 """A non-DAV resource."""
311
312 resource_types = []
313
314 def get_body(self):
315 return [ROOT_PAGE_CONTENTS]
316
317 def get_content_length(self):
318 return len(b''.join(self.get_body()))
319
320 def get_content_type(self):
321 return 'text/html'
322
323 def get_supported_locks(self):
324 return []
325
326 def get_active_locks(self):
327 return []
328
329 def get_etag(self):
330 return '"root-page"'
331
332
333 class Principal(CollectionSetResource):
334 """Principal user resource."""
335
336 resource_types = webdav.DAVCollection.resource_types + [webdav.PRINCIPAL_RESOURCE_TYPE]
337
338 def get_principal_url(self):
339 return self.path
340
341 def get_calendar_home_set(self):
342 return [CALENDAR_HOME]
343
344 def get_addressbook_home_set(self):
345 return [ADDRESSBOOK_HOME]
346
347 def get_calendar_user_address_set(self):
348 return USER_ADDRESS_SET
349
350
351 @functools.lru_cache(maxsize=RESOURCE_CACHE_SIZE)
352 def open_store_from_path(path):
353 return GitStore.open_from_path(path)
354
355
356 class DystrosBackend(webdav.DAVBackend):
357
358 def __init__(self, path, current_user_principal):
359 self.path = path
360 self.current_user_principal = posixpath.normpath(current_user_principal)
361
362 def _map_to_file_path(self, relpath):
363 return os.path.join(self.path, relpath.lstrip('/'))
364
365 def get_resource(self, relpath):
366 relpath = posixpath.normpath(relpath)
367 if relpath in WELLKNOWN_DAV_PATHS:
368 return webdav.WellknownResource(self.current_user_principal)
369 elif relpath == '/':
370 return RootPage()
371 elif relpath == self.current_user_principal:
372 return Principal(self, relpath)
373 p = self._map_to_file_path(relpath)
374 if p is None:
375 return None
376 if os.path.isdir(p):
377 try:
378 store = open_store_from_path(p)
379 except NotStoreError:
380 return CollectionSetResource(self, relpath)
381 else:
382 return {STORE_TYPE_CALENDAR: CalendarResource,
383 STORE_TYPE_ADDRESSBOOK: AddressbookResource,
384 STORE_TYPE_OTHER: Collection}[store.get_type()](store)
385 else:
386 (basepath, name) = os.path.split(relpath)
387 assert name != '', 'path is %r' % relpath
388 store = self.get_resource(basepath)
389 if (store is None or
390 webdav.COLLECTION_RESOURCE_TYPE not in store.resource_types):
391 return None
392 try:
393 return store.get_member(name)
394 except KeyError:
395 return None
396
397
398 class DystrosApp(webdav.WebDAVApp):
399 """A wsgi App that provides a Dystros web server.
400 """
401
402 def __init__(self, path, current_user_principal):
403 super(DystrosApp, self).__init__(DystrosBackend(
404 path, current_user_principal))
405 self.register_properties([
406 webdav.DAVResourceTypeProperty(),
407 webdav.DAVCurrentUserPrincipalProperty(
408 current_user_principal),
409 webdav.DAVPrincipalURLProperty(),
410 webdav.DAVDisplayNameProperty(),
411 webdav.DAVGetETagProperty(),
412 webdav.DAVGetContentTypeProperty(),
413 caldav.CalendarHomeSetProperty(),
414 caldav.CalendarUserAddressSetProperty(),
415 carddav.AddressbookHomeSetProperty(),
416 caldav.CalendarDescriptionProperty(),
417 caldav.CalendarColorProperty(),
418 caldav.SupportedCalendarComponentSetProperty(),
419 carddav.AddressbookDescriptionProperty(),
420 carddav.PrincipalAddressProperty(),
421 webdav.GetCTagProperty(),
422 carddav.SupportedAddressDataProperty(),
423 webdav.DAVSupportedReportSetProperty(self.reporters),
424 sync.SyncTokenProperty(),
425 caldav.SupportedCalendarDataProperty(),
426 caldav.CalendarTimezoneProperty(),
427 caldav.MinDateTimeProperty(),
428 caldav.MaxDateTimeProperty(),
429 carddav.MaxResourceSizeProperty(),
430 carddav.MaxImageSizeProperty(),
431 access.CurrentUserPrivilegeSetProperty(),
432 access.OwnerProperty(),
433 webdav.DAVCreationDateProperty(),
434 carddav.AddressbookColorProperty(),
435 webdav.DAVSupportedLockProperty(),
436 webdav.DAVLockDiscoveryProperty(),
437 ])
438 self.register_reporters([
439 caldav.CalendarMultiGetReporter(),
440 caldav.CalendarQueryReporter(),
441 carddav.AddressbookMultiGetReporter(),
442 webdav.DAVExpandPropertyReporter(),
443 sync.SyncCollectionReporter(),
444 ])
445
446
447 if __name__ == '__main__':
448 import optparse
449 import sys
450 parser = optparse.OptionParser()
451 parser.usage = "%prog -d ROOT-DIR [OPTIONS]"
452 parser.add_option("-l", "--listen_address", dest="listen_address",
453 default="localhost",
454 help="Binding IP address.")
455 parser.add_option("-d", "--directory", dest="directory",
456 default=None,
457 help="Default path to serve from.")
458 parser.add_option("-p", "--port", dest="port", type=int,
459 default=8000,
460 help="Port to listen on.")
461 parser.add_option("--current-user-principal",
462 default="/user/",
463 help="Path to current user principal.")
464 options, args = parser.parse_args(sys.argv)
465
466 if options.directory is None:
467 parser.print_usage()
468 sys.exit(1)
469
470 from wsgiref.simple_server import make_server
471 app = DystrosApp(
472 options.directory,
473 current_user_principal=options.current_user_principal)
474 server = make_server(options.listen_address, options.port, app)
475 server.serve_forever()
+0
-990
dystros/webdav.py less more
0 # Dystros
1 # Copyright (C) 2016 Jelmer Vernooij <jelmer@jelmer.uk>
2 #
3 # This program is free software; you can redistribute it and/or
4 # modify it under the terms of the GNU General Public License
5 # as published by the Free Software Foundation; version 2
6 # of the License or (at your option) any later version of
7 # the License.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
17 # MA 02110-1301, USA.
18
19 """Abstract WebDAV server implementation..
20
21 This module contains an abstract WebDAV server. All caldav/carddav specific
22 functionality should live in dystros.caldav/dystros.carddav respectively.
23 """
24
25 # TODO(jelmer): Add authorization support
26
27 import collections
28 import hashlib
29 import logging
30 import posixpath
31 import urllib.parse
32 from wsgiref.util import request_uri
33
34 from defusedxml.ElementTree import fromstring as xmlparse
35 # Hmm, defusedxml doesn't have XML generation functions? :(
36 from xml.etree import ElementTree as ET
37
38 DEFAULT_ENCODING = 'utf-8'
39 COLLECTION_RESOURCE_TYPE = '{DAV:}collection'
40 PRINCIPAL_RESOURCE_TYPE = '{DAV:}principal'
41
42
43 PropStatus = collections.namedtuple(
44 'PropStatus', ['statuscode', 'responsedescription', 'prop'])
45
46
47 def etag_matches(condition, actual_etag):
48 """Check if an etag matches an If-Matches condition.
49
50 :param condition: Condition (e.g. '*', '"foo"' or '"foo", "bar"'
51 :param actual_etag: ETag to compare to. None nonexistant
52 :return: bool indicating whether condition matches
53 """
54 if actual_etag is None and condition:
55 return False
56 for etag in condition.split(','):
57 if etag.strip(' ') == '*':
58 return True
59 if etag.strip(' ') == actual_etag:
60 return True
61 else:
62 return False
63
64
65 class NeedsMultiStatus(Exception):
66 """Raised when a response needs multi-status (e.g. for propstat)."""
67
68
69 class DAVStatus(object):
70 """A DAV response that can be used in multi-status."""
71
72 def __init__(self, href, status=None, error=None, responsedescription=None,
73 propstat=None):
74 self.href = href
75 self.status = status
76 self.error = error
77 self.propstat = propstat
78 self.responsedescription = responsedescription
79
80 def __repr__(self):
81 return "<%s(%r, %r, %r)>" % (
82 type(self).__name__, self.href, self.status, self.responsedescription)
83
84 def get_single_body(self, encoding):
85 if self.propstat and len(self._propstat_by_status()) > 1:
86 raise NeedsMultiStatus()
87 if self.propstat:
88 [ret] = list(self._propstat_xml())
89 body = ET.tostringlist(ret, encoding)
90 return body, ('text/xml; encoding="%s"' % encoding)
91 else:
92 body = self.responsedescription or ''
93 return body, ('text/plain; encoding="%s"' % encoding)
94
95 def _propstat_by_status(self):
96 bystatus = {}
97 for propstat in self.propstat:
98 bystatus.setdefault(
99 (propstat.statuscode, propstat.responsedescription), []).append(
100 propstat.prop)
101 return bystatus
102
103 def _propstat_xml(self):
104 bystatus = self._propstat_by_status()
105 for (status, rd), props in sorted(bystatus.items()):
106 propstat = ET.Element('{DAV:}propstat')
107 ET.SubElement(propstat,
108 '{DAV:}status').text = 'HTTP/1.1 ' + status
109 if rd:
110 ET.SubElement(propstat,
111 '{DAV:}responsedescription').text = responsedescription
112 propresp = ET.SubElement(propstat, '{DAV:}prop')
113 for prop in props:
114 propresp.append(prop)
115 yield propstat
116
117 def aselement(self):
118 ret = ET.Element('{DAV:}response')
119 ET.SubElement(ret, '{DAV:}href').text = self.href
120 if self.status:
121 ET.SubElement(ret, '{DAV:}status').text = 'HTTP/1.1 ' + self.status
122 if self.error:
123 ET.SubElement(ret, '{DAV:}error').append(self.error)
124 if self.responsedescription:
125 ET.SubElement(ret,
126 '{DAV:}responsedescription').text = self.responsedescription
127 if self.propstat is not None:
128 for ps in self._propstat_xml():
129 ret.append(ps)
130 return ret
131
132
133 class DAVProperty(object):
134 """Handler for listing, retrieving and updating DAV Properties."""
135
136 # Property name (e.g. '{DAV:}resourcetype')
137 name = None
138
139 # Whether to include this property in 'allprop' PROPFIND requests.
140 # https://tools.ietf.org/html/rfc4918, section 14.2
141 in_allprops = True
142
143 # Whether this property is protected (i.e. read-only)
144 protected = True
145
146 # Resource type this property belongs to. If None, get_value()
147 # will always be called.
148 resource_type = None
149
150 def get_value(self, resource, el):
151 """Get property with specified name.
152
153 :param resource: Resource for which to retrieve the property
154 :param el: Element to populate
155 :raise KeyError: if this property is not present
156 """
157 raise KeyError(self.name)
158
159 def set_value(self, resource, el):
160 """Set property.
161
162 :param resource: Resource to modify
163 :param el: Element to get new value from
164 """
165 raise NotImplementedError(self.set_value)
166
167 def remove(self, resource):
168 """Remove property.
169
170 :param resource: Resource to modify
171 """
172 raise NotImplementedError(self.remove)
173
174
175 class DAVResourceTypeProperty(DAVProperty):
176 """Provides {DAV:}resourcetype."""
177
178 name = '{DAV:}resourcetype'
179
180 protected = True
181
182 resource_type = None
183
184 def get_value(self, resource, el):
185 for rt in resource.resource_types:
186 ET.SubElement(el, rt)
187
188
189 class DAVDisplayNameProperty(DAVProperty):
190 """Provides {DAV:}displayname.
191
192 https://tools.ietf.org/html/rfc4918, section 5.2
193 """
194
195 name = '{DAV:}displayname'
196 resource_type = None
197
198 def get_value(self, resource, el):
199 el.text = resource.get_displayname()
200
201 # TODO(jelmer): allow modification of this property
202 # protected = True
203
204
205 class DAVGetETagProperty(DAVProperty):
206 """Provides {DAV:}getetag.
207
208 https://tools.ietf.org/html/rfc4918, section 15.6
209 """
210
211 name = '{DAV:}getetag'
212 resource_type = None
213 protected = True
214
215 def get_value(self, resource, el):
216 el.text = resource.get_etag()
217
218
219 def format_datetime(dt):
220 s = "%04d%02d%02dT%02d%02d%02dZ" % (
221 dt.year,
222 dt.month,
223 dt.day,
224 dt.hour,
225 dt.minute,
226 dt.second
227 )
228 return s.encode('utf-8')
229
230
231 class DAVCreationDateProperty(DAVProperty):
232 """Provides {DAV:}creationdate.
233
234 https://tools.ietf.org/html/rfc4918, section 23.2
235 """
236
237 name = '{DAV:}creationdate'
238 resource_type = None
239 protected = True
240
241 def get_value(self, resource, el):
242 el.text = format_datetime(resource.get_creationdate())
243
244
245 class DAVGetContentTypeProperty(DAVProperty):
246 """Provides {DAV:}getcontenttype.
247
248 https://tools.ietf.org/html/rfc4918, section 13.5
249 """
250
251 name = '{DAV:}getcontenttype'
252 resource_type = None
253 protected = True
254
255 def get_value(self, resource, el):
256 el.text = resource.get_content_type()
257
258
259 class DAVCurrentUserPrincipalProperty(DAVProperty):
260 """Provides {DAV:}current-user-principal.
261
262 See https://tools.ietf.org/html/rfc5397
263 """
264
265 name = '{DAV:}current-user-principal'
266 resource_type = None
267 in_allprops = False
268
269 def __init__(self, current_user_principal):
270 super(DAVCurrentUserPrincipalProperty, self).__init__()
271 self.current_user_principal = current_user_principal
272
273 def get_value(self, resource, el):
274 """Get property with specified name.
275
276 :param name: A property name.
277 """
278 ET.SubElement(el, '{DAV:}href').text = self.current_user_principal
279
280
281 class DAVPrincipalURLProperty(DAVProperty):
282
283 name = '{DAV:}principal-URL'
284 resource_type = '{DAV:}principal'
285 in_allprops = True
286
287 def get_value(self, resource, el):
288 """Get property with specified name.
289
290 :param name: A property name.
291 """
292 ET.SubElement(el, '{DAV:}href').text = resource.get_principal_url()
293
294
295 class DAVSupportedReportSetProperty(DAVProperty):
296
297 name = '{DAV:}supported-report-set'
298 resource_type = '{DAV:}collection'
299 in_allprops = False
300
301 def __init__(self, reporters):
302 self._reporters = reporters
303
304 def get_value(self, resource, el):
305 for name in self._reporters:
306 ET.SubElement(el, name)
307
308
309 class GetCTagProperty(DAVProperty):
310 """getctag property
311
312 """
313
314 name = '{http://calendarserver.org/ns/}getctag'
315 resource_type = COLLECTION_RESOURCE_TYPE
316 in_allprops = False
317 protected = True
318
319 def get_value(self, resource, el):
320 el.text = resource.get_ctag()
321
322
323 LOCK_SCOPE_EXCLUSIVE = '{DAV:}exclusive'
324 LOCK_SCOPE_SHARED = '{DAV:}shared'
325 LOCK_TYPE_WRITE = '{DAV:}write'
326
327
328 ActiveLock = collections.namedtuple(
329 'ActiveLock',
330 ['lockscope', 'locktype', 'depth', 'owner', 'timeout','locktoken',
331 'lockroot'])
332
333
334 class DAVResource(object):
335 """A WebDAV resource."""
336
337 # A list of resource type names (e.g. '{DAV:}collection')
338 resource_types = []
339
340 def get_displayname(self):
341 """Get the resource display name."""
342 raise KeyError
343
344 def get_creationdate(self):
345 """Get the resource creation date.
346
347 :return: A datetime object
348 """
349 raise NotImplementedError(self.get_creationdate)
350
351 def get_supported_locks(self):
352 """Get the list of supported locks.
353
354 This should return a list of (lockscope, locktype) tuples.
355 Known lockscopes are LOCK_SCOPE_EXCLUSIVE, LOCK_SCOPE_SHARED
356 Known locktypes are LOCK_TYPE_WRITE
357 """
358 raise NotImplementedError(self.get_supported_locks)
359
360 def get_active_locks(self):
361 """Return the list of active locks.