OwlCyberSecurity - MANAGER
Edit File: storebridge.py
# -*- test-case-name: twistedcaldav.test.test_wrapping -*- ## # Copyright (c) 2005-2017 Apple Inc. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ## import hashlib import time from urlparse import urlsplit, urljoin import uuid from pycalendar.datetime import DateTime from twext.enterprise.locking import LockTimeout from twext.python.log import Logger from twisted.internet.defer import succeed, inlineCallbacks, returnValue, maybeDeferred from twisted.internet.protocol import Protocol from twisted.python.util import FancyEqMixin from twistedcaldav import customxml, carddavxml, caldavxml, ical from twistedcaldav.caldavxml import ( caldav_namespace, MaxAttendeesPerInstance, MaxInstances, NoUIDConflict ) from twistedcaldav.carddavxml import carddav_namespace, NoUIDConflict as NovCardUIDConflict from twistedcaldav.config import config from twistedcaldav.customxml import calendarserver_namespace from twistedcaldav.ical import ( Component as VCalendar, Property as VProperty, iCalendarProductID, Component ) from twistedcaldav.instance import ( InvalidOverriddenInstanceError, TooManyInstancesError ) from twistedcaldav.memcachelock import MemcacheLockTimeoutError from twistedcaldav.notifications import NotificationCollectionResource, NotificationResource from twistedcaldav.resource import CalDAVResource, DefaultAlarmPropertyMixin, \ requiresPermissions from twistedcaldav.scheduling_store.caldav.resource import ScheduleInboxResource from twistedcaldav.sharing import ( invitationBindStatusToXMLMap, invitationBindModeToXMLMap ) from twistedcaldav.util import bestAcceptType, matchClientFixes from twistedcaldav.vcard import Component as VCard, InvalidVCardDataError from txdav.base.propertystore.base import PropertyName from txdav.caldav.icalendarstore import ( QuotaExceeded, AttachmentStoreFailed, AttachmentStoreValidManagedID, AttachmentRemoveFailed, AttachmentDropboxNotAllowed, InvalidComponentTypeError, TooManyAttendeesError, InvalidCalendarAccessError, ValidOrganizerError, InvalidPerUserDataMerge, AttendeeAllowedError, ResourceDeletedError, InvalidAttachmentOperation, ShareeAllowedError, DuplicatePrivateCommentsError, InvalidSplit, AttachmentSizeTooLarge, UnknownTimezone, SetComponentOptions, TooManyAttachments) from txdav.carddav.iaddressbookstore import ( KindChangeNotAllowedError, GroupWithUnsharedAddressNotAllowedError ) from txdav.common.datastore.podding.base import FailedCrossPodRequestError from txdav.common.datastore.sql_tables import ( _BIND_MODE_READ, _BIND_MODE_WRITE, _BIND_MODE_DIRECT, _BIND_STATUS_ACCEPTED ) from txdav.common.icommondatastore import ( NoSuchObjectResourceError, TooManyObjectResourcesError, ObjectResourceTooBigError, InvalidObjectResourceError, ObjectResourceNameNotAllowedError, ObjectResourceNameAlreadyExistsError, UIDExistsError, UIDExistsElsewhereError, InvalidUIDError, InvalidResourceMove, InvalidComponentForStoreError, AlreadyInTrashError, HomeChildNameAlreadyExistsError, ConcurrentModification ) from txdav.idav import PropertyChangeNotAllowedError from txdav.who.wiki import RecordType as WikiRecordType from txdav.xml import element as davxml, element from txdav.xml.base import dav_namespace, WebDAVUnknownElement, encodeXMLName from txweb2 import responsecode, http_headers, http from txweb2.dav.http import ErrorResponse, ResponseQueue, MultiStatusResponse from txweb2.dav.noneprops import NonePropertyStore from txweb2.dav.resource import ( TwistedACLInheritable, AccessDeniedError, davPrivilegeSet ) from txweb2.dav.util import parentForURL, allDataFromStream, joinURL, davXMLFromStream from txweb2.filter.location import addLocation from txweb2.http import HTTPError, StatusResponse, Response from txweb2.http_headers import ETag, MimeType, MimeDisposition from txweb2.iweb import IResponse from txweb2.responsecode import ( FORBIDDEN, NO_CONTENT, NOT_FOUND, CREATED, CONFLICT, PRECONDITION_FAILED, BAD_REQUEST, OK, INSUFFICIENT_STORAGE_SPACE, SERVICE_UNAVAILABLE ) from txweb2.stream import ProducerStream, readStream, MemoryStream from twistedcaldav.timezones import TimezoneException """ Wrappers to translate between the APIs in L{txdav.caldav.icalendarstore} and L{txdav.carddav.iaddressbookstore} and those in L{twistedcaldav}. """ log = Logger() class _NewStorePropertiesWrapper(object): """ Wrap a new-style property store (a L{txdav.idav.IPropertyStore}) in the old- style interface for compatibility with existing code. """ # FIXME: UID arguments on everything need to be tested against something. def __init__(self, newPropertyStore): """ Initialize an old-style property store from a new one. @param newPropertyStore: the new-style property store. @type newPropertyStore: L{txdav.idav.IPropertyStore} """ self._newPropertyStore = newPropertyStore @classmethod def _convertKey(cls, qname): namespace, name = qname return PropertyName(namespace, name) def get(self, qname): try: return self._newPropertyStore[self._convertKey(qname)] except KeyError: raise HTTPError(StatusResponse( NOT_FOUND, "No such property: %s" % (encodeXMLName(*qname),) )) def set(self, prop): try: self._newPropertyStore[self._convertKey(prop.qname())] = prop except PropertyChangeNotAllowedError: raise HTTPError(StatusResponse( FORBIDDEN, "Property cannot be changed: %s" % (prop.sname(),) )) def delete(self, qname): try: del self._newPropertyStore[self._convertKey(qname)] except KeyError: # RFC 2518 Section 12.13.1 says that removal of # non-existing property is not an error. pass def contains(self, qname): return (self._convertKey(qname) in self._newPropertyStore) def list(self): return [(pname.namespace, pname.name) for pname in self._newPropertyStore.keys()] class _NewStoreFileMetaDataHelper(object): def exists(self): return self._newStoreObject is not None def name(self): return self._newStoreObject.name() if self._newStoreObject is not None else self._name def etag(self): return succeed(ETag(self._newStoreObject.md5()) if self._newStoreObject is not None else None) def contentType(self): return self._newStoreObject.contentType() if self._newStoreObject is not None else None def contentLength(self): return self._newStoreObject.size() if self._newStoreObject is not None else None def lastModified(self): return self._newStoreObject.modified() if self._newStoreObject is not None else None def creationDate(self): return self._newStoreObject.created() if self._newStoreObject is not None else None def newStoreProperties(self): return self._newStoreObject.properties() if self._newStoreObject is not None else None class _CommonStoreExceptionHandler(object): """ A mix-in class that is used to help trap store exceptions and turn them into appropriate HTTP errors. The class properties define mappings from a store exception type to a L{tuple} whose first item is one of the class methods defined in this mix-in, and whose second argument is the L{arg} passed to the class method. In some cases the second L{tuple} item will not be present, and instead the argument will be directly provided to the class method. """ # The following are used to map store exceptions into HTTP error responses StoreExceptionsErrors = {} StoreMoveExceptionsErrors = {} @classmethod def _storeExceptionStatus(cls, err, arg): """ Raise a status error. @param err: the actual exception that caused the error @type err: L{Exception} @param arg: description of error or C{None} @type arg: C{str} or C{None} """ raise HTTPError(StatusResponse(responsecode.FORBIDDEN, arg if arg is not None else str(err))) @classmethod def _storeExceptionError(cls, err, arg): """ Raise a DAV:error error with the supplied error element. @param err: the actual exception that caused the error @type err: L{Exception} @param arg: the error element @type arg: C{tuple} """ raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, arg, str(err), )) @classmethod def _storeExceptionUnavailable(cls, err, arg): """ Raise a service unavailable error. @param err: the actual exception that caused the error @type err: L{Exception} @param arg: description of error or C{None} @type arg: C{str} or C{None} """ response = StatusResponse(responsecode.SERVICE_UNAVAILABLE, arg if arg is not None else str(err)) response.headers.setHeader("Retry-After", time.time() + config.TransactionHTTPRetrySeconds) raise HTTPError(response) @classmethod def _handleStoreException(cls, ex, exceptionMap): """ Process a store exception and see if it is in the supplied mapping. If so, execute the method in the mapping (which will raise an HTTPError). @param ex: the store exception that was raised @type ex: L{Exception} @param exceptionMap: the store exception mapping to use @type exceptionMap: L{dict} """ if type(ex) in exceptionMap: error, arg = exceptionMap[type(ex)] error(ex, arg) @classmethod def _handleStoreExceptionArg(cls, ex, exceptionMap, arg): """ Process a store exception and see if it is in the supplied mapping. If so, execute the method in the mapping (which will raise an HTTPError). This method is used when the argument to the class method needs to be provided at runtime, rather than statically. @param ex: the store exception that was raised @type ex: L{Exception} @param exceptionSet: the store exception set to use @type exceptionSet: L{set} @param arg: the argument to use @type arg: L{object} """ if type(ex) in exceptionMap: error = exceptionMap[type(ex)] error(ex, arg) class _CommonHomeChildCollectionMixin(_CommonStoreExceptionHandler): """ Methods for things which are like calendars. """ _childClass = None def _initializeWithHomeChild(self, child, home): """ Initialize with a home child object. @param child: the new store home child object. @type calendar: L{txdav.common._.CommonHomeChild} @param home: the home through which the given home child was accessed. @type home: L{txdav.common._.CommonHome} """ self._newStoreObject = child self._newStoreParentHome = home._newStoreHome self._parentResource = home self._dead_properties = _NewStorePropertiesWrapper( self._newStoreObject.properties() ) if self._newStoreObject else NonePropertyStore(self) def liveProperties(self): props = super(_CommonHomeChildCollectionMixin, self).liveProperties() if config.MaxResourcesPerCollection: props += (customxml.MaxResources.qname(),) if config.EnableBatchUpload: props += (customxml.BulkRequests.qname(),) return props @inlineCallbacks def readProperty(self, prop, request): if type(prop) is tuple: qname = prop else: qname = prop.qname() if qname == customxml.MaxResources.qname() and config.MaxResourcesPerCollection: returnValue(customxml.MaxResources.fromString(config.MaxResourcesPerCollection)) elif qname == customxml.BulkRequests.qname() and config.EnableBatchUpload: returnValue(customxml.BulkRequests( customxml.Simple( customxml.MaxBulkResources.fromString(str(config.MaxResourcesBatchUpload)), customxml.MaxBulkBytes.fromString(str(config.MaxBytesBatchUpload)), ), customxml.CRUD( customxml.MaxBulkResources.fromString(str(config.MaxResourcesBatchUpload)), customxml.MaxBulkBytes.fromString(str(config.MaxBytesBatchUpload)), ), )) result = (yield super(_CommonHomeChildCollectionMixin, self).readProperty(prop, request)) returnValue(result) def url(self): return joinURL(self._parentResource.url(), self._name, "/") def owner_url(self): if self.isShareeResource(): return joinURL(self._share_url, "/") if self._share_url else "" else: return self.url() def parentResource(self): return self._parentResource def exists(self): # FIXME: tests return self._newStoreObject is not None @inlineCallbacks def _indexWhatChanged(self, revision, depth): # The newstore implementation supports this directly returnValue( (yield self._newStoreObject.resourceNamesSinceToken(revision)) ) @inlineCallbacks def makeChild(self, name): """ Create a L{CalendarObjectResource} based on a calendar object name. """ if self._newStoreObject: try: newStoreObject = yield self._newStoreObject.objectResourceWithName(name) except Exception as err: self._handleStoreException(err, self.StoreExceptionsErrors) raise similar = self._childClass( newStoreObject, self._newStoreObject, self, name, principalCollections=self._principalCollections ) self.propagateTransaction(similar) returnValue(similar) else: returnValue(NoParent()) @inlineCallbacks def listChildren(self): """ @return: a sequence of the names of all known children of this resource. """ children = set(self.putChildren.keys()) children.update((yield self._newStoreObject.listObjectResources())) returnValue(sorted(children)) def countChildren(self): """ @return: L{Deferred} with the count of all known children of this resource. """ return self._newStoreObject.countObjectResources() @inlineCallbacks def resourceExists(self, name): """ Indicate whether a resource with the specified name exists. @return: C{True} if it exists @rtype: C{bool} """ allNames = yield self._newStoreObject.listObjectResources() returnValue(name in allNames) def name(self): return self._name @inlineCallbacks def etag(self): """ Use the sync token as the etag """ if self._newStoreObject: token = (yield self.getInternalSyncToken()) returnValue(ETag(hashlib.md5(token).hexdigest())) else: returnValue(None) def lastModified(self): return self._newStoreObject.modified() if self._newStoreObject else None def creationDate(self): return self._newStoreObject.created() if self._newStoreObject else None def getInternalSyncToken(self): return self._newStoreObject.syncToken() if self._newStoreObject else None def resourceID(self): rid = "%s/%s" % (self._newStoreParentHome.id(), self._newStoreObject.id(),) return uuid.uuid5(self.uuid_namespace, rid).urn @inlineCallbacks def findChildrenFaster( self, depth, request, okcallback, badcallback, missingcallback, unavailablecallback, names, privileges, inherited_aces ): """ Override to pre-load children in certain collection types for better performance. """ if depth == "1": if names: yield self._newStoreObject.objectResourcesWithNames(names) else: yield self._newStoreObject.objectResources() result = (yield super(_CommonHomeChildCollectionMixin, self).findChildrenFaster( depth, request, okcallback, badcallback, missingcallback, unavailablecallback, names, privileges, inherited_aces )) returnValue(result) @inlineCallbacks def createCollection(self): """ Override C{createCollection} to actually do the work. """ try: self._newStoreObject = (yield self._newStoreParentHome.createChildWithName(self._name)) except HomeChildNameAlreadyExistsError: # We already check for an existing child prior to this call so the only time this fails is if # there is an unaccepted share with the same name raise HTTPError(StatusResponse(responsecode.FORBIDDEN, "Unaccepted share exists")) # Re-initialize to get stuff setup again now we have a "real" object self._initializeWithHomeChild(self._newStoreObject, self._parentResource) returnValue(CREATED) def http_PUT(self, request): """ Cannot PUT to existing collection. Use POST instead. """ return FORBIDDEN @requiresPermissions(fromParent=[davxml.Unbind()]) @inlineCallbacks def http_DELETE(self, request): """ Override http_DELETE to validate 'depth' header. """ if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) depth = request.headers.getHeader("depth", "infinity") if depth != "infinity": msg = "illegal depth header for DELETE on collection: %s" % ( depth, ) log.error(msg) raise HTTPError(StatusResponse(BAD_REQUEST, msg)) try: response = (yield self.storeRemove(request)) except Exception as err: self._handleStoreException(err, self.StoreExceptionsErrors) raise returnValue(response) @inlineCallbacks def storeRemove(self, request): """ Delete this collection resource, first deleting each contained object resource. This has to emulate the behavior in fileop.delete in that any errors need to be reported back in a multistatus response. @param request: The request used to locate child resources. Note that this is the request which I{triggered} the C{DELETE}, but which may not actually be a C{DELETE} request itself. @type request: L{txweb2.iweb.IRequest} @return: an HTTP response suitable for sending to a client (or including in a multi-status). @rtype: something adaptable to L{txweb2.iweb.IResponse} """ # Check sharee collection first if self.isShareeResource(): log.debug("Removing shared collection {s!r}", s=self) yield self.removeShareeResource(request) # Re-initialize to get stuff setup again now we have no object self._initializeWithHomeChild(None, self._parentResource) returnValue(NO_CONTENT) log.debug("Deleting collection {s!r}", s=self) # 'deluri' is this resource's URI; I should be able to synthesize it # from 'self'. errors = ResponseQueue(request.uri, "DELETE", NO_CONTENT) for childname in (yield self.listChildren()): childurl = joinURL(request.uri, childname) # FIXME: use a more specific API; we should know what this child # resource is, and not have to look it up. (Sharing information # needs to move into the back-end first, though.) child = (yield request.locateChildResource(self, childname)) try: yield child.storeRemove(request) except: log.failure("storeRemove({request})", request=request) errors.add(childurl, BAD_REQUEST) # Now do normal delete # Actually delete it. yield self._newStoreObject.remove() # Re-initialize to get stuff setup again now we have no object self._initializeWithHomeChild(None, self._parentResource) # FIXME: handle exceptions, possibly like this: # if isinstance(more_responses, MultiStatusResponse): # # Merge errors # errors.responses.update(more_responses.children) response = errors.response() returnValue(response) def http_COPY(self, request): """ Copying of calendar collections isn't allowed. """ # FIXME: no direct tests return FORBIDDEN # FIXME: access control @inlineCallbacks def http_MOVE(self, request): """ Moving a collection is allowed for the purposes of changing that collections's name. """ if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) # Can not move outside of home or to existing collection sourceURI = request.uri destinationURI = urlsplit(request.headers.getHeader("destination"))[2] if parentForURL(sourceURI) != parentForURL(destinationURI): returnValue(FORBIDDEN) destination = yield request.locateResource(destinationURI) if destination.exists(): returnValue(FORBIDDEN) # Forget the destination now as after the move we will need to re-init it with its # new store object request._forgetResource(destination, destinationURI) # Move is valid so do it basename = destinationURI.rstrip("/").split("/")[-1] yield self._newStoreObject.rename(basename) returnValue(NO_CONTENT) @inlineCallbacks def POST_handler_add_member(self, request): """ Handle a POST ;add-member request on this collection @param request: the request object @type request: L{Request} """ # Create a name for the new child name = str(uuid.uuid4()) + self.resourceSuffix() # Get a resource for the new child parentURL = request.path newchildURL = joinURL(parentURL, name) newchild = (yield request.locateResource(newchildURL)) # Treat as if it were a regular PUT to a new resource response = (yield newchild.http_PUT(request)) # May need to add a location header addLocation(request, request.unparseURL(path=newchildURL, params="")) returnValue(response) @inlineCallbacks def checkCTagPrecondition(self, request): if request.headers.hasHeader("If"): iffy = request.headers.getRawHeaders("If")[0] prefix = "<%sctag/" % (customxml.mm_namespace,) if prefix in iffy: testctag = iffy[iffy.find(prefix):] testctag = testctag[len(prefix):] testctag = testctag.split(">", 1)[0] ctag = (yield self.getInternalSyncToken()) if testctag != ctag: raise HTTPError(StatusResponse(PRECONDITION_FAILED, "CTag pre-condition failure")) def checkReturnChanged(self, request): if request.headers.hasHeader("X-MobileMe-DAV-Options"): return_changed = request.headers.getRawHeaders("X-MobileMe-DAV-Options")[0] return ("return-changed-data" in return_changed) else: return False @requiresPermissions(davxml.Bind()) @inlineCallbacks def simpleBatchPOST(self, request): # If CTag precondition yield self.checkCTagPrecondition(request) # Look for return changed data option return_changed = self.checkReturnChanged(request) # Read in all data data = (yield allDataFromStream(request.stream)) format = request.headers.getHeader("content-type") if format: format = "%s/%s" % (format.mediaType, format.mediaSubtype,) components = self.componentsFromData(data, format) if components is None: raise HTTPError(StatusResponse(BAD_REQUEST, "Could not parse valid data from request body")) # Build response xmlresponses = [None] * len(components) indexedComponents = [idxComponent for idxComponent in enumerate(components)] yield self.bulkCreate(indexedComponents, request, return_changed, xmlresponses, format) result = MultiStatusResponse(xmlresponses) newctag = (yield self.getInternalSyncToken()) result.headers.setRawHeaders("CTag", (newctag,)) # Setup some useful logging request.submethod = "Simple batch" if not hasattr(request, "extendedLogItems"): request.extendedLogItems = {} request.extendedLogItems["rcount"] = len(xmlresponses) returnValue(result) @inlineCallbacks def bulkCreate(self, indexedComponents, request, return_changed, xmlresponses, format): """ Do create from simpleBatchPOST or crudCreate() Subclasses may override """ for index, component in indexedComponents: try: if component is None: newchildURL = "" newchild = None changedComponent = None raise ValueError("Invalid component") # Create a new name if one was not provided name = hashlib.md5(str(index) + component.resourceUID() + str(time.time()) + request.path).hexdigest() + self.resourceSuffix() # Get a resource for the new item newchildURL = joinURL(request.path, name) newchild = (yield request.locateResource(newchildURL)) changedComponent = (yield self.storeResourceData(newchild, component, returnChangedData=return_changed)) except HTTPError, e: # Extract the pre-condition code = e.response.code if isinstance(e.response, ErrorResponse): error = e.response.error error = (error.namespace, error.name,) xmlresponses[index] = ( yield self.bulkCreateResponse(component, newchildURL, newchild, None, code, error, format) ) except Exception: xmlresponses[index] = ( yield self.bulkCreateResponse(component, newchildURL, newchild, None, BAD_REQUEST, None, format) ) else: if not return_changed: changedComponent = None xmlresponses[index] = ( yield self.bulkCreateResponse(component, newchildURL, newchild, changedComponent, None, None, format) ) @inlineCallbacks def bulkCreateResponse(self, component, newchildURL, newchild, changedComponent, code, error, format): """ generate one xmlresponse for bulk create """ if code is None: etag = (yield newchild.etag()) if changedComponent is None: returnValue( davxml.PropertyStatusResponse( davxml.HRef.fromString(newchildURL), davxml.PropertyStatus( davxml.PropertyContainer( davxml.GETETag.fromString(etag.generate()), customxml.UID.fromString(component.resourceUID() if component else ""), ), davxml.Status.fromResponseCode(OK), ) ) ) else: returnValue( davxml.PropertyStatusResponse( davxml.HRef.fromString(newchildURL), davxml.PropertyStatus( davxml.PropertyContainer( davxml.GETETag.fromString(etag.generate()), self.xmlDataElementType().fromComponent(changedComponent, format), ), davxml.Status.fromResponseCode(OK), ) ) ) else: returnValue( davxml.StatusResponse( davxml.HRef.fromString(""), davxml.Status.fromResponseCode(code), davxml.Error( WebDAVUnknownElement.withName(*error), customxml.UID.fromString(component.resourceUID() if component else ""), ) if error else None, ) ) @inlineCallbacks def crudBatchPOST(self, request, xmlroot): # Need to force some kind of overall authentication on the request yield self.authorize(request, (davxml.Read(), davxml.Write(),)) # If CTag precondition yield self.checkCTagPrecondition(request) # Look for return changed data option return_changed = self.checkReturnChanged(request) # setup for create, update, and delete crudDeleteInfo = [] crudUpdateInfo = [] crudCreateInfo = [] for index, xmlchild in enumerate(xmlroot.children): # Determine the multiput operation: create, update, delete href = xmlchild.childOfType(davxml.HRef.qname()) set_items = xmlchild.childOfType(davxml.Set.qname()) prop = set_items.childOfType(davxml.PropertyContainer.qname()) if set_items is not None else None xmldata_root = prop if prop else set_items xmldata = xmldata_root.childOfType(self.xmlDataElementType().qname()) if xmldata_root is not None else None if href is None: if xmldata is None: raise HTTPError(StatusResponse(BAD_REQUEST, "Could not parse valid data from request body without a DAV:Href present")) crudCreateInfo.append((index, xmldata)) else: delete = xmlchild.childOfType(customxml.Delete.qname()) ifmatch = xmlchild.childOfType(customxml.IfMatch.qname()) if ifmatch: ifmatch = str(ifmatch.children[0]) if len(ifmatch.children) == 1 else None if delete is None: if set_items is None: raise HTTPError(StatusResponse(BAD_REQUEST, "Could not parse valid data from request body - no set_items of delete operation")) if xmldata is None: raise HTTPError(StatusResponse(BAD_REQUEST, "Could not parse valid data from request body for set_items operation")) crudUpdateInfo.append((index, str(href), xmldata, ifmatch)) else: crudDeleteInfo.append((index, str(href), ifmatch)) # now do the work xmlresponses = [None] * len(xmlroot.children) yield self.crudDelete(crudDeleteInfo, request, xmlresponses) yield self.crudCreate(crudCreateInfo, request, xmlresponses, return_changed) yield self.crudUpdate(crudUpdateInfo, request, xmlresponses, return_changed) result = MultiStatusResponse(xmlresponses) # @UndefinedVariable newctag = (yield self.getInternalSyncToken()) result.headers.setRawHeaders("CTag", (newctag,)) # Setup some useful logging request.submethod = "CRUD batch" if not hasattr(request, "extendedLogItems"): request.extendedLogItems = {} request.extendedLogItems["rcount"] = len(xmlresponses) if crudCreateInfo: request.extendedLogItems["create"] = len(crudCreateInfo) if crudUpdateInfo: request.extendedLogItems["update"] = len(crudUpdateInfo) if crudDeleteInfo: request.extendedLogItems["delete"] = len(crudDeleteInfo) returnValue(result) @inlineCallbacks def crudCreate(self, crudCreateInfo, request, xmlresponses, return_changed): if crudCreateInfo: # Do privilege check on collection once try: yield self.authorize(request, (davxml.Bind(),)) hasPrivilege = True except HTTPError, e: hasPrivilege = e # get components indexedComponents = [] for index, xmldata in crudCreateInfo: try: component = xmldata.generateComponent() except: component = None format = xmldata.content_type if hasPrivilege is not True: e = hasPrivilege # use same code pattern as exception code = e.response.code if isinstance(e.response, ErrorResponse): error = e.response.error error = (error.namespace, error.name,) xmlresponse = yield self.bulkCreateResponse(component, None, None, None, code, error, format) xmlresponses[index] = xmlresponse else: indexedComponents.append((index, component,)) yield self.bulkCreate(indexedComponents, request, return_changed, xmlresponses, format) @inlineCallbacks def crudUpdate(self, crudUpdateInfo, request, xmlresponses, return_changed): for index, href, xmldata, ifmatch in crudUpdateInfo: code = None error = None try: component = xmldata.generateComponent() format = xmldata.content_type updateResource = (yield request.locateResource(href)) if not updateResource.exists(): raise HTTPError(NOT_FOUND) # Check privilege yield updateResource.authorize(request, (davxml.Write(),)) # Check if match etag = (yield updateResource.etag()) if ifmatch and ifmatch != etag.generate(): raise HTTPError(PRECONDITION_FAILED) changedComponent = yield self.storeResourceData(updateResource, component, returnChangedData=return_changed) etag = (yield updateResource.etag()) except HTTPError, e: # Extract the pre-condition code = e.response.code if isinstance(e.response, ErrorResponse): error = e.response.error error = (error.namespace, error.name,) except Exception: code = BAD_REQUEST if code is None: if changedComponent is None: xmlresponses[index] = davxml.PropertyStatusResponse( davxml.HRef.fromString(href), davxml.PropertyStatus( davxml.PropertyContainer( davxml.GETETag.fromString(etag.generate()), ), davxml.Status.fromResponseCode(OK), ) ) else: xmlresponses[index] = davxml.PropertyStatusResponse( davxml.HRef.fromString(href), davxml.PropertyStatus( davxml.PropertyContainer( davxml.GETETag.fromString(etag.generate()), self.xmlDataElementType().fromComponent(changedComponent, format), ), davxml.Status.fromResponseCode(OK), ) ) else: xmlresponses[index] = davxml.StatusResponse( davxml.HRef.fromString(href), davxml.Status.fromResponseCode(code), davxml.Error( WebDAVUnknownElement.withName(*error), ) if error else None, ) @inlineCallbacks def crudDelete(self, crudDeleteInfo, request, xmlresponses): if crudDeleteInfo: # Do privilege check on collection once try: yield self.authorize(request, (davxml.Unbind(),)) hasPrivilege = True except HTTPError, e: hasPrivilege = e for index, href, ifmatch in crudDeleteInfo: code = None error = None try: if hasPrivilege is not True: raise hasPrivilege deleteResource = (yield request.locateResource(href)) if not deleteResource.exists(): raise HTTPError(NOT_FOUND) # Check if match etag = (yield deleteResource.etag()) if ifmatch and ifmatch != etag.generate(): raise HTTPError(PRECONDITION_FAILED) yield deleteResource.storeRemove(request) except HTTPError, e: # Extract the pre-condition code = e.response.code if isinstance(e.response, ErrorResponse): error = e.response.error error = (error.namespace, error.name,) except Exception: code = BAD_REQUEST if code is None: xmlresponses[index] = davxml.StatusResponse( davxml.HRef.fromString(href), davxml.Status.fromResponseCode(OK), ) else: xmlresponses[index] = davxml.StatusResponse( davxml.HRef.fromString(href), davxml.Status.fromResponseCode(code), davxml.Error( WebDAVUnknownElement.withName(*error), ) if error else None, ) def search(self, filter, **kwargs): return self._newStoreObject.search(filter, **kwargs) def notifierID(self): return "%s/%s" % self._newStoreObject.notifierID() def notifyChanged(self): return self._newStoreObject.notifyChanged() class _CalendarCollectionBehaviorMixin(): """ Functions common to calendar and inbox collections """ # Support component set behaviors def setSupportedComponentSet(self, support_components_property): """ Parse out XML property into list of components and give to store. """ support_components = tuple([comp.attributes["name"].upper() for comp in support_components_property.children]) return self.setSupportedComponents(support_components) def getSupportedComponentSet(self): comps = self._newStoreObject.getSupportedComponents() if comps: comps = comps.split(",") else: comps = ical.allowedStoreComponents return caldavxml.SupportedCalendarComponentSet( *[caldavxml.CalendarComponent(name=item) for item in comps] ) def setSupportedComponents(self, components): """ Set the allowed component set for this calendar. @param components: list of names of components to support @type components: C{list} """ # Validate them first - raise on failure if not self.validSupportedComponents(components): raise HTTPError(StatusResponse(FORBIDDEN, "Invalid CALDAV:supported-calendar-component-set")) support_components = ",".join(sorted([comp.upper() for comp in components])) return maybeDeferred(self._newStoreObject.setSupportedComponents, support_components) def getSupportedComponents(self): comps = self._newStoreObject.getSupportedComponents() if comps: comps = comps.split(",") else: comps = ical.allowedStoreComponents return comps def isSupportedComponent(self, componentType): return self._newStoreObject.isSupportedComponent(componentType) def validSupportedComponents(self, components): """ Test whether the supplied set of components is valid for the current server's component set restrictions. """ if config.RestrictCalendarsToOneComponentType: return components in (("VEVENT",), ("VTODO",),) return True class CalendarCollectionResource(DefaultAlarmPropertyMixin, _CalendarCollectionBehaviorMixin, _CommonHomeChildCollectionMixin, CalDAVResource): """ Wrapper around a L{txdav.caldav.icalendar.ICalendar}. """ StoreExceptionsErrors = { LockTimeout: (_CommonStoreExceptionHandler._storeExceptionUnavailable, "Lock timed out.",), AlreadyInTrashError: (_CommonStoreExceptionHandler._storeExceptionError, (calendarserver_namespace, "not-in-trash",),), FailedCrossPodRequestError: (_CommonStoreExceptionHandler._storeExceptionUnavailable, "Cross-pod request failed.",), } def __init__(self, calendar, home, name=None, *args, **kw): """ Create a CalendarCollectionResource from a L{txdav.caldav.icalendar.ICalendar} and the arguments required for L{CalDAVResource}. """ self._childClass = CalendarObjectResource super(CalendarCollectionResource, self).__init__(*args, **kw) self._initializeWithHomeChild(calendar, home) self._name = calendar.name() if calendar else name if config.EnableBatchUpload: self._postHandlers[("text", "calendar")] = _CommonHomeChildCollectionMixin.simpleBatchPOST if config.EnableJSONData: self._postHandlers[("application", "calendar+json")] = _CommonHomeChildCollectionMixin.simpleBatchPOST self.xmlDocHandlers[customxml.Multiput] = _CommonHomeChildCollectionMixin.crudBatchPOST def __repr__(self): return "<Calendar Collection Resource %r:%r %s>" % ( self._newStoreParentHome.uid(), self._name, "" if self._newStoreObject else "Non-existent" ) def isCollection(self): return True def isCalendarCollection(self): """ Yes, it is a calendar collection. """ return True def resourceType(self): if self.isSharedByOwner(): return customxml.ResourceType.sharedownercalendar elif self.isShareeResource(): return customxml.ResourceType.sharedcalendar elif self._newStoreObject.isTrash(): return customxml.ResourceType.trash else: return caldavxml.ResourceType.calendar @inlineCallbacks def iCalendarRolledup(self, request): # FIXME: uncached: implement cache in the storage layer # Accept header handling accepted_type = bestAcceptType(request.headers.getHeader("accept"), Component.allowedTypes()) if accepted_type is None: raise HTTPError(StatusResponse(responsecode.NOT_ACCEPTABLE, "Cannot generate requested data type")) # Generate a monolithic calendar calendar = VCalendar("VCALENDAR") calendar.addProperty(VProperty("VERSION", "2.0")) calendar.addProperty(VProperty("PRODID", iCalendarProductID)) # Add a display name if available displayName = self.displayName() if displayName is not None: calendar.addProperty(VProperty("X-WR-CALNAME", displayName)) # Do some optimisation of access control calculation by determining any # inherited ACLs outside of the child resource loop and supply those to # the checkPrivileges on each child. filteredaces = (yield self.inheritedACEsforChildren(request)) tzids = set() isowner = (yield self.isOwner(request)) for name in (yield self._newStoreObject.listObjectResources()): try: child = yield request.locateChildResource(self, name) except TypeError: child = None if child is not None: # Check privileges of child - skip if access denied try: yield child.checkPrivileges(request, (davxml.Read(),), inherited_aces=filteredaces) except AccessDeniedError: continue # Get the access filtered view of the data try: subcalendar = yield child.iCalendarFiltered(isowner) except ValueError: continue assert subcalendar.name() == "VCALENDAR" for component in subcalendar.subcomponents(): # Only insert VTIMEZONEs once if component.name() == "VTIMEZONE": tzid = component.propertyValue("TZID") if tzid in tzids: continue tzids.add(tzid) calendar.addComponent(component) returnValue((calendar, accepted_type,)) createCalendarCollection = _CommonHomeChildCollectionMixin.createCollection @classmethod def componentsFromData(cls, data, format): """ Need to split a single VCALENDAR into separate ones based on UID with the appropriate VTIEMZONES included. """ return Component.componentsFromData(data, format) @classmethod def resourceSuffix(cls): return ".ics" @classmethod def xmlDataElementType(cls): return caldavxml.CalendarData def dynamicProperties(self): return super(CalendarCollectionResource, self).dynamicProperties() + tuple( DefaultAlarmPropertyMixin.ALARM_PROPERTIES.keys() ) + ( caldavxml.CalendarTimeZone.qname(), caldavxml.CalendarTimeZoneID.qname(), ) def hasProperty(self, property, request): if type(property) is tuple: qname = property else: qname = property.qname() # Handle certain built-in values if qname in DefaultAlarmPropertyMixin.ALARM_PROPERTIES: return succeed(self.getDefaultAlarmProperty(qname) is not None) elif qname in (caldavxml.CalendarTimeZone.qname(), caldavxml.CalendarTimeZoneID.qname(),): return succeed(self._newStoreObject.getTimezone() is not None) else: return super(CalendarCollectionResource, self).hasProperty(property, request) @inlineCallbacks def readProperty(self, property, request): if type(property) is tuple: qname = property else: qname = property.qname() if qname in DefaultAlarmPropertyMixin.ALARM_PROPERTIES: returnValue(self.getDefaultAlarmProperty(qname)) elif qname == caldavxml.CalendarTimeZone.qname(): timezone = self._newStoreObject.getTimezone() format = property.content_type if isinstance(property, caldavxml.CalendarTimeZone) else None returnValue(caldavxml.CalendarTimeZone.fromCalendar(timezone, format=format) if timezone else None) elif qname == caldavxml.CalendarTimeZoneID.qname(): tzid = self._newStoreObject.getTimezoneID() returnValue(caldavxml.CalendarTimeZoneID.fromString(tzid) if tzid else None) result = (yield super(CalendarCollectionResource, self).readProperty(property, request)) returnValue(result) @inlineCallbacks def writeProperty(self, property, request): if property.qname() in DefaultAlarmPropertyMixin.ALARM_PROPERTIES: if not property.valid(): raise HTTPError(ErrorResponse( responsecode.CONFLICT, (caldav_namespace, "valid-calendar-data"), description="Invalid property" )) yield self.setDefaultAlarmProperty(property) returnValue(None) elif property.qname() == caldavxml.CalendarTimeZone.qname(): if not property.valid(): raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, (caldav_namespace, "valid-calendar-data"), description="Invalid property" )) yield self._newStoreObject.setTimezone(property.calendar()) returnValue(None) elif property.qname() == caldavxml.CalendarTimeZoneID.qname(): tzid = property.toString() try: yield self._newStoreObject.setTimezoneID(tzid) except TimezoneException: raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, (caldav_namespace, "valid-timezone"), description="Invalid property" )) returnValue(None) elif property.qname() == caldavxml.ScheduleCalendarTransp.qname(): yield self._newStoreObject.setUsedForFreeBusy(property == caldavxml.ScheduleCalendarTransp(caldavxml.Opaque())) returnValue(None) result = (yield super(CalendarCollectionResource, self).writeProperty(property, request)) returnValue(result) @inlineCallbacks def removeProperty(self, property, request): if type(property) is tuple: qname = property else: qname = property.qname() if qname in DefaultAlarmPropertyMixin.ALARM_PROPERTIES: result = (yield self.removeDefaultAlarmProperty(qname)) returnValue(result) elif qname in (caldavxml.CalendarTimeZone.qname(), caldavxml.CalendarTimeZoneID.qname(),): yield self._newStoreObject.setTimezone(None) returnValue(None) result = (yield super(CalendarCollectionResource, self).removeProperty(property, request)) returnValue(result) def canBeShared(self): return config.Sharing.Enabled and config.Sharing.Calendars.Enabled @inlineCallbacks def storeResourceData(self, newchild, component, returnChangedData=False): yield newchild.storeComponent(component) if returnChangedData and newchild._newStoreObject._componentChanged: result = (yield newchild.componentForUser()) returnValue(result) else: returnValue(None) # FIXME: access control @inlineCallbacks def http_MOVE(self, request): """ Moving a calendar collection is allowed for the purposes of changing that calendar's name. """ result = (yield super(CalendarCollectionResource, self).http_MOVE(request)) returnValue(result) class StoreScheduleInboxResource(_CalendarCollectionBehaviorMixin, _CommonHomeChildCollectionMixin, ScheduleInboxResource): def __init__(self, *a, **kw): self._childClass = CalendarObjectResource super(StoreScheduleInboxResource, self).__init__(*a, **kw) self.parent.propagateTransaction(self) @classmethod @inlineCallbacks def maybeCreateInbox(cls, *a, **kw): self = cls(*a, **kw) home = self.parent._newStoreHome storage = yield home.calendarWithName("inbox") if storage is None: # raise RuntimeError("backend should be handling this for us") # FIXME: spurious error, sanity check, should not be needed; # unfortunately, user09's calendar home does not have an inbox, so # this is a temporary workaround. yield home.createCalendarWithName("inbox") storage = yield home.calendarWithName("inbox") self._initializeWithHomeChild( storage, self.parent ) self._name = storage.name() returnValue(self) def provisionFile(self): pass def provision(self): pass def http_DELETE(self, request): return FORBIDDEN def http_COPY(self, request): return FORBIDDEN def http_MOVE(self, request): return FORBIDDEN class _GetChildHelper(CalDAVResource): def locateChild(self, request, segments): if segments[0] == '': return self, segments[1:] return self.getChild(segments[0]), segments[1:] def getChild(self, name): return None def readProperty(self, prop, request): if type(prop) is tuple: qname = prop else: qname = prop.qname() if qname == (dav_namespace, "resourcetype"): return succeed(self.resourceType()) return super(_GetChildHelper, self).readProperty(prop, request) def davComplianceClasses(self): return ("1", "access-control") @requiresPermissions(davxml.Read()) def http_GET(self, request): return super(_GetChildHelper, self).http_GET(request) class DropboxCollection(_GetChildHelper): """ A collection of all dropboxes (containers for attachments), presented as a resource under the user's calendar home, where a dropbox is a L{CalendarObjectDropbox}. """ # FIXME: no direct tests for this class at all. def __init__(self, parent, *a, **kw): kw.update(principalCollections=parent.principalCollections()) super(DropboxCollection, self).__init__(*a, **kw) self._newStoreHome = parent._newStoreHome parent.propagateTransaction(self) def isCollection(self): """ It is a collection. """ return True @inlineCallbacks def getChild(self, name): calendarObject = yield self._newStoreHome.calendarObjectWithDropboxID(name) if calendarObject is None: returnValue(NoDropboxHere()) objectDropbox = CalendarObjectDropbox( calendarObject, principalCollections=self.principalCollections() ) self.propagateTransaction(objectDropbox) returnValue(objectDropbox) def resourceType(self,): return davxml.ResourceType.dropboxhome # @UndefinedVariable def listChildren(self): return self._newStoreHome.getAllDropboxIDs() class NoDropboxHere(_GetChildHelper): def getChild(self, name): raise HTTPError(FORBIDDEN) def isCollection(self): return False def exists(self): return False def http_GET(self, request): return FORBIDDEN def http_MKCALENDAR(self, request): return FORBIDDEN @requiresPermissions(fromParent=[davxml.Bind()]) def http_MKCOL(self, request): return CREATED class CalendarObjectDropbox(_GetChildHelper): """ A wrapper around a calendar object which serves that calendar object's attachments as a DAV collection. """ def __init__(self, calendarObject, *a, **kw): super(CalendarObjectDropbox, self).__init__(*a, **kw) self._newStoreCalendarObject = calendarObject def isCollection(self): return True def resourceType(self): return davxml.ResourceType.dropbox # @UndefinedVariable @inlineCallbacks def getChild(self, name): attachment = yield self._newStoreCalendarObject.attachmentWithName(name) result = CalendarAttachment( self._newStoreCalendarObject, attachment, name, False, principalCollections=self.principalCollections() ) self.propagateTransaction(result) returnValue(result) @requiresPermissions(davxml.WriteACL()) @inlineCallbacks def http_ACL(self, request): """ Don't ever actually make changes, but attempt to deny any ACL requests that refer to permissions not referenced by attendees in the iCalendar data. """ attendees = (yield self._newStoreCalendarObject.component()).getAttendees() attendees = [attendee.split("urn:x-uid:")[-1] for attendee in attendees] document = yield davXMLFromStream(request.stream) for ace in document.root_element.children: for child in ace.children: if isinstance(child, davxml.Principal): for href in child.children: principalURI = href.children[0].data uidsPrefix = '/principals/__uids__/' if not principalURI.startswith(uidsPrefix): # Unknown principal. returnValue(FORBIDDEN) principalElements = principalURI[ len(uidsPrefix):].split("/") if principalElements[-1] == '': principalElements.pop() if principalElements[-1] in ('calendar-proxy-read', 'calendar-proxy-write'): principalElements.pop() if len(principalElements) != 1: returnValue(FORBIDDEN) principalUID = principalElements[0] if principalUID not in attendees: returnValue(FORBIDDEN) returnValue(OK) @requiresPermissions(fromParent=[davxml.Bind()]) def http_MKCOL(self, request): return CREATED @requiresPermissions(fromParent=[davxml.Unbind()]) def http_DELETE(self, request): return NO_CONTENT @inlineCallbacks def listChildren(self): l = [] for attachment in (yield self._newStoreCalendarObject.attachments()): l.append(attachment.name()) returnValue(l) @inlineCallbacks def accessControlList(self, request, *a, **kw): """ All principals identified as ATTENDEEs on the event for this dropbox may read all its children. Also include proxies of ATTENDEEs. Ignore unknown attendees. """ originalACL = yield super( CalendarObjectDropbox, self).accessControlList(request, *a, **kw) originalACEs = list(originalACL.children) if config.EnableProxyPrincipals: owner = (yield self.ownerPrincipal(request)) originalACEs += ( # DAV:write-acl access for this principal's calendar-proxy-write users. davxml.ACE( davxml.Principal(davxml.HRef(joinURL(owner.principalURL(), "calendar-proxy-write/"))), davxml.Grant( davxml.Privilege(davxml.WriteACL()), ), davxml.Protected(), TwistedACLInheritable(), ), ) othersCanWrite = self._newStoreCalendarObject.attendeesCanManageAttachments() cuas = (yield self._newStoreCalendarObject.component()).getAttendees() newACEs = [] for calendarUserAddress in cuas: principal = yield self.principalForCalendarUserAddress( calendarUserAddress ) if principal is None: continue principalURL = principal.principalURL() writePrivileges = [ davxml.Privilege(davxml.Read()), davxml.Privilege(davxml.ReadCurrentUserPrivilegeSet()), davxml.Privilege(davxml.Write()), ] readPrivileges = [ davxml.Privilege(davxml.Read()), davxml.Privilege(davxml.ReadCurrentUserPrivilegeSet()), ] if othersCanWrite: privileges = writePrivileges else: privileges = readPrivileges newACEs.append(davxml.ACE( davxml.Principal(davxml.HRef(principalURL)), davxml.Grant(*privileges), davxml.Protected(), TwistedACLInheritable(), )) newACEs.append(davxml.ACE( davxml.Principal(davxml.HRef(joinURL(principalURL, "calendar-proxy-write/"))), davxml.Grant(*privileges), davxml.Protected(), TwistedACLInheritable(), )) newACEs.append(davxml.ACE( davxml.Principal(davxml.HRef(joinURL(principalURL, "calendar-proxy-read/"))), davxml.Grant(*readPrivileges), davxml.Protected(), TwistedACLInheritable(), )) # Now also need invitees newACEs.extend((yield self.sharedDropboxACEs())) returnValue(davxml.ACL(*tuple(originalACEs + newACEs))) @inlineCallbacks def sharedDropboxACEs(self): aces = () invites = yield self._newStoreCalendarObject._parentCollection.sharingInvites() for invite in invites: # Only want accepted invites if invite.status != _BIND_STATUS_ACCEPTED: continue userprivs = [ ] if invite.mode in (_BIND_MODE_READ, _BIND_MODE_WRITE,): userprivs.append(davxml.Privilege(davxml.Read())) userprivs.append(davxml.Privilege(davxml.ReadACL())) userprivs.append(davxml.Privilege(davxml.ReadCurrentUserPrivilegeSet())) if invite.mode in (_BIND_MODE_READ,): userprivs.append(davxml.Privilege(davxml.WriteProperties())) if invite.mode in (_BIND_MODE_WRITE,): userprivs.append(davxml.Privilege(davxml.Write())) proxyprivs = list(userprivs) proxyprivs.remove(davxml.Privilege(davxml.ReadACL())) principal = yield self.principalForUID(invite.shareeUID) if principal is not None: aces += ( # Inheritable specific access for the resource's associated principal. davxml.ACE( davxml.Principal(davxml.HRef(principal.principalURL())), davxml.Grant(*userprivs), davxml.Protected(), TwistedACLInheritable(), ), ) if config.EnableProxyPrincipals: aces += ( # DAV:read/DAV:read-current-user-privilege-set access for this principal's calendar-proxy-read users. davxml.ACE( davxml.Principal(davxml.HRef(joinURL(principal.principalURL(), "calendar-proxy-read/"))), davxml.Grant( davxml.Privilege(davxml.Read()), davxml.Privilege(davxml.ReadCurrentUserPrivilegeSet()), ), davxml.Protected(), TwistedACLInheritable(), ), # DAV:read/DAV:read-current-user-privilege-set/DAV:write access for this principal's calendar-proxy-write users. davxml.ACE( davxml.Principal(davxml.HRef(joinURL(principal.principalURL(), "calendar-proxy-write/"))), davxml.Grant(*proxyprivs), davxml.Protected(), TwistedACLInheritable(), ), ) returnValue(aces) class AttachmentsCollection(_GetChildHelper): """ A collection of all managed attachments, presented as a resource under the user's calendar home. Attachments are stored in L{AttachmentsChildCollection} child collections of this one. """ # FIXME: no direct tests for this class at all. def __init__(self, parent, *a, **kw): kw.update(principalCollections=parent.principalCollections()) super(AttachmentsCollection, self).__init__(*a, **kw) self.parent = parent self._newStoreHome = self.parent._newStoreHome self.parent.propagateTransaction(self) def isCollection(self): """ It is a collection. """ return True @inlineCallbacks def getChild(self, name): calendarObject = yield self._newStoreHome.calendarObjectWithDropboxID(name) # Hide the dropbox if it has no children if calendarObject: if calendarObject.isInTrash(): # Don't allow access to attachments for items in the trash calendarObject = None else: l = (yield calendarObject.managedAttachmentList()) if len(l) == 0: l = (yield calendarObject.attachments()) if len(l) == 0: calendarObject = None if calendarObject is None: returnValue(NoDropboxHere()) objectDropbox = AttachmentsChildCollection( calendarObject, self, principalCollections=self.principalCollections() ) self.propagateTransaction(objectDropbox) returnValue(objectDropbox) def resourceType(self,): return davxml.ResourceType.dropboxhome # @UndefinedVariable def listChildren(self): return self._newStoreHome.getAllDropboxIDs() def supportedPrivileges(self, request): # Just DAV standard privileges - no CalDAV ones return succeed(davPrivilegeSet) @inlineCallbacks def defaultAccessControlList(self): """ Only read privileges allowed for managed attachments. """ myPrincipal = yield self.parent.principalForRecord() read_privs = ( davxml.Privilege(davxml.Read()), davxml.Privilege(davxml.ReadCurrentUserPrivilegeSet()), ) aces = ( # Inheritable access for the resource's associated principal. davxml.ACE( davxml.Principal(davxml.HRef(myPrincipal.principalURL())), davxml.Grant(*read_privs), davxml.Protected(), TwistedACLInheritable(), ), ) # Give read access to config.ReadPrincipals aces += config.ReadACEs # Give all access to config.AdminPrincipals aces += config.AdminACEs if config.EnableProxyPrincipals: aces += ( # DAV:read/DAV:read-current-user-privilege-set access for this principal's calendar-proxy-read users. davxml.ACE( davxml.Principal(davxml.HRef(joinURL(myPrincipal.principalURL(), "calendar-proxy-read/"))), davxml.Grant(*read_privs), davxml.Protected(), TwistedACLInheritable(), ), # DAV:read/DAV:read-current-user-privilege-set access for this principal's calendar-proxy-write users. davxml.ACE( davxml.Principal(davxml.HRef(joinURL(myPrincipal.principalURL(), "calendar-proxy-write/"))), davxml.Grant(*read_privs), davxml.Protected(), TwistedACLInheritable(), ), ) returnValue(davxml.ACL(*aces)) def accessControlList(self, request, inheritance=True, expanding=False, inherited_aces=None): # Permissions here are fixed, and are not subject to inheritance rules, etc. return self.defaultAccessControlList() class AttachmentsChildCollection(_GetChildHelper): """ A collection of all containers for attachments, presented as a resource under the user's calendar home, where a dropbox is a L{CalendarObjectDropbox}. """ # FIXME: no direct tests for this class at all. def __init__(self, calendarObject, parent, *a, **kw): kw.update(principalCollections=parent.principalCollections()) super(AttachmentsChildCollection, self).__init__(*a, **kw) self._newStoreCalendarObject = calendarObject parent.propagateTransaction(self) def isCollection(self): """ It is a collection. """ return True @inlineCallbacks def getChild(self, name): attachmentObject = yield self._newStoreCalendarObject.managedAttachmentRetrieval(name) if attachmentObject is not None: result = CalendarAttachment( None, attachmentObject, name, True, principalCollections=self.principalCollections() ) else: attachment = yield self._newStoreCalendarObject.attachmentWithName(name) result = CalendarAttachment( self._newStoreCalendarObject, attachment, name, False, principalCollections=self.principalCollections() ) self.propagateTransaction(result) returnValue(result) def resourceType(self,): return davxml.ResourceType.dropbox # @UndefinedVariable @inlineCallbacks def listChildren(self): l = (yield self._newStoreCalendarObject.managedAttachmentList()) for attachment in (yield self._newStoreCalendarObject.attachments()): l.append(attachment.name()) returnValue(l) @inlineCallbacks def http_ACL(self, request): # For managed attachment compatibility this is always forbidden as dropbox clients must never be # allowed to store attachments or make any changes. return FORBIDDEN def http_MKCOL(self, request): # For managed attachment compatibility this is always forbidden as dropbox clients must never be # allowed to store attachments or make any changes. return FORBIDDEN @requiresPermissions(fromParent=[davxml.Unbind()]) def http_DELETE(self, request): # For managed attachment compatibility this always succeeds as dropbox clients will do # this but we don't want them to see an error. Managed attachments will always be cleaned # up on removal of the actual calendar object resource. return NO_CONTENT @inlineCallbacks def accessControlList(self, request, *a, **kw): """ All principals identified as ATTENDEEs on the event for this dropbox may read all its children. Also include proxies of ATTENDEEs. Ignore unknown attendees. Do not allow attendees to write as we don't support that with managed attachments. Also include sharees of the event. """ originalACL = yield super( AttachmentsChildCollection, self).accessControlList(request, *a, **kw) originalACEs = list(originalACL.children) if config.EnableProxyPrincipals: owner = (yield self.ownerPrincipal(request)) originalACEs += ( # DAV:write-acl access for this principal's calendar-proxy-write users. davxml.ACE( davxml.Principal(davxml.HRef(joinURL(owner.principalURL(), "calendar-proxy-write/"))), davxml.Grant( davxml.Privilege(davxml.WriteACL()), ), davxml.Protected(), TwistedACLInheritable(), ), ) cuas = (yield self._newStoreCalendarObject.component()).getAttendees() newACEs = [] for calendarUserAddress in cuas: principal = yield self.principalForCalendarUserAddress( calendarUserAddress ) if principal is None: continue principalURL = principal.principalURL() privileges = [ davxml.Privilege(davxml.Read()), davxml.Privilege(davxml.ReadCurrentUserPrivilegeSet()), ] newACEs.append(davxml.ACE( davxml.Principal(davxml.HRef(principalURL)), davxml.Grant(*privileges), davxml.Protected(), TwistedACLInheritable(), )) newACEs.append(davxml.ACE( davxml.Principal(davxml.HRef(joinURL(principalURL, "calendar-proxy-write/"))), davxml.Grant(*privileges), davxml.Protected(), TwistedACLInheritable(), )) newACEs.append(davxml.ACE( davxml.Principal(davxml.HRef(joinURL(principalURL, "calendar-proxy-read/"))), davxml.Grant(*privileges), davxml.Protected(), TwistedACLInheritable(), )) # Now also need invitees newACEs.extend((yield self.sharedDropboxACEs())) returnValue(davxml.ACL(*tuple(originalACEs + newACEs))) @inlineCallbacks def _sharedAccessControl(self, invite): """ Check the shared access mode of this resource, potentially consulting an external access method if necessary. @return: a L{Deferred} firing a L{bytes} or L{None}, with one of the potential values: C{"own"}, which means that the home is the owner of the collection and it is not shared; C{"read-only"}, meaning that the home that this collection is bound into has only read access to this collection; C{"read-write"}, which means that the home has both read and write access; C{"original"}, which means that it should inherit the ACLs of the owner's collection, whatever those happen to be, or C{None}, which means that the external access control mechanism has dictate the home should no longer have any access at all. """ if invite.mode in (_BIND_MODE_DIRECT,): ownerUID = invite.ownerUID owner = yield self.principalForUID(ownerUID) shareeUID = invite.shareeUID if owner.record.recordType == WikiRecordType.macOSXServerWiki: # Access level comes from what the wiki has granted to the # sharee sharee = yield self.principalForUID(shareeUID) access = (yield owner.record.accessForRecord(sharee.record)) if access == "read": returnValue("read-only") elif access in ("write", "admin"): returnValue("read-write") else: returnValue(None) else: returnValue("original") elif invite.mode in (_BIND_MODE_READ,): returnValue("read-only") elif invite.mode in (_BIND_MODE_WRITE,): returnValue("read-write") returnValue("original") @inlineCallbacks def sharedDropboxACEs(self): aces = () invites = yield self._newStoreCalendarObject._parentCollection.sharingInvites() for invite in invites: # Only want accepted invites if invite.status != _BIND_STATUS_ACCEPTED: continue privileges = [ davxml.Privilege(davxml.Read()), davxml.Privilege(davxml.ReadCurrentUserPrivilegeSet()), ] userprivs = [] access = (yield self._sharedAccessControl(invite)) if access in ("read-only", "read-write",): userprivs.extend(privileges) principal = yield self.principalForUID(invite.shareeUID) if principal is not None: aces += ( # Inheritable specific access for the resource's associated principal. davxml.ACE( davxml.Principal(davxml.HRef(principal.principalURL())), davxml.Grant(*userprivs), davxml.Protected(), TwistedACLInheritable(), ), ) if config.EnableProxyPrincipals: aces += ( # DAV:read/DAV:read-current-user-privilege-set access for this principal's calendar-proxy-read users. davxml.ACE( davxml.Principal(davxml.HRef(joinURL(principal.principalURL(), "calendar-proxy-read/"))), davxml.Grant(*userprivs), davxml.Protected(), TwistedACLInheritable(), ), # DAV:read/DAV:read-current-user-privilege-set/DAV:write access for this principal's calendar-proxy-write users. davxml.ACE( davxml.Principal(davxml.HRef(joinURL(principal.principalURL(), "calendar-proxy-write/"))), davxml.Grant(*userprivs), davxml.Protected(), TwistedACLInheritable(), ), ) returnValue(aces) class CalendarAttachment(_NewStoreFileMetaDataHelper, _GetChildHelper): def __init__(self, calendarObject, attachment, attachmentName, managed, **kw): super(CalendarAttachment, self).__init__(**kw) self._newStoreCalendarObject = calendarObject # This can be None for a managed attachment self._newStoreAttachment = self._newStoreObject = attachment self._managed = managed self._dead_properties = NonePropertyStore(self) self.attachmentName = attachmentName def getChild(self, name): return None def displayName(self): return self.name() @requiresPermissions(davxml.WriteContent()) @inlineCallbacks def http_PUT(self, request): # FIXME: direct test # FIXME: CDT test to make sure that permissions are enforced. # Cannot PUT to a managed attachment if self._managed: raise HTTPError(FORBIDDEN) content_type = request.headers.getHeader("content-type") if content_type is None: content_type = MimeType("application", "octet-stream") try: creating = (self._newStoreAttachment is None) if creating: self._newStoreAttachment = self._newStoreObject = ( yield self._newStoreCalendarObject.createAttachmentWithName( self.attachmentName)) t = self._newStoreAttachment.store(content_type) yield readStream(request.stream, t.write) except AttachmentDropboxNotAllowed: log.error("Dropbox cannot be used after migration to managed attachments") raise HTTPError(FORBIDDEN) except Exception, e: log.error("Unable to store attachment: {ex}", ex=e) raise HTTPError(SERVICE_UNAVAILABLE) try: yield t.loseConnection() except AttachmentSizeTooLarge: raise HTTPError( ErrorResponse(FORBIDDEN, (caldav_namespace, "max-attachment-size")) ) except QuotaExceeded: raise HTTPError( ErrorResponse(INSUFFICIENT_STORAGE_SPACE, (dav_namespace, "quota-not-exceeded")) ) returnValue(CREATED if creating else NO_CONTENT) @requiresPermissions(davxml.Read()) def http_GET(self, request): if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) stream = ProducerStream() class StreamProtocol(Protocol): def connectionMade(self): stream.registerProducer(self.transport, False) def dataReceived(self, data): stream.write(data) def connectionLost(self, reason): stream.finish() try: self._newStoreAttachment.retrieve(StreamProtocol()) except IOError, e: log.error("Unable to read attachment: {s!r}, due to: {ex}", s=self, ex=e) raise HTTPError(NOT_FOUND) headers = {"content-type": self.contentType()} headers["content-disposition"] = MimeDisposition("attachment", params={"filename": self.displayName()}) return Response(OK, headers, stream) @requiresPermissions(fromParent=[davxml.Unbind()]) @inlineCallbacks def http_DELETE(self, request): # Cannot DELETE a managed attachment if self._managed: raise HTTPError(FORBIDDEN) if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) yield self._newStoreCalendarObject.removeAttachmentWithName( self._newStoreAttachment.name() ) self._newStoreAttachment = self._newStoreCalendarObject = None returnValue(NO_CONTENT) http_MKCOL = None http_MKCALENDAR = None def http_PROPPATCH(self, request): """ No dead properties allowed on attachments. """ return FORBIDDEN def isCollection(self): return False def supportedPrivileges(self, request): # Just DAV standard privileges - no CalDAV ones return succeed(davPrivilegeSet) class NoParent(CalDAVResource): def http_MKCALENDAR(self, request): return CONFLICT def http_PUT(self, request): return CONFLICT def isCollection(self): return False def exists(self): return False class _CommonObjectResource(_NewStoreFileMetaDataHelper, _CommonStoreExceptionHandler, CalDAVResource, FancyEqMixin): _componentFromStream = None def __init__(self, storeObject, parentObject, parentResource, name, *args, **kw): """ Construct a L{_CommonObjectResource} from an L{CommonObjectResource}. @param storeObject: The storage for the object. @type storeObject: L{txdav.common.CommonObjectResource} """ super(_CommonObjectResource, self).__init__(*args, **kw) self._initializeWithObject(storeObject, parentObject) self._parentResource = parentResource self._name = name self._metadata = {} def _initializeWithObject(self, storeObject, parentObject): self._newStoreParent = parentObject self._newStoreObject = storeObject self._dead_properties = _NewStorePropertiesWrapper( self._newStoreObject.properties() ) if self._newStoreObject and self._newStoreParent.objectResourcesHaveProperties() else NonePropertyStore(self) def url(self): return joinURL(self._parentResource.url(), self.name()) def isCollection(self): return False def quotaSize(self, request): return succeed(self._newStoreObject.size()) def uid(self): return self._newStoreObject.uid() def component(self): return self._newStoreObject.component() def componentForUser(self): return self._newStoreObject.component() def allowedTypes(self): """ Return a dict of allowed MIME types for storing, mapped to equivalent PyCalendar types. """ raise NotImplementedError def determineType(self, content_type): """ Determine if the supplied content-type is valid for storing and return the matching PyCalendar type. """ format = None if content_type is not None: format = "%s/%s" % (content_type.mediaType, content_type.mediaSubtype,) return format if format in self.allowedTypes() else None @inlineCallbacks def render(self, request): if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) # Accept header handling accepted_type = bestAcceptType(request.headers.getHeader("accept"), self.allowedTypes()) if accepted_type is None: raise HTTPError(StatusResponse(responsecode.NOT_ACCEPTABLE, "Cannot generate requested data type")) output = yield self.componentForUser() response = Response(OK, {}, output.getText(accepted_type)) response.headers.setHeader("content-type", MimeType.fromString("%s; charset=utf-8" % (accepted_type,))) returnValue(response) @inlineCallbacks def checkPreconditions(self, request): """ We override the base class to trap the failure case and process any Prefer header. """ try: response = yield super(_CommonObjectResource, self).checkPreconditions(request) except HTTPError as e: if e.response.code == responsecode.PRECONDITION_FAILED: response = yield self._processPrefer(request, e.response) raise HTTPError(response) else: raise returnValue(response) @inlineCallbacks def _processPrefer(self, request, response): # Look for Prefer header prefer = request.headers.getHeader("prefer", {}) returnRepresentation = any([key == "return" and value == "representation" for key, value, _ignore_args in prefer]) if returnRepresentation and (response.code / 100 == 2 or response.code == responsecode.PRECONDITION_FAILED): oldcode = response.code response = (yield self.http_GET(request)) if oldcode in (responsecode.CREATED, responsecode.PRECONDITION_FAILED): response.code = oldcode response.headers.removeHeader("content-location") response.headers.setHeader("content-location", self.url()) returnValue(response) @requiresPermissions(fromParent=[davxml.Unbind()]) def http_DELETE(self, request): """ Override http_DELETE to validate 'depth' header. """ if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) return self.storeRemove(request) def http_COPY(self, request): """ Copying of calendar data isn't allowed. """ # FIXME: no direct tests return FORBIDDEN @inlineCallbacks def http_MOVE(self, request): """ MOVE for object resources. """ # Do some pre-flight checks - must exist, must be move to another # CommonHomeChild in the same Home, destination resource must not exist if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) parent = (yield request.locateResource(parentForURL(request.uri))) # # Find the destination resource # destination_uri = request.headers.getHeader("destination") overwrite = request.headers.getHeader("overwrite", True) if not destination_uri: msg = "No destination header in MOVE request." log.error(msg) raise HTTPError(StatusResponse(BAD_REQUEST, msg)) destination = (yield request.locateResource(destination_uri)) if destination is None: msg = "Destination of MOVE does not exist: %s" % (destination_uri,) log.debug(msg) raise HTTPError(StatusResponse(BAD_REQUEST, msg)) if destination.exists(): if overwrite: msg = "Cannot overwrite existing resource with a MOVE" log.debug(msg) raise HTTPError(StatusResponse(FORBIDDEN, msg)) else: msg = "Cannot MOVE to existing resource without overwrite flag enabled" log.debug(msg) raise HTTPError(StatusResponse(PRECONDITION_FAILED, msg)) # Check for parent calendar collection destination_uri = urlsplit(destination_uri)[2] destinationparent = (yield request.locateResource(parentForURL(destination_uri))) if not isinstance(destinationparent, _CommonHomeChildCollectionMixin): msg = "Destination of MOVE is not valid: %s" % (destination_uri,) log.debug(msg) raise HTTPError(StatusResponse(FORBIDDEN, msg)) if parentForURL(parentForURL(destination_uri)) != parentForURL(parentForURL(request.uri)): msg = "Can only MOVE within the same home collection: %s" % (destination_uri,) log.debug(msg) raise HTTPError(StatusResponse(FORBIDDEN, msg)) # # Check authentication and access controls # yield parent.authorize(request, (davxml.Unbind(),)) yield destinationparent.authorize(request, (davxml.Bind(),)) # May need to add a location header addLocation(request, destination_uri) try: response = (yield self.storeMove(request, destinationparent, destination.name())) self._newStoreObject = None returnValue(response) # Handle the various store errors except Exception as err: self._handleStoreException(err, self.StoreMoveExceptionsErrors) raise def http_PROPPATCH(self, request): """ No dead properties allowed on object resources. """ if self._newStoreParent.objectResourcesHaveProperties(): return super(_CommonObjectResource, self).http_PROPPATCH(request) else: return FORBIDDEN @inlineCallbacks def storeStream(self, stream, format): # FIXME: direct tests component = self._componentFromStream((yield allDataFromStream(stream)), format) result = (yield self.storeComponent(component)) returnValue(result) @inlineCallbacks def storeComponent(self, component, **kwargs): try: if self._newStoreObject: yield self._newStoreObject.setComponent(component, **kwargs) returnValue(NO_CONTENT) else: self._newStoreObject = (yield self._newStoreParent.createObjectResourceWithName( self.name(), component, self._metadata )) # Re-initialize to get stuff setup again now we have no object self._initializeWithObject(self._newStoreObject, self._newStoreParent) returnValue(CREATED) # Map store exception to HTTP errors except Exception as err: self._handleStoreException(err, self.StoreExceptionsErrors) raise @inlineCallbacks def storeMove(self, request, destinationparent, destination_name): """ Move this object to a different parent. @param request: @type request: L{txweb2.iweb.IRequest} @param destinationparent: Parent to move to @type destinationparent: L{CommonHomeChild} @param destination_name: name of new resource @type destination_name: C{str} """ yield self._newStoreObject.moveTo(destinationparent._newStoreObject, destination_name) returnValue(CREATED) @inlineCallbacks def storeRemove(self, request): """ Delete this object. @param request: Unused by this implementation; present for signature compatibility with L{CalendarCollectionResource.storeRemove}. @type request: L{txweb2.iweb.IRequest} @return: an HTTP response suitable for sending to a client (or including in a multi-status). @rtype: something adaptable to L{txweb2.iweb.IResponse} """ # Do delete try: yield self._newStoreObject.remove() except NoSuchObjectResourceError: raise HTTPError(NOT_FOUND) # Map store exception to HTTP errors except Exception as err: self._handleStoreException(err, self.StoreExceptionsErrors) raise # Re-initialize to get stuff setup again now we have no object self._initializeWithObject(None, self._newStoreParent) returnValue(NO_CONTENT) class _MetadataProperty(object): """ A python property which can be set either on a _newStoreObject or on some metadata if no new store object exists yet. """ def __init__(self, name): self.name = name def __get__(self, oself, ptype=None): if oself._newStoreObject: return getattr(oself._newStoreObject, self.name) else: return oself._metadata.get(self.name, None) def __set__(self, oself, value): if oself._newStoreObject: setattr(oself._newStoreObject, self.name, value) else: oself._metadata[self.name] = value class _CalendarObjectMetaDataMixin(object): """ Dynamically create the required meta-data for an object resource """ accessMode = _MetadataProperty("accessMode") isScheduleObject = _MetadataProperty("isScheduleObject") scheduleTag = _MetadataProperty("scheduleTag") scheduleEtags = _MetadataProperty("scheduleEtags") hasPrivateComment = _MetadataProperty("hasPrivateComment") class CalendarObjectResource(_CalendarObjectMetaDataMixin, _CommonObjectResource): """ A resource wrapping a calendar object. """ compareAttributes = ( "_newStoreObject", ) _componentFromStream = VCalendar.fromString def allowedTypes(self): """ Return a tuple of allowed MIME types for storing. """ return Component.allowedTypes() @inlineCallbacks def inNewTransaction(self, request, label=""): """ Implicit auto-replies need to span multiple transactions. Clean out the given request's resource-lookup mapping, transaction, and re-look- up this L{CalendarObjectResource}'s calendar object in a new transaction. @return: a Deferred which fires with the new transaction, so it can be committed. """ objectName = self._newStoreObject.name() calendar = self._newStoreObject.calendar() calendarName = calendar.name() ownerHome = calendar.ownerCalendarHome() homeUID = ownerHome.uid() txn = ownerHome.transaction().store().newTransaction( "new transaction for %s, doing: %s" % (self._newStoreObject.name(), label,)) newParent = ( yield (yield txn.calendarHomeWithUID(homeUID)) .calendarWithName(calendarName) ) newObject = (yield newParent.calendarObjectWithName(objectName)) request._newStoreTransaction = txn request._resourcesByURL.clear() request._urlsByResource.clear() self._initializeWithObject(newObject, newParent) returnValue(txn) def componentForUser(self): return self._newStoreObject.componentForUser() def validIfScheduleMatch(self, request): """ Check to see if the given request's C{If-Schedule-Tag-Match} header matches this resource's schedule tag. @raise HTTPError: if the tag does not match. @return: None """ # Note, internal requests shouldn't issue this. header = request.headers.getHeader("If-Schedule-Tag-Match") if header: # Do "precondition" test if (self.scheduleTag != header): log.debug( "If-Schedule-Tag-Match: header value '{h}' does not match resource value '{r}'", h=header, r=self.scheduleTag ) raise HTTPError(PRECONDITION_FAILED) return True elif config.Scheduling.CalDAV.ScheduleTagCompatibility: # Compatibility with old clients. Policy: # # 1. If If-Match header is not present, never do smart merge. # 2. If If-Match is present and the specified ETag is # considered a "weak" match to the current Schedule-Tag, # then do smart merge, else reject with a 412. # # Actually by the time we get here the precondition will # already have been tested and found to be OK, so we can just # always do smart merge now if If-Match is present. return request.headers.getHeader("If-Match") is not None else: return False StoreExceptionsErrors = { ObjectResourceNameNotAllowedError: (_CommonStoreExceptionHandler._storeExceptionStatus, None,), ObjectResourceNameAlreadyExistsError: (_CommonStoreExceptionHandler._storeExceptionStatus, None,), TooManyObjectResourcesError: (_CommonStoreExceptionHandler._storeExceptionError, customxml.MaxResources(),), ObjectResourceTooBigError: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "max-resource-size"),), InvalidObjectResourceError: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "valid-calendar-data"),), InvalidComponentForStoreError: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "valid-calendar-object-resource"),), InvalidComponentTypeError: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "supported-calendar-component"),), TooManyAttendeesError: (_CommonStoreExceptionHandler._storeExceptionError, MaxAttendeesPerInstance.fromString(str(config.MaxAttendeesPerInstance)),), InvalidCalendarAccessError: (_CommonStoreExceptionHandler._storeExceptionError, (calendarserver_namespace, "valid-access-restriction"),), ValidOrganizerError: (_CommonStoreExceptionHandler._storeExceptionError, (calendarserver_namespace, "valid-organizer"),), UIDExistsError: (_CommonStoreExceptionHandler._storeExceptionError, NoUIDConflict(),), UIDExistsElsewhereError: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "unique-scheduling-object-resource"),), InvalidUIDError: (_CommonStoreExceptionHandler._storeExceptionError, NoUIDConflict(),), InvalidPerUserDataMerge: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "valid-calendar-data"),), AttendeeAllowedError: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "attendee-allowed"),), InvalidOverriddenInstanceError: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "valid-calendar-data"),), TooManyInstancesError: (_CommonStoreExceptionHandler._storeExceptionError, MaxInstances.fromString(str(config.MaxAllowedInstances)),), AttachmentStoreValidManagedID: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "valid-managed-id"),), TooManyAttachments: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "max-attachments"),), ShareeAllowedError: (_CommonStoreExceptionHandler._storeExceptionError, (calendarserver_namespace, "sharee-privilege-needed",),), DuplicatePrivateCommentsError: (_CommonStoreExceptionHandler._storeExceptionError, (calendarserver_namespace, "no-duplicate-private-comments",),), LockTimeout: (_CommonStoreExceptionHandler._storeExceptionUnavailable, "Lock timed out.",), UnknownTimezone: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "valid-timezone"),), AlreadyInTrashError: (_CommonStoreExceptionHandler._storeExceptionError, (calendarserver_namespace, "not-in-trash",),), FailedCrossPodRequestError: (_CommonStoreExceptionHandler._storeExceptionUnavailable, "Cross-pod request failed.",), } StoreMoveExceptionsErrors = { ObjectResourceNameNotAllowedError: (_CommonStoreExceptionHandler._storeExceptionStatus, None,), ObjectResourceNameAlreadyExistsError: (_CommonStoreExceptionHandler._storeExceptionStatus, None,), TooManyObjectResourcesError: (_CommonStoreExceptionHandler._storeExceptionError, customxml.MaxResources(),), InvalidResourceMove: (_CommonStoreExceptionHandler._storeExceptionError, (calendarserver_namespace, "valid-move"),), InvalidComponentTypeError: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "supported-calendar-component"),), LockTimeout: (_CommonStoreExceptionHandler._storeExceptionUnavailable, "Lock timed out.",), } StoreAttachmentValidErrors = { AttachmentStoreFailed: _CommonStoreExceptionHandler._storeExceptionError, InvalidAttachmentOperation: _CommonStoreExceptionHandler._storeExceptionError, } StoreAttachmentExceptionsErrors = { AttachmentStoreValidManagedID: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "valid-managed-id-parameter",),), AttachmentRemoveFailed: (_CommonStoreExceptionHandler._storeExceptionError, (caldav_namespace, "valid-attachment-remove",),), } @inlineCallbacks def _checkPreconditions(self, request): """ We override the base class to handle the special implicit scheduling weak ETag behavior for compatibility with old clients using If-Match. """ if config.Scheduling.CalDAV.ScheduleTagCompatibility: if self.exists(): etags = self.scheduleEtags if len(etags) > 1: # This is almost verbatim from txweb2.static.checkPreconditions if request.method not in ("GET", "HEAD"): # Always test against the current etag first just in case schedule-etags is out of sync etag = (yield self.etag()) etags = (etag,) + tuple([http_headers.ETag(schedule_etag) for schedule_etag in etags]) # Loop over each tag and succeed if any one matches, else re-raise last exception exists = self.exists() last_modified = self.lastModified() last_exception = None for etag in etags: try: http.checkPreconditions( request, entityExists=exists, etag=etag, lastModified=last_modified, ) except HTTPError, e: last_exception = e else: break else: if last_exception: raise last_exception # Check per-method preconditions method = getattr(self, "preconditions_" + request.method, None) if method: returnValue((yield method(request))) else: returnValue(None) result = (yield super(CalendarObjectResource, self).checkPreconditions(request)) returnValue(result) @inlineCallbacks def checkPreconditions(self, request): """ We override the base class to do special schedule tag processing. """ try: response = yield self._checkPreconditions(request) except HTTPError as e: if e.response.code == responsecode.PRECONDITION_FAILED: response = yield self._processPrefer(request, e.response) raise HTTPError(response) else: raise returnValue(response) def canBeShared(self): return False @inlineCallbacks def http_PUT(self, request): # Content-type check content_type = request.headers.getHeader("content-type") format = self.determineType(content_type) if format is None: log.error("MIME type {content_type} not allowed in calendar collection", content_type=content_type) raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, (caldav_namespace, "supported-calendar-data"), "Invalid MIME type for calendar collection", )) # Do schedule tag check try: schedule_tag_match = self.validIfScheduleMatch(request) except HTTPError as e: if e.response.code == responsecode.PRECONDITION_FAILED: response = yield self._processPrefer(request, e.response) raise HTTPError(response) else: raise # Read the calendar component from the stream try: calendardata = (yield allDataFromStream(request.stream)) if not hasattr(request, "extendedLogItems"): request.extendedLogItems = {} request.extendedLogItems["cl"] = str(len(calendardata)) if calendardata else "0" # We must have some data at this point if calendardata is None: # Use correct DAV:error response raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, (caldav_namespace, "valid-calendar-data"), description="No calendar data" )) try: component = Component.fromString(calendardata, format) except ValueError, e: log.error(str(e)) raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, (caldav_namespace, "valid-calendar-data"), "Can't parse calendar data: %s" % (str(e),) )) # Look for client fixes ua = request.headers.getHeader("User-Agent") client_fix_transp = "ForceAttendeeTRANSP" in matchClientFixes(config, ua) # Setup options options = { SetComponentOptions.smartMerge: schedule_tag_match, SetComponentOptions.clientFixTRANSP: client_fix_transp, } try: response = (yield self.storeComponent(component, options=options)) except ResourceDeletedError: # This is OK - it just means the server deleted the resource during the PUT. We make it look # like the PUT succeeded. response = responsecode.NO_CONTENT if self.exists() else responsecode.CREATED # Re-initialize to get stuff setup again now we have no object self._initializeWithObject(None, self._newStoreParent) returnValue(response) response = IResponse(response) if self._newStoreObject.isScheduleObject: # Add a response header response.headers.setHeader("Schedule-Tag", self._newStoreObject.scheduleTag) # Must not set ETag in response if data changed if self._newStoreObject._componentChanged: def _removeEtag(request, response): response.headers.removeHeader('etag') return response _removeEtag.handleErrors = True request.addResponseFilter(_removeEtag, atEnd=True) # Handle Prefer header if request.headers.getHeader("accept") is None: request.headers.setHeader("accept", dict(((MimeType.fromString(format), 1.0,),))) response = yield self._processPrefer(request, response) returnValue(response) # Handle the various store errors except Exception as err: if isinstance(err, ValueError): log.error("Error while handling (calendar) PUT: {ex}", ex=err) raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, str(err))) else: raise @requiresPermissions(fromParent=[davxml.Unbind()]) def http_DELETE(self, request): """ Override http_DELETE to do schedule tag behavior. """ if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) # Do schedule tag check self.validIfScheduleMatch(request) return self.storeRemove(request) @inlineCallbacks def http_MOVE(self, request): """ Need If-Schedule-Tag-Match behavior """ # Do some pre-flight checks - must exist, must be move to another # CommonHomeChild in the same Home, destination resource must not exist if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) # Do schedule tag check self.validIfScheduleMatch(request) result = (yield super(CalendarObjectResource, self).http_MOVE(request)) returnValue(result) @inlineCallbacks def POST_handler_action(self, request, action): """ Handle a POST request with an action= query parameter @param request: the request to process @type request: L{Request} @param action: the action to execute @type action: C{str} """ if action.startswith("attachment-"): result = (yield self.POST_handler_attachment(request, action)) returnValue(result) else: actioner = { "split": self.POST_handler_split, } if action in actioner: result = (yield actioner[action](request, action)) returnValue(result) else: raise HTTPError(ErrorResponse( FORBIDDEN, (caldav_namespace, "valid-action-parameter",), "The action parameter in the request-URI is not valid", )) @requiresPermissions(davxml.WriteContent()) @inlineCallbacks def POST_handler_split(self, request, action): """ Handle a split of a calendar object resource. @param request: HTTP request object @type request: L{Request} @param action: The request-URI 'action' argument @type action: C{str} @return: an HTTP response """ # Resource must exist if not self.exists(): raise HTTPError(NOT_FOUND) # Do schedule tag check try: self.validIfScheduleMatch(request) except HTTPError as e: if e.response.code == responsecode.PRECONDITION_FAILED: response = yield self._processPrefer(request, e.response) raise HTTPError(response) else: raise # Split point is in the rid query parameter rid = request.args.get("rid") if rid is None: raise HTTPError(ErrorResponse( FORBIDDEN, (caldav_namespace, "valid-rid-parameter",), "The rid parameter in the request-URI contains an invalid value", )) try: rid = DateTime.parseText(rid[0]) except ValueError: raise HTTPError(ErrorResponse( FORBIDDEN, (caldav_namespace, "valid-rid-parameter",), "The rid parameter in the request-URI contains an invalid value", )) # Client may provide optional UID for the split-off past component pastUID = request.args.get("uid") if pastUID is not None: pastUID = pastUID[0] try: otherStoreObject = yield self._newStoreObject.splitAt(rid, pastUID) except InvalidSplit as e: raise HTTPError(ErrorResponse( FORBIDDEN, (calendarserver_namespace, "valid-split",), str(e), )) other = yield request.locateChildResource(self._parentResource, otherStoreObject.name()) if other is None: raise responsecode.INTERNAL_SERVER_ERROR # Look for Prefer header prefer = request.headers.getHeader("prefer", {}) returnRepresentation = any([key == "return" and value == "representation" for key, value, _ignore_args in prefer]) if returnRepresentation: # Accept header handling accepted_type = bestAcceptType(request.headers.getHeader("accept"), Component.allowedTypes()) if accepted_type is None: raise HTTPError(StatusResponse(responsecode.NOT_ACCEPTABLE, "Cannot generate requested data type")) etag1 = yield self.etag() etag2 = yield other.etag() scheduletag1 = self.scheduleTag scheduletag2 = otherStoreObject.scheduleTag cal1 = yield self.componentForUser() cal2 = yield other.componentForUser() xml_responses = [ davxml.PropertyStatusResponse( davxml.HRef.fromString(self.url()), davxml.PropertyStatus( davxml.PropertyContainer( davxml.GETETag.fromString(etag1.generate()), caldavxml.ScheduleTag.fromString(scheduletag1), caldavxml.CalendarData.fromComponent(cal1, accepted_type), ), davxml.Status.fromResponseCode(OK), ) ), davxml.PropertyStatusResponse( davxml.HRef.fromString(other.url()), davxml.PropertyStatus( davxml.PropertyContainer( davxml.GETETag.fromString(etag2.generate()), caldavxml.ScheduleTag.fromString(scheduletag2), caldavxml.CalendarData.fromComponent(cal2, accepted_type), ), davxml.Status.fromResponseCode(OK), ) ), ] # Return multistatus with calendar data for this resource and the new one result = MultiStatusResponse(xml_responses) else: result = Response(responsecode.NO_CONTENT) result.headers.addRawHeader("Split-Component-URL", other.url()) returnValue(result) @requiresPermissions(davxml.WriteContent()) @inlineCallbacks def POST_handler_attachment(self, request, action): """ Handle a managed attachments request on the calendar object resource. @param request: HTTP request object @type request: L{Request} @param action: The request-URI 'action' argument @type action: C{str} @return: an HTTP response """ if not config.EnableManagedAttachments: returnValue(StatusResponse(responsecode.FORBIDDEN, "Managed Attachments not supported.")) # Resource must exist to allow attachment operations if not self.exists(): raise HTTPError(NOT_FOUND) def _getRIDs(): rids = request.args.get("rid") if rids is not None: rids = rids[0].split(",") try: rids = [DateTime.parseText(rid) if rid != "M" else None for rid in rids] except ValueError: raise HTTPError(ErrorResponse( FORBIDDEN, (caldav_namespace, "valid-rid-parameter",), "The rid parameter in the request-URI contains an invalid value", )) if rids: raise HTTPError(ErrorResponse( FORBIDDEN, (caldav_namespace, "valid-rid-parameter",), "Server does not support per-instance attachments", )) return rids def _getMID(): mid = request.args.get("managed-id") if mid is None: raise HTTPError(ErrorResponse( FORBIDDEN, (caldav_namespace, "valid-managed-id-parameter",), "The managed-id parameter is missing from the request-URI", )) return mid[0] def _getContentInfo(): content_type = request.headers.getHeader("content-type") if content_type is None: content_type = MimeType("application", "octet-stream") content_disposition = request.headers.getHeader("content-disposition") if content_disposition is None or "filename" not in content_disposition.params: filename = str(uuid.uuid4()) else: filename = content_disposition.params["filename"] return content_type, filename valid_preconditions = { "attachment-add": "valid-attachment-add", "attachment-update": "valid-attachment-update", "attachment-remove": "valid-attachment-remove", } # Dispatch to store object try: if action == "attachment-add": rids = _getRIDs() content_type, filename = _getContentInfo() attachment, location = (yield self._newStoreObject.addAttachment(rids, content_type, filename, request.stream)) post_result = Response(CREATED) if not hasattr(request, "extendedLogItems"): request.extendedLogItems = {} request.extendedLogItems["cl"] = str(attachment.size()) elif action == "attachment-update": mid = _getMID() content_type, filename = _getContentInfo() attachment, location = (yield self._newStoreObject.updateAttachment(mid, content_type, filename, request.stream)) post_result = Response(NO_CONTENT) if not hasattr(request, "extendedLogItems"): request.extendedLogItems = {} request.extendedLogItems["cl"] = str(attachment.size()) elif action == "attachment-remove": rids = _getRIDs() mid = _getMID() yield self._newStoreObject.removeAttachment(rids, mid) post_result = Response(NO_CONTENT) else: raise HTTPError(ErrorResponse( FORBIDDEN, (caldav_namespace, "valid-action-parameter",), "The action parameter in the request-URI is not valid", )) except AttachmentSizeTooLarge: raise HTTPError( ErrorResponse(FORBIDDEN, (caldav_namespace, "max-attachment-size")) ) except TooManyAttachments: raise HTTPError( ErrorResponse(FORBIDDEN, (caldav_namespace, "max-attachments")) ) except QuotaExceeded: raise HTTPError(ErrorResponse( INSUFFICIENT_STORAGE_SPACE, (dav_namespace, "quota-not-exceeded"), "Could not store the supplied attachment because user quota would be exceeded", )) # Map store exception to HTTP errors except Exception as err: self._handleStoreExceptionArg(err, self.StoreAttachmentValidErrors, (caldav_namespace, valid_preconditions[action],)) self._handleStoreException(err, self.StoreAttachmentExceptionsErrors) self._handleStoreException(err, self.StoreExceptionsErrors) raise # Look for Prefer header result = yield self._processPrefer(request, post_result) if action in ("attachment-add", "attachment-update",): result.headers.setHeader("location", location) result.headers.addRawHeader("Cal-Managed-ID", attachment.managedID()) returnValue(result) class AddressBookCollectionResource(_CommonHomeChildCollectionMixin, CalDAVResource): """ Wrapper around a L{txdav.carddav.iaddressbook.IAddressBook}. """ def __init__(self, addressbook, home, name=None, *args, **kw): """ Create a AddressBookCollectionResource from a L{txdav.carddav.iaddressbook.IAddressBook} and the arguments required for L{CalDAVResource}. """ self._childClass = AddressBookObjectResource super(AddressBookCollectionResource, self).__init__(*args, **kw) self._initializeWithHomeChild(addressbook, home) self._name = addressbook.name() if addressbook else name if config.EnableBatchUpload: self._postHandlers[("text", "vcard")] = AddressBookCollectionResource.simpleBatchPOST if config.EnableJSONData: self._postHandlers[("application", "vcard+json")] = _CommonHomeChildCollectionMixin.simpleBatchPOST self.xmlDocHandlers[customxml.Multiput] = AddressBookCollectionResource.crudBatchPOST def __repr__(self): return "<AddressBook Collection Resource %r:%r %s>" % ( self._newStoreParentHome.uid(), self._name, "" if self._newStoreObject else "Non-existent" ) def isCollection(self): return True def isAddressBookCollection(self): return True def resourceType(self): if self.isSharedByOwner(): return customxml.ResourceType.sharedowneraddressbook elif self.isShareeResource(): return customxml.ResourceType.sharedaddressbook else: return carddavxml.ResourceType.addressbook createAddressBookCollection = _CommonHomeChildCollectionMixin.createCollection @classmethod def componentsFromData(cls, data, format): try: return VCard.allFromString(data, format) except InvalidVCardDataError: return None @classmethod def resourceSuffix(cls): return ".vcf" @classmethod def xmlDataElementType(cls): return carddavxml.AddressData def canBeShared(self): return config.Sharing.Enabled and config.Sharing.AddressBooks.Enabled @inlineCallbacks def storeResourceData(self, newchild, component, returnChangedData=False): yield newchild.storeComponent(component) if returnChangedData and newchild._newStoreObject._componentChanged: result = (yield newchild.componentForUser()) returnValue(result) else: returnValue(None) def http_MOVE(self, request): """ Addressbooks may not be renamed. """ return FORBIDDEN @inlineCallbacks def makeChild(self, name): """ call super and provision group share """ abObjectResource = yield super(AddressBookCollectionResource, self).makeChild(name) # if abObjectResource.exists() and abObjectResource._newStoreObject.shareUID() is not None: # abObjectResource = yield self.parentResource().provisionShare(abObjectResource) returnValue(abObjectResource) @inlineCallbacks def bulkCreate(self, indexedComponents, request, return_changed, xmlresponses, format): """ bulk create allowing groups to contain member UIDs added during the same bulk create """ groupRetries = [] coaddedUIDs = set() for index, component in indexedComponents: try: if component is None: newchildURL = "" newchild = None changedComponent = None raise ValueError("Invalid component") # Create a new name if one was not provided name = hashlib.md5(str(index) + component.resourceUID() + str(time.time()) + request.path).hexdigest() + self.resourceSuffix() # Get a resource for the new item newchildURL = joinURL(request.path, name) newchild = (yield request.locateResource(newchildURL)) changedComponent = (yield self.storeResourceData(newchild, component, returnChangedData=return_changed)) except GroupWithUnsharedAddressNotAllowedError, e: # save off info and try again below missingUIDs = set(e.message) groupRetries.append((index, component, newchildURL, newchild, missingUIDs,)) except HTTPError, e: # Extract the pre-condition code = e.response.code if isinstance(e.response, ErrorResponse): error = e.response.error error = (error.namespace, error.name,) xmlresponses[index] = ( yield self.bulkCreateResponse(component, newchildURL, newchild, None, code, error, format) ) except Exception: xmlresponses[index] = ( yield self.bulkCreateResponse(component, newchildURL, newchild, None, BAD_REQUEST, None, format) ) else: if not return_changed: changedComponent = None coaddedUIDs |= set([component.resourceUID()]) xmlresponses[index] = ( yield self.bulkCreateResponse(component, newchildURL, newchild, changedComponent, None, None, format) ) if groupRetries: # get set of UIDs added coaddedUIDs |= set([groupRetry[1].resourceUID() for groupRetry in groupRetries]) # check each group add to see if it will succeed if coaddedUIDs are allowed while(True): for groupRetry in groupRetries: if bool(groupRetry[4] - coaddedUIDs): break else: break # give FORBIDDEN response index, component, newchildURL, newchild, missingUIDs = groupRetry xmlresponses[index] = ( yield self.bulkCreateResponse(component, newchildURL, newchild, None, FORBIDDEN, None, format) ) coaddedUIDs -= set([component.resourceUID()]) # group uid not added groupRetries.remove(groupRetry) # remove this retry for index, component, newchildURL, newchild, missingUIDs in groupRetries: # newchild._metadata -> newchild._options during store newchild._metadata["coaddedUIDs"] = coaddedUIDs # don't catch errors, abort the whole transaction changedComponent = yield self.storeResourceData(newchild, component, returnChangedData=return_changed) if not return_changed: changedComponent = None xmlresponses[index] = ( yield self.bulkCreateResponse(component, newchildURL, newchild, changedComponent, None, None, format) ) @inlineCallbacks def crudDelete(self, crudDeleteInfo, request, xmlresponses): """ Change handling of privileges """ if crudDeleteInfo: # Do privilege check on collection once try: yield self.authorize(request, (davxml.Unbind(),)) hasPrivilege = True except HTTPError, e: hasPrivilege = e for index, href, ifmatch in crudDeleteInfo: code = None error = None try: deleteResource = (yield request.locateResource(href)) if not deleteResource.exists(): raise HTTPError(NOT_FOUND) # Check if match etag = (yield deleteResource.etag()) if ifmatch and ifmatch != etag.generate(): raise HTTPError(PRECONDITION_FAILED) # =========================================================== # # If unshared is allowed deletes fails but crud adds works work! # if (hasPrivilege is not True and not ( # deleteResource.isShareeResource() or # deleteResource._newStoreObject.isGroupForSharedAddressBook() # ) # ): # raise hasPrivilege # =========================================================== # don't allow shared group deletion -> unshare if ( deleteResource.isShareeResource() or deleteResource._newStoreObject.isGroupForSharedAddressBook() ): raise HTTPError(FORBIDDEN) if hasPrivilege is not True: raise hasPrivilege yield deleteResource.storeRemove(request) except HTTPError, e: # Extract the pre-condition code = e.response.code if isinstance(e.response, ErrorResponse): error = e.response.error error = (error.namespace, error.name,) except Exception: code = BAD_REQUEST if code is None: xmlresponses[index] = davxml.StatusResponse( davxml.HRef.fromString(href), davxml.Status.fromResponseCode(OK), ) else: xmlresponses[index] = davxml.StatusResponse( davxml.HRef.fromString(href), davxml.Status.fromResponseCode(code), davxml.Error( WebDAVUnknownElement.withName(*error), ) if error else None, ) class AddressBookObjectResource(_CommonObjectResource): """ A resource wrapping a addressbook object. """ compareAttributes = ( "_newStoreObject", ) _componentFromStream = VCard.fromString def allowedTypes(self): """ Return a tuple of allowed MIME types for storing. """ return VCard.allowedTypes() @inlineCallbacks def vCardText(self): data = yield self.vCard() returnValue(str(data)) vCard = _CommonObjectResource.component StoreExceptionsErrors = { ObjectResourceNameNotAllowedError: (_CommonStoreExceptionHandler._storeExceptionStatus, None,), ObjectResourceNameAlreadyExistsError: (_CommonStoreExceptionHandler._storeExceptionStatus, None,), TooManyObjectResourcesError: (_CommonStoreExceptionHandler._storeExceptionError, customxml.MaxResources(),), ObjectResourceTooBigError: (_CommonStoreExceptionHandler._storeExceptionError, (carddav_namespace, "max-resource-size"),), InvalidObjectResourceError: (_CommonStoreExceptionHandler._storeExceptionError, (carddav_namespace, "valid-address-data"),), InvalidComponentForStoreError: (_CommonStoreExceptionHandler._storeExceptionError, (carddav_namespace, "valid-addressbook-object-resource"),), UIDExistsError: (_CommonStoreExceptionHandler._storeExceptionError, NovCardUIDConflict(),), InvalidUIDError: (_CommonStoreExceptionHandler._storeExceptionError, NovCardUIDConflict(),), InvalidPerUserDataMerge: (_CommonStoreExceptionHandler._storeExceptionError, (carddav_namespace, "valid-address-data"),), LockTimeout: (_CommonStoreExceptionHandler._storeExceptionUnavailable, "Lock timed out.",), FailedCrossPodRequestError: (_CommonStoreExceptionHandler._storeExceptionUnavailable, "Cross-pod request failed.",), } StoreMoveExceptionsErrors = { ObjectResourceNameNotAllowedError: (_CommonStoreExceptionHandler._storeExceptionStatus, None,), ObjectResourceNameAlreadyExistsError: (_CommonStoreExceptionHandler._storeExceptionStatus, None,), TooManyObjectResourcesError: (_CommonStoreExceptionHandler._storeExceptionError, customxml.MaxResources(),), InvalidResourceMove: (_CommonStoreExceptionHandler._storeExceptionError, (calendarserver_namespace, "valid-move"),), LockTimeout: (_CommonStoreExceptionHandler._storeExceptionUnavailable, "Lock timed out.",), } def resourceType(self): if self.isSharedByOwner(): return customxml.ResourceType.sharedownergroup elif self.isShareeResource(): return customxml.ResourceType.sharedgroup else: return super(AddressBookObjectResource, self).resourceType() @inlineCallbacks def storeRemove(self, request): """ Remove this address book object """ # Handle sharing if self.isShareeResource(): log.debug("Removing shared resource {s!r}", s=self) yield self.removeShareeResource(request) # Re-initialize to get stuff setup again now we have no object self._initializeWithObject(None, self._newStoreParent) returnValue(NO_CONTENT) elif self._newStoreObject.isGroupForSharedAddressBook(): abCollectionResource = (yield request.locateResource(parentForURL(request.uri))) returnValue((yield abCollectionResource.storeRemove(request))) elif self.isSharedByOwner(): yield self.downgradeFromShare(request) response = ( yield super(AddressBookObjectResource, self).storeRemove( request ) ) returnValue(response) def canBeShared(self): return ( config.Sharing.Enabled and config.Sharing.AddressBooks.Enabled and config.Sharing.AddressBooks.Groups.Enabled ) @inlineCallbacks def http_PUT(self, request): # Content-type check content_type = request.headers.getHeader("content-type") format = self.determineType(content_type) if format is None: log.error("MIME type {content_type} not allowed in vcard collection", content_type=content_type) raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, (carddav_namespace, "supported-address-data"), "Invalid MIME type for vcard collection", )) # Read the vcard from the stream try: vcarddata = (yield allDataFromStream(request.stream)) if not hasattr(request, "extendedLogItems"): request.extendedLogItems = {} request.extendedLogItems["cl"] = str(len(vcarddata)) if vcarddata else "0" # We must have some data at this point if vcarddata is None: # Use correct DAV:error response raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, (carddav_namespace, "valid-address-data"), description="No vcard data" )) try: component = VCard.fromString(vcarddata, format) except ValueError, e: log.error(str(e)) raise HTTPError(ErrorResponse( responsecode.FORBIDDEN, (carddav_namespace, "valid-address-data"), "Could not parse vCard", )) try: response = (yield self.storeComponent(component)) except ResourceDeletedError: # This is OK - it just means the server deleted the resource during the PUT. We make it look # like the PUT succeeded. response = responsecode.NO_CONTENT if self.exists() else responsecode.CREATED # Re-initialize to get stuff setup again now we have no object self._initializeWithObject(None, self._newStoreParent) returnValue(response) response = IResponse(response) # Must not set ETag in response if data changed if self._newStoreObject._componentChanged: def _removeEtag(request, response): response.headers.removeHeader('etag') return response _removeEtag.handleErrors = True request.addResponseFilter(_removeEtag, atEnd=True) # Look for Prefer header if request.headers.getHeader("accept") is None: request.headers.setHeader("accept", dict(((MimeType.fromString(format), 1.0,),))) response = yield self._processPrefer(request, response) returnValue(response) # Handle the various store errors except KindChangeNotAllowedError: raise HTTPError(StatusResponse( FORBIDDEN, "vCard kind may not be changed",) ) # Handle the various store errors except GroupWithUnsharedAddressNotAllowedError: raise HTTPError(StatusResponse( FORBIDDEN, "Sharee cannot add unshared group members",) ) except Exception as err: if isinstance(err, ValueError): log.error("Error while handling (vCard) PUT: {ex}", ex=err) raise HTTPError(StatusResponse(responsecode.BAD_REQUEST, str(err))) else: raise @inlineCallbacks def http_DELETE(self, request): """ Override http_DELETE handle shared group deletion without fromParent=[davxml.Unbind()] """ if ( self.isShareeResource() or self.exists() and self._newStoreObject.isGroupForSharedAddressBook() ): returnValue((yield self.storeRemove(request))) returnValue((yield super(AddressBookObjectResource, self).http_DELETE(request))) @inlineCallbacks def accessControlList(self, request, *a, **kw): """ Return WebDAV ACLs appropriate for the current user accessing the a vcard in a shared addressbook or shared group. Items in an "invite" share get read-only privileges. (It's not clear if that case ever occurs) "direct" shares are not supported. @param request: the request used to locate the owner resource. @type request: L{txweb2.iweb.IRequest} @param args: The arguments for L{txweb2.dav.idav.IDAVResource.accessControlList} @param kwargs: The keyword arguments for L{txweb2.dav.idav.IDAVResource.accessControlList}, plus keyword-only arguments. @return: the appropriate WebDAV ACL for the sharee @rtype: L{davxml.ACL} """ if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) if not self._parentResource.isShareeResource(): returnValue((yield super(AddressBookObjectResource, self).accessControlList(request, *a, **kw))) # Direct shares use underlying privileges of shared collection userprivs = [] userprivs.append(davxml.Privilege(davxml.Read())) userprivs.append(davxml.Privilege(davxml.ReadACL())) userprivs.append(davxml.Privilege(davxml.ReadCurrentUserPrivilegeSet())) if (yield self._newStoreObject.readWriteAccess()): userprivs.append(davxml.Privilege(davxml.Write())) else: userprivs.append(davxml.Privilege(davxml.WriteProperties())) sharee = yield self.principalForUID(self._newStoreObject.viewerHome().uid()) aces = ( # Inheritable specific access for the resource's associated principal. davxml.ACE( davxml.Principal(davxml.HRef(sharee.principalURL())), davxml.Grant(*userprivs), davxml.Protected(), TwistedACLInheritable(), ), ) # Give read access to config.ReadPrincipals aces += config.ReadACEs # Give all access to config.AdminPrincipals aces += config.AdminACEs returnValue(davxml.ACL(*aces)) class _NotificationChildHelper(object): """ Methods for things which are like notification objects. """ def _initializeWithNotifications(self, notifications, home): """ Initialize with a notification collection. @param notifications: the wrapped notification collection backend object. @type notifications: L{txdav.common.inotification.INotificationCollection} @param home: the home through which the given notification collection was accessed. @type home: L{txdav.icommonstore.ICommonHome} """ self._newStoreNotifications = notifications self._newStoreParentHome = home self._dead_properties = _NewStorePropertiesWrapper( self._newStoreNotifications.properties() ) def locateChild(self, request, segments): if segments[0] == '': return self, segments[1:] return self.getChild(segments[0]), segments[1:] def exists(self): # FIXME: tests return True @inlineCallbacks def makeChild(self, name): """ Create a L{NotificationObjectFile} or L{ProtoNotificationObjectFile} based on the name of a notification. """ newStoreObject = ( yield self._newStoreNotifications.notificationObjectWithName(name) ) similar = StoreNotificationObjectFile(newStoreObject, self) # FIXME: tests should be failing without this line. # Specifically, http_PUT won't be committing its transaction properly. self.propagateTransaction(similar) returnValue(similar) @inlineCallbacks def listChildren(self): """ @return: a sequence of the names of all known children of this resource. """ children = set(self.putChildren.keys()) children.update((yield self._newStoreNotifications.listNotificationObjects())) returnValue(children) class StoreNotificationCollectionResource(_NotificationChildHelper, NotificationCollectionResource): """ Wrapper around a L{txdav.caldav.icalendar.ICalendar}. """ def __init__(self, notifications, homeResource, home, *args, **kw): """ Create a CalendarCollectionResource from a L{txdav.caldav.icalendar.ICalendar} and the arguments required for L{CalDAVResource}. """ super(StoreNotificationCollectionResource, self).__init__(*args, **kw) self._initializeWithNotifications(notifications, home) self._parentResource = homeResource def name(self): return "notification" def url(self): return joinURL(self._parentResource.url(), self.name(), "/") @inlineCallbacks def listChildren(self): l = [] for notification in (yield self._newStoreNotifications.notificationObjects()): l.append(notification.name()) returnValue(l) def isCollection(self): return True def getInternalSyncToken(self): return self._newStoreNotifications.syncToken() @inlineCallbacks def _indexWhatChanged(self, revision, depth): # The newstore implementation supports this directly returnValue( (yield self._newStoreNotifications.resourceNamesSinceToken(revision)) ) def deleteNotification(self, request, record): return maybeDeferred( self._newStoreNotifications.removeNotificationObjectWithName, record.name ) class StoreNotificationObjectFile(_NewStoreFileMetaDataHelper, NotificationResource): """ A resource wrapping a calendar object. """ def __init__(self, notificationObject, *args, **kw): """ Construct a L{CalendarObjectResource} from an L{ICalendarObject}. @param calendarObject: The storage for the calendar object. @type calendarObject: L{txdav.caldav.icalendarstore.ICalendarObject} """ super(StoreNotificationObjectFile, self).__init__(*args, **kw) self._initializeWithObject(notificationObject) def _initializeWithObject(self, notificationObject): self._newStoreObject = notificationObject self._dead_properties = NonePropertyStore(self) def liveProperties(self): props = super(StoreNotificationObjectFile, self).liveProperties() props += (customxml.NotificationType.qname(),) return props @inlineCallbacks def readProperty(self, prop, request): if type(prop) is tuple: qname = prop else: qname = prop.qname() if qname == customxml.NotificationType.qname(): jsontype = self._newStoreObject.notificationType() # FIXME: notificationType( ) does not always return json; it can # currently return a utf-8 encoded str of XML if isinstance(jsontype, str): returnValue( davxml.WebDAVDocument.fromString(jsontype).root_element ) if jsontype["notification-type"] == "invite-notification": typeAttr = {"shared-type": jsontype["shared-type"]} xmltype = customxml.InviteNotification(**typeAttr) elif jsontype["notification-type"] == "invite-reply": xmltype = customxml.InviteReply() else: raise HTTPError(responsecode.INTERNAL_SERVER_ERROR) returnValue(customxml.NotificationType(xmltype)) returnValue((yield super(StoreNotificationObjectFile, self).readProperty(prop, request))) def isCollection(self): return False def quotaSize(self, request): return succeed(self._newStoreObject.size()) @inlineCallbacks def text(self, ignored=None): assert ignored is None, "This is a notification object, not a notification" jsondata = (yield self._newStoreObject.notificationData()) # FIXME: notificationData( ) does not always return json; it can # currently return a utf-8 encoded str of XML if isinstance(jsondata, str): returnValue(jsondata) if jsondata["notification-type"] == "invite-notification": ownerPrincipal = yield self.principalForUID(jsondata["owner"]) if ownerPrincipal is None: ownerCN = "" ownerCollectionURL = "" owner = "urn:x-uid:" + jsondata["owner"] else: ownerCN = ownerPrincipal.displayName() ownerHomeURL = ownerPrincipal.calendarHomeURLs()[0] if jsondata["shared-type"] == "calendar" else ownerPrincipal.addressBookHomeURLs()[0] ownerCollectionURL = urljoin(ownerHomeURL, jsondata["ownerName"]) # FIXME: use urn:uuid always? if jsondata["shared-type"] == "calendar": owner = ownerPrincipal.principalURL() else: owner = "urn:x-uid:" + ownerPrincipal.principalUID() if "supported-components" in jsondata: comps = jsondata["supported-components"] if comps: comps = comps.split(",") else: comps = ical.allowedStoreComponents supported = caldavxml.SupportedCalendarComponentSet( *[caldavxml.CalendarComponent(name=item) for item in comps] ) else: supported = None typeAttr = {"shared-type": jsondata["shared-type"]} xmldata = customxml.Notification( customxml.DTStamp.fromString(jsondata["dtstamp"]), customxml.InviteNotification( customxml.UID.fromString(jsondata["uid"]), element.HRef.fromString("urn:x-uid:" + jsondata["sharee"]), invitationBindStatusToXMLMap[jsondata["status"]](), customxml.InviteAccess(invitationBindModeToXMLMap[jsondata["access"]]()), customxml.HostURL( element.HRef.fromString(ownerCollectionURL), ), customxml.Organizer( element.HRef.fromString(owner), customxml.CommonName.fromString(ownerCN), ), customxml.InviteSummary.fromString(jsondata["summary"]), supported, **typeAttr ), ) elif jsondata["notification-type"] == "invite-reply": ownerPrincipal = yield self.principalForUID(jsondata["owner"]) ownerHomeURL = ownerPrincipal.calendarHomeURLs()[0] if jsondata["shared-type"] == "calendar" else ownerPrincipal.addressBookHomeURLs()[0] shareePrincipal = yield self.principalForUID(jsondata["sharee"]) # FIXME: use urn:x-uid always? if shareePrincipal is not None: if jsondata["shared-type"] == "calendar": # Prefer mailto:, otherwise use principal URL for cua in shareePrincipal.calendarUserAddresses(): if cua.startswith("mailto:"): break else: cua = shareePrincipal.principalURL() else: cua = "urn:x-uid:" + shareePrincipal.principalUID() commonName = shareePrincipal.displayName() else: cua = "urn:x-uid:" + jsondata["sharee"] commonName = "" typeAttr = {"shared-type": jsondata["shared-type"]} xmldata = customxml.Notification( customxml.DTStamp.fromString(jsondata["dtstamp"]), customxml.InviteReply( element.HRef.fromString(cua), invitationBindStatusToXMLMap[jsondata["status"]](), customxml.HostURL( element.HRef.fromString(urljoin(ownerHomeURL, jsondata["ownerName"])), ), customxml.InReplyTo.fromString(jsondata["in-reply-to"]), customxml.InviteSummary.fromString(jsondata["summary"]) if jsondata["summary"] else None, customxml.CommonName.fromString(commonName) if commonName else None, **typeAttr ), ) else: raise HTTPError(responsecode.INTERNAL_SERVER_ERROR) returnValue(xmldata.toxml()) @requiresPermissions(davxml.Read()) @inlineCallbacks def http_GET(self, request): if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) try: returnValue( Response(OK, {"content-type": self.contentType()}, MemoryStream((yield self.text()))) ) except ConcurrentModification: raise HTTPError(NOT_FOUND) @requiresPermissions(fromParent=[davxml.Unbind()]) def http_DELETE(self, request): """ Override http_DELETE to validate 'depth' header. """ if not self.exists(): log.debug("Resource not found: {s!r}", s=self) raise HTTPError(NOT_FOUND) return self.storeRemove(request) def http_PROPPATCH(self, request): """ No dead properties allowed on notification objects. """ return FORBIDDEN @inlineCallbacks def storeRemove(self, request): """ Remove this notification object. """ try: storeNotifications = self._newStoreObject.notificationCollection() # Do delete # FIXME: public attribute please yield storeNotifications.removeNotificationObjectWithName( self._newStoreObject.name() ) self._initializeWithObject(None) except MemcacheLockTimeoutError: raise HTTPError(StatusResponse(CONFLICT, "Resource: %s currently in use on the server." % (request.uri,))) except NoSuchObjectResourceError: raise HTTPError(NOT_FOUND) returnValue(NO_CONTENT)