# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*- # # Copyright 2002 Ben Escoto # Copyright 2007 Kenneth Loafman # # This file is part of duplicity. # # Duplicity is free software; you can redistribute it and/or modify it # under the terms of the GNU General Public License as published by the # Free Software Foundation; either version 2 of the License, or (at your # option) any later version. # # Duplicity is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # General Public License for more details. # # You should have received a copy of the GNU General Public License # along with duplicity; if not, write to the Free Software Foundation, # Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA import base64 import httplib import re import urllib import urllib2 import xml.dom.minidom import duplicity.backend from duplicity import globals from duplicity import log from duplicity.errors import * address@hidden from duplicity import urlparse_2_5 as urlparser from duplicity.backend import retry_fatal class CustomMethodRequest(urllib2.Request): """ This request subclass allows explicit specification of the HTTP request method. Basic urllib2.Request class chooses GET or POST depending on self.has_data() """ def __init__(self, method, *args, **kwargs): self.method = method urllib2.Request.__init__(self, *args, **kwargs) def get_method(self): return self.method class WebDAVBackend(duplicity.backend.Backend): """Backend for accessing a WebDAV repository. webdav backend contributed in 2006 by Jesper Zedlitz """ """ for better compatibility we send an empty listbody as described in http://www.ietf.org/rfc/rfc4918.txt " A client may choose not to submit a request body. An empty PROPFIND request body MUST be treated as if it were an 'allprop' request. " it was retired because e.g. box.net didn't support """ listbody ="" """Connect to remote store using WebDAV Protocol""" def __init__(self, parsed_url): duplicity.backend.Backend.__init__(self, parsed_url) self.headers = {'Connection': 'keep-alive'} self.parsed_url = parsed_url self.digest_challenge = None self.digest_auth_handler = None if parsed_url.path: foldpath = re.compile('/+') self.directory = foldpath.sub('/', parsed_url.path + '/' ) else: self.directory = '/' log.Info("Using WebDAV host %s" % (parsed_url.hostname,)) log.Info("Using WebDAV port %s" % (parsed_url.port,)) log.Info("Using WebDAV directory %s" % (self.directory,)) log.Info("Using WebDAV protocol %s" % (globals.webdav_proto,)) if parsed_url.scheme == 'webdav': self.conn = httplib.HTTPConnection(parsed_url.hostname, parsed_url.port) elif parsed_url.scheme == 'webdavs': self.conn = httplib.HTTPSConnection(parsed_url.hostname, parsed_url.port) else: raise BackendException("Unknown URI scheme: %s" % (parsed_url.scheme)) def _getText(self,nodelist): rc = "" for node in nodelist: if node.nodeType == node.TEXT_NODE: rc = rc + node.data return rc def close(self): self.conn.close() def request(self, method, path, data=None): """ Wraps the connection.request method to retry once if authentication is required """ quoted_path = urllib.quote(path) if self.digest_challenge is not None: self.headers['Authorization'] = self.get_digest_authorization(path) log.Debug("WebDAV %s %s request with headers: %s " % (method,quoted_path,self.headers)) self.conn.request(method, quoted_path, data, self.headers) response = self.conn.getresponse() log.Debug("WebDAV response status %s with reason '%s'." % (response.status,response.reason)) if response.status == 401: response.close() self.headers['Authorization'] = self.get_authorization(response, quoted_path) log.Debug("WebDAV retry due to auth timeout") log.Debug("WebDAV %s %s request2 with headers: %s " % (method,quoted_path,self.headers)) self.conn.request(method, quoted_path, data, self.headers) response = self.conn.getresponse() log.Debug("WebDAV response2 status %s with reason '%s'." % (response.status,response.reason)) return response def get_authorization(self, response, path): """ Fetches the auth header based on the requested method (basic or digest) """ try: auth_hdr = response.getheader('www-authenticate', '') token, challenge = auth_hdr.split(' ', 1) except ValueError: return None if token.lower() == 'basic': return self.get_basic_authorization() else: self.digest_challenge = self.parse_digest_challenge(challenge) return self.get_digest_authorization(path) def parse_digest_challenge(self, challenge_string): return urllib2.parse_keqv_list(urllib2.parse_http_list(challenge_string)) def get_basic_authorization(self): """ Returns the basic auth header """ auth_string = '%s:%s' % (self.parsed_url.username, self.get_password()) return 'Basic %s' % base64.encodestring(auth_string).strip() def get_digest_authorization(self, path): """ Returns the digest auth header """ u = self.parsed_url if self.digest_auth_handler is None: pw_manager = urllib2.HTTPPasswordMgrWithDefaultRealm() pw_manager.add_password(None, self.conn.host, u.username, self.get_password()) self.digest_auth_handler = urllib2.HTTPDigestAuthHandler(pw_manager) # building a dummy request that gets never sent, # needed for call to auth_handler.get_authorization scheme = u.scheme == 'webdavs' and 'https' or 'http' hostname = u.port and "%s:%s" % (u.hostname, u.port) or u.hostname dummy_url = "%s://%s%s" % (scheme, hostname, path) dummy_req = CustomMethodRequest(self.conn._method, dummy_url) auth_string = self.digest_auth_handler.get_authorization(dummy_req, self.digest_challenge) return 'Digest %s' % auth_string @retry_fatal def list(self): """List files in directory""" log.Info("Listing directory %s on WebDAV server" % (self.directory,)) try: self.headers['Depth'] = "1" response = self.request("PROPFIND", self.directory, self.listbody) del self.headers['Depth'] # if the target collection does not exist, create it. if response.status == 404: response.close() log.Info("Directory '%s' being created." % self.directory) response = self.request("MKCOL", self.directory) log.Info("WebDAV MKCOL status: %s %s" % (response.status, response.reason)) response.close() # just created folder is so return empty return [] elif response.status == 207: document = response.read() response.close() else: status = response.status reason = response.reason response.close() raise BackendException("Bad status code %s reason %s." % (status,reason)) log.Debug("%s" % (document,)) dom = xml.dom.minidom.parseString(document) result = [] for href in dom.getElementsByTagName('d:href') + dom.getElementsByTagName('D:href'): filename = self.__taste_href(href) if filename: result.append(filename) return result except Exception, cause: e = BackendException("Listing directory %s on WebDAV server failed. %s" % (self.directory,cause)) raise e def __taste_href(self, href): """ Internal helper to taste the given href node and, if it is a duplicity file, collect it as a result file. @return: A matching filename, or None if the href did not match. """ raw_filename = self._getText(href.childNodes).strip() parsed_url = urlparser.urlparse(urllib.unquote(raw_filename)) filename = parsed_url.path log.Debug("webdav path decoding and translation: " "%s -> %s" % (raw_filename, filename)) # at least one WebDAV server returns files in the form # of full URL:s. this may or may not be # according to the standard, but regardless we # feel we want to bail out if the hostname # does not match until someone has looked into # what the WebDAV protocol mandages. if not parsed_url.hostname is None \ and not (parsed_url.hostname == self.parsed_url.hostname): m = "Received filename was in the form of a "\ "full url, but the hostname (%s) did "\ "not match that of the webdav backend "\ "url (%s) - aborting as a conservative "\ "safety measure. If this happens to you, "\ "please report the problem"\ "" % (parsed_url.hostname, self.parsed_url.hostname) raise BackendException(m) if filename.startswith(self.directory): filename = filename.replace(self.directory,'',1) return filename else: return None @retry_fatal def get(self, remote_filename, local_path): """Get remote filename, saving it to local_path""" url = self.directory + remote_filename log.Info("Retrieving %s from WebDAV server" % (url ,)) try: target_file = local_path.open("wb") response = self.request("GET", url) if response.status == 200: target_file.write(response.read()) assert not target_file.close() local_path.setdata() response.close() else: status = response.status reason = response.reason response.close() raise BackendException("Bad status code %s reason %s." % (status,reason)) except Exception, cause: e = BackendException("Getting %s from WebDAV server failed. %s" % (url,cause)) raise e @retry_fatal def put(self, source_path, remote_filename = None): """Transfer source_path to remote_filename""" if not remote_filename: remote_filename = source_path.get_filename() url = self.directory + remote_filename log.Info("Saving %s on WebDAV server" % (url ,)) try: source_file = source_path.open("rb") response = self.request("PUT", url, source_file.read()) if response.status == 201: response.read() response.close() else: status = response.status reason = response.reason response.close() raise BackendException("Bad status code %s reason %s." % (status,reason)) except Exception, cause: e = BackendException("Putting %s on WebDAV server failed. %s" % (url,cause)) raise e @retry_fatal def delete(self, filename_list): """Delete files in filename_list""" for filename in filename_list: url = self.directory + filename log.Info("Deleting %s from WebDAV server" % (url ,)) try: response = self.request("DELETE", url) if response.status == 204: response.read() response.close() else: status = response.status reason = response.reason response.close() raise BackendException("Bad status code %s reason %s." % (status,reason)) except Exception, cause: e = BackendException("Deleting %s on WebDAV server failed. %s" % (url,cause)) raise e duplicity.backend.register_backend("webdav", WebDAVBackend) duplicity.backend.register_backend("webdavs", WebDAVBackend)