Code Repositories xandikos / upstream/0.0.3
Merge tag 'v0.0.3' into upstream Release 0.0.3 Jelmer Vernooń≥ 2 years ago
21 changed file(s) with 424 addition(s) and 267 deletion(s). Raw diff Collapse all Expand all
1212 compat/vdirsyncer/
1313 compat/ccs-caldavtester/
1414 *.egg*
15 child.log
16 debug.log
1111 install:
1212 - pip install pip --upgrade
1313 - pip install coverage codecov flake8 pycalendar
14 - sudo apt-get install -qq libneon27-dev curl
14 - sudo apt-get install -qq libneon27-dev curl python2.7
1515 - python setup.py develop
1616 script:
1717 - make style
2121 - mv .coverage .coverage.litmus
2222 - make coverage-vdirsyncer
2323 - mv .coverage .coverage.vdirsyncer
24 # CalDAVtester needs to run with Python2, but travis seems to always force
25 # Python3.
26 # - make coverage-caldavtester
27 # - mv .coverage .coverage.caldavtester
24 - make coverage-caldavtester
25 - mv .coverage .coverage.caldavtester
2826 after_success:
29 - codecov -F unit -f coverage.unit
30 - codecov -F litmus -f coverage.litmus
31 - codecov -F vdirsyncer -f coverage.vdirsyncer
32 # - codecov -F caldavtester -f coverage.caldavtester
27 - python -m coverage combine
28 - codecov
0 include AUTHORS
1 include CONTRIBUTING.md
2 include COPYING
3 include Makefile
4 include README.md
5 include TODO
6 recursive-include compat/
7 recursive-include examples/
8 recursive-include notes/
9 include tox.ini
10 include xandikos.1
3838
3939 check-all: check check-vdirsyncer check-litmus check-caldavtester
4040
41 coverage-all: coverage coverage-litmus coverage-vdirsyncer
41 coverage-all: coverage coverage-litmus coverage-vdirsyncer coverage-caldavtester
4242
4343 coverage:
4444 $(COVERAGE_RUN) --source=xandikos -m unittest $(TESTSUITE)
0 Authentication
1 ==============
2
3 Ideally, xandikos would stay out of the business of authenticating users.
4 The trouble with this is that there are many flavours that need to
5 be supported and configured.
6
7 However, it is still necessary for xandikos to handle authorization.
8
9 An external system authenticates the user, and then sets the REMOTE_USER
10 environment variable.
11
12 Per
13 http://wsgi.readthedocs.io/en/latest/specifications/simple_authentication.html,
14 Xandikos should distinguish between 401 and 403.
0 Some properties need WebDAV server metadata:
0 Contexts
1 ========
2
3 Currently, property get_value/set_value receive three pieces of context:
4
5 * HREF for the resource
6 * resource object
7 * Element object to update
8
9 However, some properties need WebDAV server metadata:
110
211 supported-live-property-set needs list of properties
312 supported-report-set needs list of reports
1625 class Context(object):
1726
1827 def get_current_user(self):
19 return (name, principal)
28 return (name, principal)
0 File structure
1 ==============
2
03 Collections are represented as Git repositories on disk.
14
25 A specific version is represented as a commit id. The 'ctag' for a calendar is taken from the
0 Goals
1 =====
2
03 - standards compliant
14 - standards complete
25 - backed by git
0 Monitoring
1 ==========
2
03 Things to monitor:
14 - number of uploaded items
25 - number of accessed store items
0 Principal
1 =========
2
03 Need per principal config:
14
25 * calendar home sets
36 * addressbook home sets
4 * address set
7 * user address set
8 * infit settings
0 1. Update version in setup.py
1 2. Update version in xandikos/__init__.py
2 3. git commit -a -m "Release $VERSION"
3 4. git tag -as -m "Release $VERSION"
4 5. ./setup.py sdist --sign upload
00 WebDAV implementation
1 ---------------------
1 =====================
22
33 class DAVPropertyProvider(object):
44
2121
2222 from setuptools import setup
2323
24 version = "0.0.2"
24 version = "0.0.3"
2525
2626 setup(name="xandikos",
2727 description="CalDAV/CardDAV server",
3131 license="GNU GPLv3 or later",
3232 url="https://www.jelmer.uk/projects/xandikos",
3333 install_requires=['icalendar', 'dulwich', 'defusedxml', 'jinja2'],
34 packages=['xandikos'],
35 package_data={'xandikos': ['templates/*.html']},
34 packages=['xandikos', 'xandikos.tests'],
3635 scripts=['bin/xandikos'],
3736 classifiers=[
3837 'Development Status :: 4 - Beta',
2020
2121 """CalDAV/CardDAV server."""
2222
23 __version__ = (0, 0, 2)
23 __version__ = (0, 0, 3)
2424
2525 import defusedxml.ElementTree # noqa: This does some monkey-patching on-load
677677
678678 """
679679 name = '{http://calendarserver.org/ns/}calendar-proxy-read-for'
680 resource_type = webdav.PRINCIPAL_RESOURCE_TYPE
680681 in_allprops = False
681682 live = True
682683
693694
694695 """
695696 name = '{http://calendarserver.org/ns/}calendar-proxy-write-for'
697 resource_type = webdav.PRINCIPAL_RESOURCE_TYPE
696698 in_allprops = False
697699 live = True
698700
782784 ret.add_component(fb)
783785 start_response('200 OK', [])
784786 return [ret.to_ical()]
787
788
789 class MkcalendarMethod(webdav.Method):
790
791 def handle(self, environ, start_response, app):
792 try:
793 content_type = environ['CONTENT_TYPE']
794 except KeyError:
795 base_content_type = None
796 else:
797 base_content_type, params = webdav.parse_type(content_type)
798 if base_content_type not in (
799 'text/xml', 'application/xml', None, 'text/plain'
800 ):
801 raise webdav.UnsupportedMediaType(content_type)
802 href, path, resource = app._get_resource_from_environ(environ)
803 if resource is not None:
804 return webdav._send_method_not_allowed(
805 environ, start_response,
806 app._get_allowed_methods(environ))
807 try:
808 resource = app.backend.create_collection(path)
809 except FileNotFoundError:
810 start_response('409 Conflict', [])
811 return []
812 el = ET.Element('{DAV:}resourcetype')
813 app.properties['{DAV:}resourcetype'].get_value(href, resource, el)
814 ET.SubElement(el, '{urn:ietf:params:xml:ns:caldav}calendar')
815 app.properties['{DAV:}resourcetype'].set_value(href, resource, el)
816 if base_content_type in ('text/xml', 'application/xml'):
817 et = webdav._readXmlBody(environ, '{DAV:}mkcalendar')
818 propstat = []
819 for el in et:
820 if el.tag != '{DAV:}set':
821 raise webdav.BadRequestError(
822 'Unknown tag %s in mkcalendar' % el.tag)
823 propstat.extend(webdav.apply_modify_prop(
824 el, href, resource, app.properties))
825 ret = ET.Element('{DAV:}mkcalendar-response')
826 for propstat_el in webdav.propstat_as_xml(propstat):
827 ret.append(propstat_el)
828 return webdav._send_xml_response(
829 start_response, '201 Created', ret, webdav.DEFAULT_ENCODING)
830 else:
831 start_response('201 Created', [])
832 return []
8888
8989
9090 DELTA_IGNORE_FIELDS = set(["LAST-MODIFIED", "SEQUENCE", "DTSTAMP", "PRODID",
91 "CREATED", "COMPLETED"])
91 "CREATED", "COMPLETED", "X-MOZ-GENERATION",
92 "X-LIC-ERROR"])
9293
9394
9495 def describe_calendar_delta(old_cal, new_cal):
114114 if old_propstat != new_propstat:
115115 propstat.append(new_propstat)
116116 yield webdav.Status(
117 urllib.parse.urljoin(href + '/', name), propstat=propstat)
118 # TODO(jelmer): This is a bit of a hack..
117 urllib.parse.urljoin(webdav.ensure_trailing_slash(href), name),
118 propstat=propstat)
119119 yield SyncToken(new_token)
120120
121121
1515 # along with this program; if not, write to the Free Software
1616 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
1717 # MA 02110-1301, USA.
18
19 from wsgiref.util import setup_testing_defaults
20
21 from xandikos import caldav
22 from xandikos.webdav import Property, WebDAVApp, ET
23
24 from xandikos.tests import test_webdav
25
26
27 class WebTests(test_webdav.WebTestCase):
28
29 def makeApp(self, backend):
30 app = WebDAVApp(backend)
31 app.register_methods([caldav.MkcalendarMethod()])
32 return app
33
34 def mkcalendar(self, app, path):
35 environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'MKCALENDAR',
36 'SCRIPT_NAME': ''}
37 setup_testing_defaults(environ)
38 _code = []
39 _headers = []
40
41 def start_response(code, headers):
42 _code.append(code)
43 _headers.extend(headers)
44 contents = b''.join(app(environ, start_response))
45 return _code[0], _headers, contents
46
47 def test_mkcalendar_ok(self):
48 class Backend(object):
49 def create_collection(self, relpath):
50 pass
51
52 def get_resource(self, relpath):
53 return None
54
55 class ResourceTypeProperty(Property):
56 name = '{DAV:}resourcetype'
57
58 def get_value(unused_self, href, resource, ret):
59 ET.SubElement(ret, '{DAV:}collection')
60
61 def set_value(unused_self, href, resource, ret):
62 self.assertEqual(
63 ['{DAV:}collection',
64 '{urn:ietf:params:xml:ns:caldav}calendar'],
65 [x.tag for x in ret])
66
67 app = self.makeApp(Backend())
68 app.register_properties([ResourceTypeProperty()])
69 code, headers, contents = self.mkcalendar(app, '/resource/bla')
70 self.assertEqual('201 Created', code)
71 self.assertEqual(b'', contents)
3131 )
3232
3333
34 class WebTests(unittest.TestCase):
34 class WebTestCase(unittest.TestCase):
3535
3636 def setUp(self):
37 super(WebTests, self).setUp()
37 super(WebTestCase, self).setUp()
3838 logging.disable(logging.WARNING)
3939 self.addCleanup(logging.disable, logging.NOTSET)
4040
4545 app.register_properties(properties)
4646 return app
4747
48
49 class WebTests(WebTestCase):
50
4851 def _method(self, app, method, path):
4952 environ = {'PATH_INFO': path, 'REQUEST_METHOD': method,
5053 'SCRIPT_NAME': ''}
6063
6164 def lock(self, app, path):
6265 return self._method(app, 'LOCK', path)
63
64 def mkcalendar(self, app, path):
65 environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'MKCALENDAR',
66 'SCRIPT_NAME': ''}
67 setup_testing_defaults(environ)
68 _code = []
69 _headers = []
70
71 def start_response(code, headers):
72 _code.append(code)
73 _headers.extend(headers)
74 contents = b''.join(app(environ, start_response))
75 return _code[0], _headers, contents
7666
7767 def mkcol(self, app, path):
7868 environ = {'PATH_INFO': path, 'REQUEST_METHOD': 'MKCOL',
193183 code, headers, contents = self.lock(app, '/resource')
194184 self.assertEqual('405 Method Not Allowed', code)
195185 self.assertIn(
196 ('Allow', ('DELETE, GET, HEAD, MKCALENDAR, MKCOL, OPTIONS, '
186 ('Allow', ('DELETE, GET, HEAD, MKCOL, OPTIONS, '
197187 'POST, PROPFIND, PROPPATCH, PUT, REPORT')),
198188 headers)
199189 self.assertEqual(b'', contents)
207197 return None
208198 app = WebDAVApp(Backend())
209199 code, headers, contents = self.mkcol(app, '/resource/bla')
210 self.assertEqual('201 Created', code)
211 self.assertEqual(b'', contents)
212
213 def test_mkcalendar_ok(self):
214 class Backend(object):
215 def create_collection(self, relpath):
216 pass
217
218 def get_resource(self, relpath):
219 return None
220
221 class ResourceTypeProperty(Property):
222 name = '{DAV:}resourcetype'
223
224 def get_value(unused_self, href, resource, ret):
225 ET.SubElement(ret, '{DAV:}collection')
226
227 def set_value(unused_self, href, resource, ret):
228 self.assertEqual(
229 ['{DAV:}collection',
230 '{urn:ietf:params:xml:ns:caldav}calendar'],
231 [x.tag for x in ret])
232
233 app = WebDAVApp(Backend())
234 app.register_properties([ResourceTypeProperty()])
235 code, headers, contents = self.mkcalendar(app, '/resource/bla')
236200 self.assertEqual('201 Created', code)
237201 self.assertEqual(b'', contents)
238202
777777 sync.SyncCollectionReporter(),
778778 caldav.FreeBusyQueryReporter(),
779779 ])
780 self.register_methods([
781 caldav.MkcalendarMethod(),
782 ])
780783
781784
782785 class WellknownRedirector(object):
7373 self.content_type = content_type
7474
7575
76 class UnauthorizedError(Exception):
77 """Base class for unauthorized errors."""
78
79 def __init__(self):
80 super(Exception, self).__init__(
81 "Request unauthorized")
82
83
7684 def pick_content_types(accepted_content_types, available_content_types):
7785 """Pick best content types for a client.
7886
481489
482490 :param name: A property name.
483491 """
484 el.append(create_href(self.current_user_principal, href))
492 if self.current_user_principal is None:
493 ET.SubElement(el, '{DAV:}unauthenticated')
494 else:
495 el.append(create_href(self.current_user_principal, href))
485496
486497
487498 class PrincipalURLProperty(Property):
12171228 yield PropStatus(statuscode, None, ET.Element(propel.tag))
12181229
12191230
1220 class WebDAVApp(object):
1221 """A wsgi App that provides a WebDAV server.
1222
1223 A concrete implementation should provide an implementation of the
1224 lookup_resource function that can map a path to a Resource object
1225 (returning None for nonexistant objects).
1226 """
1227
1228 def __init__(self, backend):
1229 self.backend = backend
1230 self.properties = {}
1231 self.reporters = {}
1232
1233 def _get_resource_from_environ(self, environ):
1234 path = path_from_environ(environ, 'PATH_INFO')
1235 href = (environ['SCRIPT_NAME'] + path)
1236 r = self.backend.get_resource(path)
1237 return (href, path, r)
1238
1239 def register_properties(self, properties):
1240 for p in properties:
1241 self.properties[p.name] = p
1242
1243 def register_reporters(self, reporters):
1244 for r in reporters:
1245 self.reporters[r.name] = r
1246
1247 def _get_dav_features(self, resource):
1248 # TODO(jelmer): Support access-control
1249 return ['1', '2', '3', 'calendar-access', 'addressbook']
1250
1251 def _get_allowed_methods(self, environ):
1252 """List of supported methods on this endpoint."""
1253 # TODO(jelmer): Look up resource to determine supported methods.
1254 return sorted([n[3:] for n in dir(self) if n.startswith('do_')])
1255
1256 def do_HEAD(self, environ, start_response):
1257 return self._do_get(environ, start_response, send_body=False)
1258
1259 def do_GET(self, environ, start_response):
1260 return self._do_get(environ, start_response, send_body=True)
1261
1262 def _do_get(self, environ, start_response, send_body):
1263 unused_href, unused_path, r = self._get_resource_from_environ(environ)
1264 if r is None:
1265 return _send_not_found(environ, start_response)
1266 accept_content_types = parse_accept_header(
1267 environ.get('HTTP_ACCEPT', '*/*'))
1268 accept_content_languages = parse_accept_header(
1269 environ.get('HTTP_ACCEPT_LANGUAGES', '*'))
1270
1271 (
1272 body,
1273 content_length,
1274 current_etag,
1275 content_type,
1276 content_languages
1277 ) = r.render(accept_content_types, accept_content_languages)
1278
1279 if_none_match = environ.get('HTTP_IF_NONE_MATCH', None)
1280 if (
1281 if_none_match and current_etag is not None and
1282 etag_matches(if_none_match, current_etag)
1283 ):
1284 start_response('304 Not Modified', [])
1285 return []
1286 headers = [
1287 ('Content-Length', str(content_length)),
1288 ]
1289 if current_etag is not None:
1290 headers.append(('ETag', current_etag))
1291 if content_type is not None:
1292 headers.append(('Content-Type', content_type))
1293 try:
1294 last_modified = r.get_last_modified()
1295 except KeyError:
1296 pass
1297 else:
1298 headers.append(('Last-Modified', last_modified))
1299 if content_languages is not None:
1300 headers.append(('Content-Language', ', '.join(content_languages)))
1301 start_response('200 OK', headers)
1302 if send_body:
1303 return body
1304 else:
1305 return []
1306
1307 def do_DELETE(self, environ, start_response):
1308 unused_href, path, r = self._get_resource_from_environ(environ)
1231 def _readBody(environ):
1232 try:
1233 request_body_size = int(environ['CONTENT_LENGTH'])
1234 except KeyError:
1235 return [environ['wsgi.input'].read()]
1236 else:
1237 return [environ['wsgi.input'].read(request_body_size)]
1238
1239
1240 def _readXmlBody(environ, expected_tag=None):
1241 try:
1242 content_type = environ['CONTENT_TYPE']
1243 except KeyError:
1244 pass # Just assume it's okay?
1245 else:
1246 base_content_type, params = parse_type(content_type)
1247 if base_content_type not in ('text/xml', 'application/xml'):
1248 raise UnsupportedMediaType(content_type)
1249 body = b''.join(_readBody(environ))
1250 try:
1251 et = xmlparse(body)
1252 except ET.ParseError:
1253 raise BadRequestError('Unable to parse body.')
1254 if expected_tag is not None and et.tag != expected_tag:
1255 raise BadRequestError('Expected %s tag, got %s' %
1256 (expected_tag, et.tag))
1257 return et
1258
1259
1260 class Method(object):
1261
1262 @property
1263 def name(self):
1264 return type(self).__name__.upper()[:-6]
1265
1266 def handle(self, environ, start_response, app):
1267 raise NotImplementedError(self.handle)
1268
1269 def allow(self, environ):
1270 raise NotImplementedError(self.allow)
1271
1272
1273 class DeleteMethod(Method):
1274
1275 def handle(self, environ, start_response, app):
1276 unused_href, path, r = app._get_resource_from_environ(environ)
13091277 if r is None:
13101278 return _send_not_found(environ, start_response)
13111279 container_path, item_name = posixpath.split(path)
1312 pr = self.backend.get_resource(container_path)
1280 pr = app.backend.get_resource(container_path)
13131281 if pr is None:
13141282 return _send_not_found(environ, start_response)
13151283 current_etag = r.get_etag()
13211289 start_response('204 No Content', [])
13221290 return []
13231291
1324 def do_POST(self, environ, start_response):
1292
1293 class PostMethod(Method):
1294
1295 def handle(self, environ, start_response, app):
13251296 # see RFC5995
1326 new_contents = self._readBody(environ)
1327 unused_href, path, r = self._get_resource_from_environ(environ)
1297 new_contents = _readBody(environ)
1298 unused_href, path, r = app._get_resource_from_environ(environ)
13281299 if r is None:
13291300 return _send_not_found(environ, start_response)
13301301 if COLLECTION_RESOURCE_TYPE not in r.resource_types:
1331 start_response('405 Method Not Allowed', [])
1332 return []
1302 return _send_method_not_allowed(
1303 environ, start_response,
1304 app._get_allowed_methods(environ))
13331305 content_type = environ['CONTENT_TYPE'].split(';')[0]
13341306 try:
13351307 (name, etag) = r.create_member(None, new_contents, content_type)
13451317 start_response('200 OK', [('Location', href)])
13461318 return []
13471319
1348 def do_PUT(self, environ, start_response):
1349 new_contents = self._readBody(environ)
1350 unused_href, path, r = self._get_resource_from_environ(environ)
1320
1321 class PutMethod(Method):
1322
1323 def handle(self, environ, start_response, app):
1324 new_contents = _readBody(environ)
1325 unused_href, path, r = app._get_resource_from_environ(environ)
13511326 if r is not None:
13521327 current_etag = r.get_etag()
13531328 else:
13711346 except NotImplementedError:
13721347 return _send_method_not_allowed(
13731348 environ, start_response,
1374 self._get_allowed_methods(environ))
1349 app._get_allowed_methods(environ))
13751350 else:
13761351 start_response('204 No Content', [
13771352 ('ETag', new_etag)])
13781353 return []
13791354 content_type = environ.get('CONTENT_TYPE')
13801355 container_path, name = posixpath.split(path)
1381 r = self.backend.get_resource(container_path)
1356 r = app.backend.get_resource(container_path)
13821357 if r is not None:
13831358 if COLLECTION_RESOURCE_TYPE not in r.resource_types:
1384 start_response('405 Method Not Allowed', [])
1385 return []
1359 return _send_method_not_allowed(
1360 environ, start_response,
1361 app._get_allowed_methods(environ))
13861362 try:
13871363 (new_name, new_etag) = r.create_member(
13881364 name, new_contents, content_type)
13961372 return []
13971373 return _send_not_found(environ, start_response)
13981374
1399 def _readBody(self, environ):
1400 try:
1401 request_body_size = int(environ['CONTENT_LENGTH'])
1402 except KeyError:
1403 return [environ['wsgi.input'].read()]
1404 else:
1405 return [environ['wsgi.input'].read(request_body_size)]
1406
1407 def _readXmlBody(self, environ, expected_tag=None):
1408 try:
1409 content_type = environ['CONTENT_TYPE']
1410 except KeyError:
1411 pass # Just assume it's okay?
1412 else:
1413 base_content_type, params = parse_type(content_type)
1414 if base_content_type not in ('text/xml', 'application/xml'):
1415 raise UnsupportedMediaType(content_type)
1416 body = b''.join(self._readBody(environ))
1417 try:
1418 et = xmlparse(body)
1419 except ET.ParseError:
1420 raise BadRequestError('Unable to parse body.')
1421 if expected_tag is not None and et.tag != expected_tag:
1422 raise BadRequestError('Expected %s tag, got %s' %
1423 (expected_tag, et.tag))
1424 return et
1425
1426 def do_REPORT(self, environ, start_response):
1375
1376 class ReportMethod(Method):
1377
1378 def handle(self, environ, start_response, app):
14271379 # See https://tools.ietf.org/html/rfc3253, section 3.6
1428 base_href, unused_path, r = self._get_resource_from_environ(environ)
1380 base_href, unused_path, r = app._get_resource_from_environ(environ)
14291381 if r is None:
14301382 return _send_not_found(environ, start_response)
14311383 depth = environ.get("HTTP_DEPTH", "0")
1432 et = self._readXmlBody(environ, None)
1384 et = _readXmlBody(environ, None)
14331385 try:
1434 reporter = self.reporters[et.tag]
1386 reporter = app.reporters[et.tag]
14351387 except KeyError:
14361388 logging.warning('Client requested unknown REPORT %s', et.tag)
14371389 return _send_simple_dav_error(
14481400 return reporter.report(
14491401 environ, start_response, et,
14501402 functools.partial(
1451 _get_resources_by_hrefs, self.backend, environ),
1452 self.properties, base_href, r, depth)
1403 _get_resources_by_hrefs, app.backend, environ),
1404 app.properties, base_href, r, depth)
1405
1406
1407 class PropfindMethod(Method):
14531408
14541409 @multistatus
1455 def do_PROPFIND(self, environ):
1410 def handle(self, environ, app):
14561411 base_href, unused_path, base_resource = (
1457 self._get_resource_from_environ(environ))
1412 app._get_resource_from_environ(environ))
14581413 if base_resource is None:
14591414 return Status(request_uri(environ), '404 Not Found')
14601415 # Default depth is infinity, per RFC2518
14651420 ):
14661421 requested = ET.Element('{DAV:}allprop')
14671422 else:
1468 et = self._readXmlBody(environ, '{DAV:}propfind')
1423 et = _readXmlBody(environ, '{DAV:}propfind')
14691424 try:
14701425 [requested] = et
14711426 except ValueError:
14761431 for href, resource in traverse_resource(
14771432 base_resource, base_href, depth):
14781433 propstat = get_properties(
1479 href, resource, self.properties, requested)
1434 href, resource, app.properties, requested)
14801435 ret.append(Status(href, '200 OK', propstat=list(propstat)))
14811436 # By my reading of the WebDAV RFC, it should be legal to return
14821437 # '200 OK' here if Depth=0, but the RFC is not super clear and
14871442 for href, resource in traverse_resource(
14881443 base_resource, base_href, depth):
14891444 propstat = []
1490 for name in self.properties:
1491 ps = get_property(href, resource, self.properties, name)
1445 for name in app.properties:
1446 ps = get_property(href, resource, app.properties, name)
14921447 if ps.statuscode == '200 OK':
14931448 propstat.append(ps)
14941449 ret.append(Status(href, '200 OK', propstat=propstat))
14981453 for href, resource in traverse_resource(
14991454 base_resource, base_href, depth):
15001455 propstat = []
1501 for name, prop in self.properties.items():
1456 for name, prop in app.properties.items():
15021457 if prop.is_set(href, resource):
15031458 propstat.append(
15041459 PropStatus('200 OK', None, ET.Element(name)))
15081463 raise BadRequestError('Expected prop/allprop/propname tag, got ' +
15091464 requested.tag)
15101465
1466
1467 class ProppatchMethod(Method):
1468
15111469 @multistatus
1512 def do_PROPPATCH(self, environ):
1513 href, unused_path, resource = self._get_resource_from_environ(environ)
1470 def handle(self, environ, app):
1471 href, unused_path, resource = app._get_resource_from_environ(environ)
15141472 if resource is None:
15151473 return Status(request_uri(environ), '404 Not Found')
1516 et = self._readXmlBody(environ, '{DAV:}propertyupdate')
1474 et = _readXmlBody(environ, '{DAV:}propertyupdate')
15171475 propstat = []
15181476 for el in et:
15191477 if el.tag not in ('{DAV:}set', '{DAV:}remove'):
15201478 raise BadRequestError('Unknown tag %s in propertyupdate'
15211479 % el.tag)
15221480 propstat.extend(apply_modify_prop(el, href, resource,
1523 self.properties))
1481 app.properties))
15241482 return [Status(request_uri(environ), propstat=propstat)]
15251483
1526 # TODO(jelmer): This should really live in xandikos.caldav
1527 def do_MKCALENDAR(self, environ, start_response):
1528 try:
1529 content_type = environ['CONTENT_TYPE']
1530 except KeyError:
1531 base_content_type = None
1532 else:
1533 base_content_type, params = parse_type(content_type)
1534 if base_content_type not in (
1535 'text/xml', 'application/xml', None, 'text/plain'
1536 ):
1537 raise UnsupportedMediaType(content_type)
1538 href, path, resource = self._get_resource_from_environ(environ)
1539 if resource is not None:
1540 start_response('405 Method Not Allowed', [])
1541 return []
1542 try:
1543 resource = self.backend.create_collection(path)
1544 except FileNotFoundError:
1545 start_response('409 Conflict', [])
1546 return []
1547 el = ET.Element('{DAV:}resourcetype')
1548 self.properties['{DAV:}resourcetype'].get_value(href, resource, el)
1549 ET.SubElement(el, '{urn:ietf:params:xml:ns:caldav}calendar')
1550 self.properties['{DAV:}resourcetype'].set_value(href, resource, el)
1551 if base_content_type in ('text/xml', 'application/xml'):
1552 et = self._readXmlBody(environ, '{DAV:}mkcalendar')
1553 propstat = []
1554 for el in et:
1555 if el.tag != '{DAV:}set':
1556 raise BadRequestError('Unknown tag %s in mkcalendar'
1557 % el.tag)
1558 propstat.extend(apply_modify_prop(el, href, resource,
1559 self.properties))
1560 ret = ET.Element('{DAV:}mkcalendar-response')
1561 for propstat_el in propstat_as_xml(propstat):
1562 ret.append(propstat_el)
1563 return _send_xml_response(start_response, '201 Created',
1564 ret, DEFAULT_ENCODING)
1565 else:
1566 start_response('201 Created', [])
1567 return []
1568
1569 def do_MKCOL(self, environ, start_response):
1484
1485 class MkcolMethod(Method):
1486
1487 def handle(self, environ, start_response, app):
15701488 try:
15711489 content_type = environ['CONTENT_TYPE']
15721490 except KeyError:
15771495 'text/plain', 'text/xml', 'application/xml', None
15781496 ):
15791497 raise UnsupportedMediaType(base_content_type)
1580 href, path, resource = self._get_resource_from_environ(environ)
1498 href, path, resource = app._get_resource_from_environ(environ)
15811499 if resource is not None:
1582 start_response('405 Method Not Allowed', [])
1583 return []
1500 return _send_method_not_allowed(
1501 environ, start_response,
1502 app._get_allowed_methods(environ))
15841503 try:
1585 resource = self.backend.create_collection(path)
1504 resource = app.backend.create_collection(path)
15861505 except FileNotFoundError:
15871506 start_response('409 Conflict', [])
15881507 return []
15891508 if base_content_type in ('text/xml', 'application/xml'):
15901509 # Extended MKCOL (RFC5689)
1591 et = self._readXmlBody(environ, '{DAV:}mkcol')
1510 et = _readXmlBody(environ, '{DAV:}mkcol')
15921511 propstat = []
15931512 for el in et:
15941513 if el.tag != '{DAV:}set':
15951514 raise BadRequestError('Unknown tag %s in mkcol' % el.tag)
15961515 propstat.extend(apply_modify_prop(el, href, resource,
1597 self.properties))
1516 app.properties))
15981517 ret = ET.Element('{DAV:}mkcol-response')
15991518 for propstat_el in propstat_as_xml(propstat):
16001519 ret.append(propstat_el)
16041523 start_response('201 Created', [])
16051524 return []
16061525
1607 def do_OPTIONS(self, environ, start_response):
1526
1527 class OptionsMethod(Method):
1528
1529 def handle(self, environ, start_response, app):
16081530 headers = []
16091531 if environ['PATH_INFO'] != '*':
16101532 unused_href, unused_path, r = (
1611 self._get_resource_from_environ(environ))
1533 app._get_resource_from_environ(environ))
16121534 if r is None:
16131535 return _send_not_found(environ, start_response)
1614 dav_features = self._get_dav_features(r)
1536 dav_features = app._get_dav_features(r)
16151537 headers.append(('DAV', ', '.join(dav_features)))
1616 allowed_methods = self._get_allowed_methods(environ)
1538 allowed_methods = app._get_allowed_methods(environ)
16171539 headers.append(('Allow', ', '.join(allowed_methods)))
16181540
16191541 # RFC7231 requires that if there is no response body,
16241546 ('Content-Length', '0')])
16251547 return []
16261548
1549
1550 class HeadMethod(Method):
1551
1552 def handle(self, environ, start_response, app):
1553 return _do_get(environ, start_response, app, send_body=False)
1554
1555
1556 class GetMethod(Method):
1557
1558 def handle(self, environ, start_response, app):
1559 return _do_get(environ, start_response, app, send_body=True)
1560
1561
1562 def _do_get(environ, start_response, app, send_body):
1563 unused_href, unused_path, r = app._get_resource_from_environ(environ)
1564 if r is None:
1565 return _send_not_found(environ, start_response)
1566 accept_content_types = parse_accept_header(
1567 environ.get('HTTP_ACCEPT', '*/*'))
1568 accept_content_languages = parse_accept_header(
1569 environ.get('HTTP_ACCEPT_LANGUAGES', '*'))
1570
1571 (
1572 body,
1573 content_length,
1574 current_etag,
1575 content_type,
1576 content_languages
1577 ) = r.render(accept_content_types, accept_content_languages)
1578
1579 if_none_match = environ.get('HTTP_IF_NONE_MATCH', None)
1580 if (
1581 if_none_match and current_etag is not None and
1582 etag_matches(if_none_match, current_etag)
1583 ):
1584 start_response('304 Not Modified', [])
1585 return []
1586 headers = [
1587 ('Content-Length', str(content_length)),
1588 ]
1589 if current_etag is not None:
1590 headers.append(('ETag', current_etag))
1591 if content_type is not None:
1592 headers.append(('Content-Type', content_type))
1593 try:
1594 last_modified = r.get_last_modified()
1595 except KeyError:
1596 pass
1597 else:
1598 headers.append(('Last-Modified', last_modified))
1599 if content_languages is not None:
1600 headers.append(('Content-Language', ', '.join(content_languages)))
1601 start_response('200 OK', headers)
1602 if send_body:
1603 return body
1604 else:
1605 return []
1606
1607
1608 class WebDAVApp(object):
1609 """A wsgi App that provides a WebDAV server.
1610
1611 A concrete implementation should provide an implementation of the
1612 lookup_resource function that can map a path to a Resource object
1613 (returning None for nonexistant objects).
1614 """
1615
1616 def __init__(self, backend):
1617 self.backend = backend
1618 self.properties = {}
1619 self.reporters = {}
1620 self.methods = {}
1621 self.register_methods([
1622 DeleteMethod(),
1623 PostMethod(),
1624 PutMethod(),
1625 ReportMethod(),
1626 PropfindMethod(),
1627 ProppatchMethod(),
1628 MkcolMethod(),
1629 OptionsMethod(),
1630 GetMethod(),
1631 HeadMethod(),
1632 ])
1633
1634 def _get_resource_from_environ(self, environ):
1635 path = path_from_environ(environ, 'PATH_INFO')
1636 href = (environ['SCRIPT_NAME'] + path)
1637 r = self.backend.get_resource(path)
1638 return (href, path, r)
1639
1640 def register_properties(self, properties):
1641 for p in properties:
1642 self.properties[p.name] = p
1643
1644 def register_reporters(self, reporters):
1645 for r in reporters:
1646 self.reporters[r.name] = r
1647
1648 def register_methods(self, methods):
1649 for m in methods:
1650 self.methods[m.name] = m
1651
1652 def _get_dav_features(self, resource):
1653 # TODO(jelmer): Support access-control
1654 return ['1', '2', '3', 'calendar-access', 'addressbook']
1655
1656 def _get_allowed_methods(self, environ):
1657 """List of supported methods on this endpoint."""
1658 # TODO(jelmer): Look up resource to determine supported methods.
1659 return sorted(self.methods.keys())
1660
16271661 def __call__(self, environ, start_response):
16281662 if environ.get('HTTP_EXPECT', '') != '':
16291663 start_response('417 Expectation Failed', [])
16301664 return []
16311665 method = environ['REQUEST_METHOD']
16321666 try:
1633 do = getattr(self, 'do_' + method)
1634 except AttributeError as e:
1667 do = self.methods[method]
1668 except KeyError as e:
16351669 return _send_method_not_allowed(environ, start_response,
16361670 self._get_allowed_methods(environ))
16371671 try:
1638 return do(environ, start_response)
1672 return do.handle(environ, start_response, self)
16391673 except BadRequestError as e:
16401674 start_response('400 Bad Request', [])
16411675 return [e.message.encode(DEFAULT_ENCODING)]
16461680 start_response('415 Unsupported Media Type', [])
16471681 return [('Unsupported media type %r' % e.content_type)
16481682 .encode(DEFAULT_ENCODING)]
1683 except UnauthorizedError as e:
1684 start_response('401 Unauthorized', [])
1685 return [('Please login.'.encode(DEFAULT_ENCODING))]