1 # -*- encoding: utf-8 -*-
3 # Copyright P. Christeas <p_christ@hol.gr> 2008,2009
4 # Copyright OpenERP SA. (http://www.openerp.com) 2010
7 # WARNING: This program as such is intended to be used by professional
8 # programmers who take the whole responsability of assessing all potential
9 # consequences resulting from its eventual inadequacies and bugs
10 # End users who are looking for a ready-to-use solution with commercial
11 # garantees and support are strongly adviced to contract a Free Software
14 # This program is Free Software; you can redistribute it and/or
15 # modify it under the terms of the GNU General Public License
16 # as published by the Free Software Foundation; either version 2
17 # of the License, or (at your option) any later version.
19 # This program is distributed in the hope that it will be useful,
20 # but WITHOUT ANY WARRANTY; without even the implied warranty of
21 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22 # GNU General Public License for more details.
24 # You should have received a copy of the GNU General Public License
25 # along with this program; if not, write to the Free Software
26 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
27 ###############################################################################
29 """ A trivial HTTP/WebDAV client, used for testing the server
31 # code taken from the 'http-client.py' script:
32 # http://git.hellug.gr/?p=xrg/openerp;a=history;f=tests/http-client.py;hb=refs/heads/xrg-60
36 import xml.dom.minidom
40 from openerp.tools import config
41 from xmlrpclib import Transport, ProtocolError
44 from openerp import SUPERUSER_ID
46 _logger = logging.getLogger(__name__)
48 class HTTP11(httplib.HTTP):
50 _http_vsn_str = 'HTTP/1.1'
52 class PersistentTransport(Transport):
53 """Handles an HTTP transaction to an XML-RPC server, persistently."""
55 def __init__(self, use_datetime=0):
56 self._use_datetime = use_datetime
58 log.debug("Using persistent transport")
60 def make_connection(self, host):
61 # create a HTTP connection object from a host descriptor
62 if not self._http.has_key(host):
63 host, extra_headers, x509 = self.get_host_info(host)
64 self._http[host] = HTTP11(host)
65 _logger.debug("New connection to %s", host)
66 return self._http[host]
68 def get_host_info(self, host):
69 host, extra_headers, x509 = Transport.get_host_info(self,host)
70 if extra_headers == None:
73 extra_headers.append( ( 'Connection', 'keep-alive' ))
75 return host, extra_headers, x509
77 def _parse_response(self, file, sock, response):
78 """ read response from input file/socket, and parse it
79 We are persistent, so it is important to only parse
80 the right amount of input
83 p, u = self.getparser()
85 if response.msg.get('content-encoding') == 'gzip':
86 gzdata = StringIO.StringIO()
87 while not response.isclosed():
88 rdata = response.read(1024)
93 rbuffer = gzip.GzipFile(mode='rb', fileobj=gzdata)
95 respdata = rbuffer.read()
100 while not response.isclosed():
101 rdata = response.read(1024)
111 def request(self, host, handler, request_body, verbose=0):
112 # issue XML-RPC request
114 h = self.make_connection(host)
118 self.send_request(h, handler, request_body)
119 self.send_host(h, host)
120 self.send_user_agent(h)
121 self.send_content(h, request_body)
123 resp = h._conn.getresponse()
124 # TODO: except BadStatusLine, e:
126 errcode, errmsg, headers = resp.status, resp.reason, resp.msg
136 self.verbose = verbose
140 except AttributeError:
143 return self._parse_response(h.getfile(), sock, resp)
145 class CompressedTransport(PersistentTransport):
146 def send_content(self, connection, request_body):
147 connection.putheader("Content-Type", "text/xml")
149 if len(request_body) > 512 or True:
150 buffer = StringIO.StringIO()
151 output = gzip.GzipFile(mode='wb', fileobj=buffer)
152 output.write(request_body)
155 request_body = buffer.getvalue()
156 connection.putheader('Content-Encoding', 'gzip')
158 connection.putheader("Content-Length", str(len(request_body)))
159 connection.putheader("Accept-Encoding",'gzip')
160 connection.endheaders()
162 connection.send(request_body)
164 def send_request(self, connection, handler, request_body):
165 connection.putrequest("POST", handler, skip_accept_encoding=1)
167 class SafePersistentTransport(PersistentTransport):
168 def make_connection(self, host):
169 # create a HTTP connection object from a host descriptor
170 if not self._http.has_key(host):
171 host, extra_headers, x509 = self.get_host_info(host)
172 self._http[host] = httplib.HTTPS(host, None, **(x509 or {}))
173 _logger.debug("New connection to %s", host)
174 return self._http[host]
176 class AuthClient(object):
177 def getAuth(self, atype, realm):
178 raise NotImplementedError("Cannot authenticate for %s" % atype)
180 def resolveFailedRealm(self, realm):
181 """ Called when, using a known auth type, the realm is not in cache
183 raise NotImplementedError("Cannot authenticate for realm %s" % realm)
185 class BasicAuthClient(AuthClient):
187 self._realm_dict = {}
189 def getAuth(self, atype, realm):
190 if atype != 'Basic' :
191 return super(BasicAuthClient,self).getAuth(atype, realm)
193 if not self._realm_dict.has_key(realm):
194 _logger.debug("realm dict: %r", self._realm_dict)
195 _logger.debug("missing key: \"%s\"" % realm)
196 self.resolveFailedRealm(realm)
197 return 'Basic '+ self._realm_dict[realm]
199 def addLogin(self, realm, username, passwd):
200 """ Add some known username/password for a specific login.
201 This function should be called once, for each realm
202 that we want to authenticate against
205 auths = base64.encodestring(username + ':' + passwd)
206 if auths[-1] == "\n":
208 self._realm_dict[realm] = auths
210 class addAuthTransport:
211 """ Intermediate class that authentication algorithm to http transport
214 def setAuthClient(self, authobj):
215 """ Set the authentication client object.
216 This method must be called before any request is issued, that
217 would require http authentication
219 assert isinstance(authobj, AuthClient)
220 self._auth_client = authobj
223 def request(self, host, handler, request_body, verbose=0):
224 # issue XML-RPC request
226 h = self.make_connection(host)
235 self.send_request(h, handler, request_body)
236 self.send_host(h, host)
237 self.send_user_agent(h)
239 # This line will bork if self.setAuthClient has not
240 # been issued. That is a programming error, fix your code!
241 auths = self._auth_client.getAuth(atype, realm)
242 _logger.debug("sending authorization: %s", auths)
243 h.putheader('Authorization', auths)
244 self.send_content(h, request_body)
246 resp = h._conn.getresponse()
247 # except BadStatusLine, e:
250 if resp.status == 401:
251 if 'www-authenticate' in resp.msg:
252 (atype,realm) = resp.msg.getheader('www-authenticate').split(' ',1)
255 log.warning("Why have data on a 401 auth. message?")
256 if realm.startswith('realm="') and realm.endswith('"'):
258 _logger.debug("Resp: %r %r", resp.version,resp.isclosed(), resp.will_close)
259 _logger.debug("Want to do auth %s for realm %s", atype, realm)
261 raise ProtocolError(host+handler, 403,
262 "Unknown authentication method: %s" % atype, resp.msg)
263 continue # with the outer while loop
265 raise ProtocolError(host+handler, 403,
266 'Server-incomplete authentication', resp.msg)
268 if resp.status != 200:
269 raise ProtocolError( host + handler,
270 resp.status, resp.reason, resp.msg )
272 self.verbose = verbose
276 except AttributeError:
279 return self._parse_response(h.getfile(), sock, resp)
281 raise ProtocolError(host+handler, 403, "No authentication.",'')
283 class PersistentAuthTransport(addAuthTransport,PersistentTransport):
286 class PersistentAuthCTransport(addAuthTransport,CompressedTransport):
289 class HTTPSConnection(httplib.HTTPSConnection):
292 "Connect to a host on a given (SSL) port. check the certificate"
295 if HTTPSConnection.certs_file:
296 ca_certs = HTTPSConnection.certs_file
297 cert_reqs = ssl.CERT_REQUIRED
300 cert_reqs = ssl.CERT_NONE
301 sock = socket.create_connection((self.host, self.port), self.timeout)
302 self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
307 def getpeercert(self):
311 cert = self.sock.getpeercert()
313 cert = ssl.get_server_certificate((self.host,self.port),
314 ssl_version=ssl.PROTOCOL_SSLv23 )
315 lf = (len(ssl.PEM_FOOTER)+1)
316 if cert[0-lf] != '\n':
317 cert = cert[:0-lf]+'\n'+cert[0-lf:]
318 _logger.debug("len-footer: %s cert: %r", lf, cert[0-lf])
323 class DAVClient(object):
324 """An instance of a WebDAV client, connected to the OpenERP server
327 def __init__(self, user=None, passwd=None, dbg=0, use_ssl=False, useragent=False, timeout=None):
329 self.host = config.get_misc('httpsd', 'interface', False)
330 self.port = config.get_misc('httpsd', 'port', 8071)
332 self.host = config.get('xmlrpcs_interface')
333 self.port = config.get('xmlrpcs_port')
335 self.host = config.get_misc('httpd', 'interface')
336 self.port = config.get_misc('httpd', 'port', 8069)
338 self.host = config.get('xmlrpc_interface')
339 self.port = config.get('xmlrpc_port') or self.port
340 if self.host == '0.0.0.0' or not self.host:
341 self.host = '127.0.0.1'
342 self.port = int(self.port)
343 if not config.get_misc('webdav','enable',True):
344 raise Exception("WebDAV is disabled, cannot continue")
345 self.davpath = '/' + config.get_misc('webdav','vdir','webdav')
349 self.timeout = timeout or 5.0 # seconds, tests need to respond pretty fast!
352 self.set_useragent(useragent)
354 def get_creds(self, obj, cr, uid):
355 """Read back the user credentials from cr, uid
357 @param obj is any orm object, in order to use its pool
358 @param uid is the numeric id, which we will try to reverse resolve
360 note: this is a hackish way to get the credentials. It is expected
361 to break if "base_crypt" is used.
363 ruob = obj.pool.get('res.users')
364 res = ruob.read(cr, SUPERUSER_ID, [uid,], ['login', 'password'])
365 assert res, "uid %s not found" % uid
366 self.user = res[0]['login']
367 self.passwd = res[0]['password']
368 if self.passwd.startswith('$1$'):
369 # md5 by base crypt. We cannot decode, wild guess
370 # that passwd = login
371 self.passwd = self.user
374 def set_useragent(self, uastr):
375 """ Set the user-agent header to something meaningful.
376 Some shorthand names will be replaced by stock strings.
378 if uastr in ('KDE4', 'Korganizer'):
379 self.hdrs['User-Agent'] = "Mozilla/5.0 (compatible; Konqueror/4.4; Linux) KHTML/4.4.3 (like Gecko)"
380 elif uastr == 'iPhone3':
381 self.hdrs['User-Agent'] = "DAVKit/5.0 (765); iCalendar/5.0 (79); iPhone/4.1 8B117"
382 elif uastr == "MacOS":
383 self.hdrs['User-Agent'] = "WebDAVFS/1.8 (01808000) Darwin/9.8.0 (i386)"
385 self.hdrs['User-Agent'] = uastr
387 def _http_request(self, path, method='GET', hdrs=None, body=None):
392 hdrs.update(self.hdrs)
393 _logger.debug("Getting %s http://%s:%d/%s", method, self.host, self.port, path)
394 conn = httplib.HTTPConnection(self.host, port=self.port, timeout=self.timeout)
395 conn.set_debuglevel(dbg)
398 if not hdrs.has_key('Connection'):
399 hdrs['Connection']= 'keep-alive'
400 conn.request(method, path, body, hdrs )
402 r1 = conn.getresponse()
403 except httplib.BadStatusLine, bsl:
404 log.warning("Bad status line: %s", bsl.line)
405 raise Exception('Bad status line.')
406 if r1.status == 401: # and r1.headers:
407 if 'www-authenticate' in r1.msg:
408 (atype,realm) = r1.msg.getheader('www-authenticate').split(' ',1)
411 raise Exception('Must auth, have no user/pass!')
412 _logger.debug("Ver: %s, closed: %s, will close: %s", r1.version,r1.isclosed(), r1.will_close)
413 _logger.debug("Want to do auth %s for realm %s", atype, realm)
414 if atype == 'Basic' :
415 auths = base64.encodestring(self.user + ':' + self.passwd)
416 if auths[-1] == "\n":
418 hdrs['Authorization']= 'Basic '+ auths
420 conn.request(method, path, body, hdrs )
421 r1 = conn.getresponse()
423 raise Exception("Unknown auth type %s" %atype)
425 _logger.warning("Got 401, cannot auth")
426 raise Exception('No auth')
428 _logger.debug("Reponse: %s %s",r1.status, r1.reason)
431 _logger.debug("Body:\n%s\nEnd of body", data1)
433 ctype = r1.msg.getheader('content-type')
434 if ctype and ';' in ctype:
435 ctype, encoding = ctype.split(';',1)
436 if ctype == 'text/xml':
437 doc = xml.dom.minidom.parseString(data1)
438 _logger.debug("XML Body:\n %s", doc.toprettyxml(indent="\t"))
440 _logger.warning("Cannot print XML.", exc_info=True)
443 return r1.status, r1.msg, data1
445 def _assert_headers(self, expect, msg):
446 """ Assert that the headers in msg contain the expect values
448 for k, v in expect.items():
449 hval = msg.getheader(k)
451 raise AssertionError("Header %s not defined in http response" % k)
452 if isinstance(v, (list, tuple)):
454 hits = map(str.strip, hval.split(delim))
460 raise AssertionError("HTTP header \"%s\" is missing: %s" %(k, ', '.join(mvits)))
462 if hval.strip() != v.strip():
463 raise AssertionError("HTTP header \"%s: %s\"" % (k, hval))
465 def gd_options(self, path='*', expect=None):
466 """ Test the http options functionality
467 If a dictionary is defined in expect, those options are
471 path = self.davpath + path
472 hdrs = { 'Content-Length': 0
474 s, m, d = self._http_request(path, method='OPTIONS', hdrs=hdrs)
475 assert s == 200, "Status: %r" % s
476 assert 'OPTIONS' in m.getheader('Allow')
477 _logger.debug('Options: %r', m.getheader('Allow'))
480 self._assert_headers(expect, m)
482 def _parse_prop_response(self, data):
483 """ Parse a propfind/propname response
487 for node in node.childNodes:
488 if node.nodeType == node.TEXT_NODE:
492 def getElements(node, namespaces=None, strict=False):
493 for cnod in node.childNodes:
494 if cnod.nodeType != node.ELEMENT_NODE:
496 _logger.debug("Found %r inside <%s>", cnod, node.tagName)
498 if namespaces and (cnod.namespaceURI not in namespaces):
499 _logger.debug("Ignoring <%s> in <%s>", cnod.tagName, node.localName)
503 nod = xml.dom.minidom.parseString(data)
504 nod_r = nod.documentElement
506 assert nod_r.localName == 'multistatus', nod_r.tagName
507 for resp in nod_r.getElementsByTagNameNS('DAV:', 'response'):
511 for cno in getElements(resp, namespaces=['DAV:',]):
512 if cno.localName == 'href':
513 assert href is None, "Second href in same response"
515 elif cno.localName == 'propstat':
516 for pno in getElements(cno, namespaces=['DAV:',]):
518 if pno.localName == 'prop':
519 for prop in getElements(pno):
521 tval = getText(prop).strip()
522 val = tval or (True, rstatus or status)
523 if prop.namespaceURI == 'DAV:' and prop.localName == 'resourcetype':
525 for rte in getElements(prop, namespaces=['DAV:',]):
526 # Note: we only look at DAV:... elements, we
527 # actually expect only one DAV:collection child
529 res_nss.setdefault(prop.namespaceURI,{})[key] = val
530 elif pno.localName == 'status':
532 htver, sta, msg = rstr.split(' ', 3)
533 assert htver == 'HTTP/1.1'
536 _logger.debug("What is <%s> inside a <propstat>?", pno.tagName)
539 _logger.debug("Unknown node: %s", cno.tagName)
541 res.setdefault(href,[]).append((status, res_nss))
545 def gd_propfind(self, path, props=None, depth=0):
547 propstr = '<allprop/>'
553 if isinstance(p, tuple):
555 if ns is None or ns == 'DAV:':
556 propstr += '<%s/>' % p
558 propstr += '<ns%d:%s xmlns:ns%d="%s" />' %(nscount, p, nscount, ns)
562 body="""<?xml version="1.0" encoding="utf-8"?>
563 <propfind xmlns="DAV:">%s</propfind>""" % propstr
564 hdrs = { 'Content-Type': 'text/xml; charset=utf-8',
565 'Accept': 'text/xml',
569 s, m, d = self._http_request(self.davpath + path, method='PROPFIND',
570 hdrs=hdrs, body=body)
571 assert s == 207, "Bad status: %s" % s
572 ctype = m.getheader('Content-Type').split(';',1)[0]
573 assert ctype == 'text/xml', m.getheader('Content-Type')
574 res = self._parse_prop_response(d)
577 res = res.values()[0]
583 def gd_propname(self, path, depth=0):
584 body="""<?xml version="1.0" encoding="utf-8"?>
585 <propfind xmlns="DAV:"><propname/></propfind>"""
586 hdrs = { 'Content-Type': 'text/xml; charset=utf-8',
587 'Accept': 'text/xml',
590 s, m, d = self._http_request(self.davpath + path, method='PROPFIND',
591 hdrs=hdrs, body=body)
592 assert s == 207, "Bad status: %s" % s
593 ctype = m.getheader('Content-Type').split(';',1)[0]
594 assert ctype == 'text/xml', m.getheader('Content-Type')
595 res = self._parse_prop_response(d)
598 res = res.values()[0]
603 def gd_getetag(self, path, depth=0):
604 return self.gd_propfind(path, props=['getetag',], depth=depth)
606 def gd_lsl(self, path):
607 """ Return a list of 'ls -l' kind of data for a folder
609 This is based on propfind.
612 lspairs = [ ('name', 'displayname', 'n/a'), ('size', 'getcontentlength', '0'),
613 ('type', 'resourcetype', '----------'), ('uid', 'owner', 'nobody'),
614 ('gid', 'group', 'nogroup'), ('mtime', 'getlastmodified', 'n/a'),
615 ('mime', 'getcontenttype', 'application/data'), ]
617 propnames = [ l[1] for l in lspairs]
618 propres = self.gd_propfind(path, props=propnames, depth=1)
621 for href, pr in propres.items():
624 davprops = nsdic['DAV:']
627 if lsp[1] in davprops:
628 if lsp[1] == 'resourcetype':
629 if davprops[lsp[1]] == 'collection':
630 lsline[lsp[0]] = 'dr-xr-x---'
632 lsline[lsp[0]] = '-r-xr-x---'
634 lsline[lsp[0]] = davprops[lsp[1]]
635 elif st in (404, 403):
637 if lsp[1] in davprops:
638 lsline[lsp[0]] = lsp[2]
640 _logger.debug("Strange status: %s", st)
646 def gd_get(self, path, crange=None, mime=None, compare=None):
647 """ HTTP GET for path, supporting Partial ranges
649 hdrs = { 'Accept': mime or '*/*', }
651 if isinstance(crange, tuple):
653 if not isinstance(crange, list):
654 raise TypeError("Range must be a tuple or list of tuples.")
657 rs.append('%d-%d' % r)
658 hdrs['Range'] = 'bytes='+ (','.join(rs))
659 s, m, d = self._http_request(self.davpath + path, method='GET', hdrs=hdrs)
660 assert s in (200, 206), "Bad status: %s" % s
661 ctype = m.getheader('Content-Type')
662 if ctype and ';' in ctype:
663 ctype = ctype.split(';',1)[0]
665 assert ctype == mime, m.getheader('Content-Type')
667 rrh = m.getheader('Content-Range')
669 assert rrh.startswith('bytes '), rrh
670 rrh=rrh[6:].split('/',1)[0]
671 rrange = map(int, rrh.split('-',1))
673 # we need to compare the returned data with that of compare
674 fd = open(compare, 'rb')
679 raise NotImplementedError
682 assert d2 == d, "Data does not match"
683 return ctype, rrange, d
685 def gd_put(self, path, body=None, srcpath=None, mime=None, noclobber=False):
687 @param noclobber will prevent overwritting a resource (If-None-Match)
688 @param mime will set the content-type
691 if not (body or srcpath):
692 raise ValueError("PUT must have something to send.")
693 if (not body) and srcpath:
694 fd = open(srcpath, 'rb')
698 hdrs['Content-Type'] = mime
700 hdrs['If-None-Match'] = '*'
701 s, m, d = self._http_request(self.davpath + path, method='PUT',
702 hdrs=hdrs, body=body)
703 assert s == (201), "Bad status: %s" % s
704 etag = m.getheader('ETag')
707 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: