doc webdav: Implement locking support

This replaces the pseydo-locking of python-webdav library with a real
db-based locking. Locks are stored as DAV properties, which will
effectively also be listed in the PROPFIND response of the nodes.

With locking in place, Office suites can collaborate on documents online.

bzr revid: p_christ@hol.gr-20101207134041-8negkvxrbscv7fs7
This commit is contained in:
P. Christeas 2010-12-07 15:40:41 +02:00
parent 9a4c999803
commit 6b2c832f5f
4 changed files with 454 additions and 12 deletions

View File

@ -35,6 +35,9 @@ import urllib
from DAV.davcmd import copyone, copytree, moveone, movetree, delone, deltree
from cache import memoize
from tools import misc
from webdav import mk_lock_response
try:
from tools.dict_tools import dict_merge2
except ImportError:
@ -209,18 +212,20 @@ class openerp_dav_handler(dav_interface):
self.parent.log_error("Cannot %s: %s", opname, err.strerror)
self.parent.log_message("Exc: %s",traceback.format_exc())
raise default_exc(err.strerror)
except Exception,e:
except Exception, e:
import traceback
if cr: cr.close()
self.parent.log_error("Cannot %s: %s", opname, str(e))
self.parent.log_message("Exc: %s",traceback.format_exc())
raise default_exc("Operation failed")
#def _get_dav_lockdiscovery(self, uri):
# raise DAV_NotFound
def _get_dav_lockdiscovery(self, uri):
""" We raise that so that the node API is used """
raise DAV_NotFound
#def A_get_dav_supportedlock(self, uri):
# raise DAV_NotFound
def _get_dav_supportedlock(self, uri):
""" We raise that so that the node API is used """
raise DAV_NotFound
def match_prop(self, uri, match, ns, propname):
if self.M_NS.has_key(ns):
@ -911,6 +916,91 @@ class openerp_dav_handler(dav_interface):
cr.close()
return result
def unlock(self, uri, token):
""" Unlock a resource from that token
@return True if unlocked, False if no lock existed, Exceptions
"""
cr, uid, pool, dbname, uri2 = self.get_cr(uri)
if not dbname:
if cr: cr.close()
raise DAV_Error, 409
node = self.uri2object(cr, uid, pool, uri2)
try:
node_fn = node.dav_unlock
except AttributeError:
# perhaps the node doesn't support locks
cr.close()
raise DAV_Error(400, 'No locks for this resource')
res = self._try_function(node_fn, (cr, token), "unlock %s" % uri, cr=cr)
cr.commit()
cr.close()
return res
def lock(self, uri, lock_data):
""" Lock (may create) resource.
Data is a dict, may contain:
depth, token, refresh, lockscope, locktype, owner
"""
cr, uid, pool, dbname, uri2 = self.get_cr(uri)
created = False
if not dbname:
if cr: cr.close()
raise DAV_Error, 409
try:
node = self.uri2object(cr, uid, pool, uri2[:])
except Exception:
node = False
objname = misc.ustr(uri2[-1])
if not node:
dir_node = self.uri2object(cr, uid, pool, uri2[:-1])
if not dir_node:
cr.close()
raise DAV_NotFound('Parent folder not found')
# We create a new node (file) but with empty data=None,
# as in RFC4918 p. 9.10.4
node = self._try_function(dir_node.create_child, (cr, objname, None),
"create %s" % objname, cr=cr)
if not node:
cr.commit()
cr.close()
raise DAV_Error(400, "Failed to create resource")
created = True
try:
node_fn = node.dav_lock
except AttributeError:
# perhaps the node doesn't support locks
cr.close()
raise DAV_Error(400, 'No locks for this resource')
# Obtain the lock on the node
lres, pid, token = self._try_function(node_fn, (cr, lock_data), "lock %s" % objname, cr=cr)
if not lres:
cr.commit()
cr.close()
raise DAV_Error(423, "Resource already locked")
assert isinstance(lres, list), 'lres: %s' % repr(lres)
try:
data = mk_lock_response(self, uri, lres)
cr.commit()
except Exception:
cr.close()
raise
cr.close()
return created, data, token
@memoize(CACHE_SIZE)
def is_collection(self, uri):
""" test if the given uri is a collection """

View File

@ -22,14 +22,14 @@
from document import nodes
from tools.safe_eval import safe_eval as eval
import time
import urllib
import uuid
try:
from tools.dict_tools import dict_filter
except ImportError:
from document.dict_tools import dict_filter
import urllib
class node_acl_mixin(object):
def _get_dav_owner(self, cr):
return self.uuser
@ -116,6 +116,153 @@ class node_acl_mixin(object):
return val
return None
def _dav_lock_hlpr(self, cr, lock_data, par_class, prop_model,
prop_ref_field, res_id):
""" Helper, which uses the dav properties table for placing locks
@param lock_data a dictionary of input to this function.
@return list of tuples, DAV:activelock _contents_ structure.
See webdav.py:class Prop2Xml() for semantics
Note: although the DAV response shall be an <activelock/>, this
function will only return the elements inside the activelock,
because the calling function needs to append the <lockroot/> in
it. See webdav.py:mk_lock_response()
In order to reuse code, this function can be called with
lock_data['unlock_mode']=True, in order to unlock.
@return bool in unlock mode, (davstruct, prop_id, token) in lock/refresh,
or (False, prop_id, token) if already locked,
or (False, False, False) if lock not found to refresh
"""
assert prop_model
assert res_id
assert isinstance(lock_data, dict), '%r' % lock_data
propobj = self.context._dirobj.pool.get(prop_model)
uid = self.context.uid
ctx = self.context.context.copy()
ctx.update(self.dctx)
ctx.update({'uid': uid, 'dbname': self.context.dbname })
ctx['node_classname'] = "%s.%s" % (self.__class__.__module__, self.__class__.__name__)
dict_filter(self.context.extra_ctx, ['username', 'groupname', 'webdav_path'], ctx)
sdomain = [(prop_ref_field, '=', res_id), ('namespace', '=', 'DAV:'),
('name','=', 'lockdiscovery')]
props_to_delete = []
lock_found = False
lock_val = None
tmout2 = int(lock_data.get('timeout', 3*3600))
prop_ids = propobj.search(cr, uid, sdomain, context=ctx)
if prop_ids:
for pbro in propobj.browse(cr, uid, prop_ids, context=ctx):
val = pbro.value
if pbro.do_subst:
if val.startswith("('") and val.endswith(")"):
glbls = { 'urlquote': urllib.quote, }
val = eval(val, glbls, ctx)
else:
# all locks should be at "subst" format
continue
if not (val and isinstance(val, tuple)
and val[0:2] == ( 'activelock','DAV:')):
# print "Value is not activelock:", val
continue
old_token = False
old_owner = False
try:
# discover the timeout. If anything goes wrong, delete
# the lock (cleanup)
tmout = False
for parm in val[2]:
if parm[1] != 'DAV:':
continue
if parm[0] == 'timeout':
if isinstance(parm[2], basestring) \
and parm[2].startswith('Second-'):
tmout = int(parm[2][7:])
elif parm[0] == 'locktoken':
if isinstance(parm[2], basestring):
old_token = parm[2]
elif isinstance(parm[2], tuple) and \
parm[2][0:2] == ('href','DAV:'):
old_token = parm[2][2]
else:
# print "Mangled token in DAV property: %r" % parm[2]
props_to_delete.append(pbro.id)
continue
elif parm[0] == 'owner':
old_owner = parm[2] # not used yet
if tmout:
mdate = pbro.write_date or pbro.create_date
mdate = time.mktime(time.strptime(mdate,'%Y-%m-%d %H:%M:%S'))
if mdate + tmout < time.time():
props_to_delete.append(pbro.id)
continue
else:
props_to_delete.append(pbro.id)
continue
except ValueError:
props_to_delete.append(pbro.id)
continue
# A valid lock is found here
if lock_data.get('refresh', False):
if old_token != lock_data.get('token'):
continue
# refresh mode. Just touch anything and the ORM will update
# the write uid+date, won't it?
# Note: we don't update the owner, because incoming refresh
# wouldn't have a body, anyway.
propobj.write(cr, uid, [pbro.id,], { 'name': 'lockdiscovery'})
elif lock_data.get('unlock_mode', False):
if old_token != lock_data.get('token'):
continue
props_to_delete.append(pbro.id)
lock_found = pbro.id
lock_val = val
if tmout2 > 3*3600: # 3 hours maximum
tmout2 = 3*3600
elif tmout2 < 300:
# 5 minutes minimum, but an unlock request can always
# break it at any time. Ensures no negative values, either.
tmout2 = 300
if props_to_delete:
# explicitly delete, as admin, any of the ids we have identified.
propobj.unlink(cr, 1, props_to_delete)
if lock_data.get('unlock_mode', False):
return lock_found and True
elif (not lock_found) and not (lock_data.get('refresh', False)):
# Create a new lock, attach and return it.
new_token = uuid.uuid4().urn
lock_val = ('activelock', 'DAV:',
[ ('locktype', 'DAV:', (lock_data.get('locktype',False) or 'write','DAV:')),
('lockscope', 'DAV:', (lock_data.get('lockscope',False) or 'exclusive','DAV:')),
# ? ('depth', 'DAV:', lock_data.get('depth','0') ),
('timeout','DAV:', 'Second-%d' % tmout2),
('locktoken', 'DAV:', ('href', 'DAV:', new_token)),
# ('lockroot', 'DAV: ..., we don't store that, appended by caller
])
new_owner = lock_data.get('lockowner',False) or ctx.get('username', False)
if new_owner:
lock_val[2].append( ('owner', 'DAV:', new_owner) )
prop_id = propobj.create(cr, uid, { prop_ref_field: res_id,
'namespace': 'DAV:', 'name': 'lockdiscovery',
'do_subst': True, 'value': repr(lock_val) })
return (lock_val[2], prop_id, new_token )
elif not lock_found: # and refresh
return (False, False, False)
elif lock_found and not lock_data.get('refresh', False):
# already locked
return (False, lock_found, old_token)
else:
return (lock_val[2], lock_found, old_token )
class node_dir(node_acl_mixin, nodes.node_dir):
""" override node_dir and add DAV functionality
"""
@ -141,7 +288,8 @@ class node_dir(node_acl_mixin, nodes.node_dir):
class node_file(node_acl_mixin, nodes.node_file):
DAV_PROPS = { "DAV:": ('owner', 'group',
'supported-privilege-set',
'current-user-privilege-set'),
'current-user-privilege-set',
),
}
DAV_M_NS = { "DAV:" : '_get_dav',}
http_options = { 'DAV': ['access-control', ] }
@ -152,10 +300,45 @@ class node_file(node_acl_mixin, nodes.node_file):
def get_dav_props(self, cr):
return self._get_dav_props_hlpr(cr, nodes.node_dir,
None, 'file_id', self.file_id)
#'document.webdav.dir.property', 'dir_id', self.dir_id)
'document.webdav.file.property', 'file_id', self.file_id)
#def get_dav_eprop(self, cr, ns, prop):
def dav_lock(self, cr, lock_data):
""" Locks or unlocks the node, using DAV semantics.
Unlocking will be done when lock_data['unlock_mode'] == True
See _dav_lock_hlpr() for calling details.
It is fundamentally OK to use this function from non-DAV endpoints,
but they will all have to emulate the tuple-in-list structure of
the DAV lock data. RFC if this translation should be done inside
the _dav_lock_hlpr (to ease other protocols).
"""
return self._dav_lock_hlpr(cr, lock_data, nodes.node_file,
'document.webdav.file.property', 'file_id', self.file_id)
def dav_unlock(self, cr, token):
"""Releases the token lock held for the node
This is a utility complement of dav_lock()
"""
lock_data = { 'token': token, 'unlock_mode': True }
return self._dav_lock_hlpr(cr, lock_data, nodes.node_file,
'document.webdav.file.property', 'file_id', self.file_id)
def get_dav_eprop(self, cr, ns, prop):
if ns == 'DAV:' and prop == 'supportedlock':
return [ ('lockentry', 'DAV:',
[ ('lockscope','DAV:', ('shared', 'DAV:')),
('locktype','DAV:', ('write', 'DAV:')),
]),
('lockentry', 'DAV:',
[ ('lockscope','DAV:', ('exclusive', 'DAV:')),
('locktype','DAV:', ('write', 'DAV:')),
] )
]
return self._get_dav_eprop_hlpr(cr, ns, prop, nodes.node_file,
'document.webdav.file.property', 'file_id', self.file_id)
class node_database(nodes.node_database):
def get_dav_resourcetype(self, cr):

View File

@ -268,6 +268,39 @@ def mk_propname_response(self,uri,propnames,doc):
PROPFIND.mk_prop_response = mk_prop_response
PROPFIND.mk_propname_response = mk_propname_response
def mk_lock_response(self, uri, props):
""" Prepare the data response to a DAV LOCK command
This function is here, merely to be in the same file as the
ones above, that have similar code.
"""
doc = domimpl.createDocument('DAV:', "D:prop", None)
ms = doc.documentElement
ms.setAttribute("xmlns:D", "DAV:")
# ms.tagName = 'D:multistatus'
namespaces = []
nsnum = 0
propgen = Prop2xml(doc, namespaces, nsnum)
# write href information
uparts=urlparse.urlparse(uri)
fileloc=uparts[2]
if isinstance(fileloc, unicode):
fileloc = fileloc.encode('utf-8')
davpath = self.parent.get_davpath()
if uparts[0] and uparts[1]:
hurl = '%s://%s%s%s' % (uparts[0], uparts[1], davpath, urllib.quote(fileloc))
else:
# When the request has been relative, we don't have enough data to
# reply with absolute url here.
hurl = '%s%s' % (davpath, urllib.quote(fileloc))
props.append( ('lockroot', 'DAV:', ('href', 'DAV:', (hurl))))
pld = doc.createElement('D:lockdiscovery')
ms.appendChild(pld)
propgen._prop_child(pld, 'DAV:', 'activelock', props)
return doc.toxml(encoding="utf-8")
super_create_prop = REPORT.create_prop
def create_prop(self):

View File

@ -38,7 +38,9 @@ import urllib
import re
from string import atoi
from DAV.errors import *
from DAV.utils import IfParser, TagList
# from DAV.constants import DAV_VERSION_1, DAV_VERSION_2
from xml.dom import minidom
khtml_re = re.compile(r' KHTML/([0-9\.]+) ')
@ -103,6 +105,7 @@ class DAVHandler(HttpOptions, FixSendError, DAVRequestHandler):
self.headers['Destination'] = up.path[len(self.davpath):]
else:
raise DAV_Forbidden("Not allowed to copy/move outside webdav path")
# TODO: locks
DAVRequestHandler.copymove(self, CLASS)
def get_davpath(self):
@ -262,6 +265,139 @@ class DAVHandler(HttpOptions, FixSendError, DAVRequestHandler):
except DAV_Error, (ec, dd):
return self.send_status(ec)
def do_UNLOCK(self):
""" Unlocks given resource """
dc = self.IFACE_CLASS
self.log_message('UNLOCKing resource %s' % self.headers)
uri = urlparse.urljoin(self.get_baseuri(dc), self.path)
uri = urllib.unquote(uri)
token = self.headers.get('Lock-Token', False)
if token:
token = token.strip()
if token[0] == '<' and token[-1] == '>':
token = token[1:-1]
else:
token = False
if not token:
return self.send_status(400, 'Bad lock token')
try:
res = dc.unlock(uri, token)
except DAV_Error, (ec, dd):
return self.send_status(ec, dd)
if res == True:
self.send_body(None, '204', 'OK', 'Resource unlocked.')
else:
# We just differentiate the description, for debugging purposes
self.send_body(None, '204', 'OK', 'Resource not locked.')
def do_LOCK(self):
""" Attempt to place a lock on the given resource.
"""
dc = self.IFACE_CLASS
lock_data = {}
self.log_message('LOCKing resource %s' % self.headers)
body = None
if self.headers.has_key('Content-Length'):
l = self.headers['Content-Length']
body = self.rfile.read(atoi(l))
depth = self.headers.get('Depth', 'infinity')
uri = urlparse.urljoin(self.get_baseuri(dc), self.path)
uri = urllib.unquote(uri)
self.log_message('do_LOCK: uri = %s' % uri)
ifheader = self.headers.get('If')
if ifheader:
ldif = IfParser(ifheader)
if isinstance(ldif, list):
if len(ldif) !=1 or (not isinstance(ldif[0], TagList)) \
or len(ldif[0].list) != 1:
raise DAV_Error(400, "Cannot accept multiple tokens")
ldif = ldif[0].list[0]
if ldif[0] == '<' and ldif[-1] == '>':
ldif = ldif[1:-1]
lock_data['token'] = ldif
if not body:
lock_data['refresh'] = True
else:
lock_data['refresh'] = False
lock_data.update(self._lock_unlock_parse(body))
if lock_data['refresh'] and not lock_data.get('token', False):
raise DAV_Error(400, 'Lock refresh must specify token')
lock_data['depth'] = depth
try:
created, data, lock_token = dc.lock(uri, lock_data)
except DAV_Error, (ec, dd):
return self.send_status(ec, dd)
headers = {}
if not lock_data['refresh']:
headers['Lock-Token'] = '<%s>' % lock_token
if created:
self.send_body(data, '201', 'Created', ctype='text/xml', headers=headers)
else:
self.send_body(data, '200', 'OK', ctype='text/xml', headers=headers)
def _lock_unlock_parse(self, body):
# Override the python-webdav function, with some improvements
# Unlike the py-webdav one, we also parse the owner minidom elements into
# pure pythonic struct.
doc = minidom.parseString(body)
data = {}
owners = []
for info in doc.getElementsByTagNameNS('DAV:', 'lockinfo'):
for scope in info.getElementsByTagNameNS('DAV:', 'lockscope'):
for scc in scope.childNodes:
if scc.nodeType == info.ELEMENT_NODE \
and scc.namespaceURI == 'DAV:':
data['lockscope'] = scc.localName
break
for ltype in info.getElementsByTagNameNS('DAV:', 'locktype'):
for ltc in ltype.childNodes:
if ltc.nodeType == info.ELEMENT_NODE \
and ltc.namespaceURI == 'DAV:':
data['locktype'] = ltc.localName
break
for own in info.getElementsByTagNameNS('DAV:', 'owner'):
for ono in own.childNodes:
if ono.nodeType == info.TEXT_NODE:
if ono.data:
owners.append(ono.data)
elif ono.nodeType == info.ELEMENT_NODE \
and ono.namespaceURI == 'DAV:' \
and ono.localName == 'href':
href = ''
for hno in ono.childNodes:
if hno.nodeType == info.TEXT_NODE:
href += hno.data
owners.append(('href','DAV:', href))
if len(owners) == 1:
data['lockowner'] = owners[0]
elif not owners:
pass
else:
data['lockowner'] = owners
return data
from service.http_server import reg_http_service,OpenERPAuthProvider
class DAVAuthProvider(OpenERPAuthProvider):