[ADD] document_wopi

This commit is contained in:
Holger Brunn 2017-05-07 19:12:40 +02:00
parent 999595bba2
commit 9569004c7e
No known key found for this signature in database
GPG Key ID: 01C9760FECA3AE18
22 changed files with 654 additions and 0 deletions

80
document_wopi/README.rst Normal file
View File

@ -0,0 +1,80 @@
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
====
WOPI
====
This module was written to implement the `WOPI protocol <http://wopi.readthedocs.io/projects/wopirest>`_ in order to allow online collaborative editing and viewing of office files. This works with `LibreOffice online <https://wiki.documentfoundation.org/Development/LibreOffice_Online>`_/`Collabora online <https://www.collaboraoffice.com/collabora-online>`_ and probably also with Office 365.
Installation
============
To install this module, you need to:
#. have a WOPI client available. For testing, use one of the `Collabora <https://hub.docker.com/r/collabora/code>`_ or `Libreoffice <https://hub.docker.com/r/libreoffice/online/>`_ docker images. The first works out of the box, but is rate limited, the second needs manual interaction, but is unmodified. The `Nextcloud documentation <https://nextcloud.com/collaboraonline/>`_ contains some information on how to get this running
#. have your instance accessible on port 443 via your WOPI client and should have a valid SSL certificate
#. use a dbfilter if necessary to have unauthenticated requests ending up in the database you want
Configuration
=============
To configure this module, you need to:
#. fill in your WOPI client URL in the configuration wizard
Usage
=====
To use this module, you need to:
#. on any supported attachment, click the edit button
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
:alt: Try me on Runbot
:target: https://runbot.odoo-community.org/runbot/118/8.0
Known issues / Roadmap
======================
* implement quasi permanent public shares a la google docs
* whoever want to get their hands dirty with Microsoft should find our how to integrate this with Office 365
Bug Tracker
===========
Bugs are tracked on `GitHub Issues
<https://github.com/OCA/knowledge/issues>`_. In case of trouble, please
check there if your issue has already been reported. If you spotted it first,
help us smashing it by providing a detailed and welcomed feedback.
Credits
=======
Images
------
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_.
Contributors
------------
* Holger Brunn <hbrunn@therp.nl>
Do not contact contributors directly about help with questions or problems concerning this addon, but use the `community mailing list <mailto:community@mail.odoo.com>`_ or the `appropriate specialized mailinglist <https://odoo-community.org/groups>`_ for help, and the bug tracker linked in `Bug Tracker`_ above for technical issues.
Maintainer
----------
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
This module is maintained by the OCA.
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
To contribute to this module, please visit https://odoo-community.org.

View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import models
from . import controllers

View File

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{
"name": "WOPI",
"version": "8.0.1.0.0",
"author": "Therp BV,Odoo Community Association (OCA)",
"license": "AGPL-3",
"category": "Knowledge Management",
"summary": "Allow online collaborative editing of your documents",
"depends": [
'knowledge',
'attachment_action',
'attachment_lock',
],
"demo": [
"demo/ir_config_parameter.xml",
],
"data": [
"security/ir_rule.xml",
"data/ir_actions_todo.xml",
"views/knowledge_config_settings.xml",
'views/templates.xml',
'security/ir.model.access.csv',
],
"qweb": [
"static/src/xml/document_wopi.xml",
],
"images": [
"images/screenshot.png",
],
}

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import main

View File

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import base64
import json
import time
import logging
from openerp import http, fields
from openerp.http import request
_logger = logging.getLogger(__name__)
class WopiEndpoints(http.Controller):
@http.route(
'/wopi/files/<model("ir.attachment"):attachment>', type='http',
auth='public',
)
def files(self, attachment, access_token, **kwargs):
token = self._verify(access_token)
if not token:
_logger.error(request.httprequest.headers)
return request.not_found()
attachment = token.attachment_id
_logger.debug(request.httprequest.headers)
return json.dumps({
'BaseFileName': attachment.name,
'OwnerId': attachment.create_uid.id,
'Size': attachment.file_size,
'UserId': token.user_id.id,
'Version': str(self._timestamp(attachment)),
'UserFriendlyName': token.user_id.display_name,
'UserCanWrite': attachment.check_access_rights('write', False),
'SupportsUpdate': True,
'SupportsGetLock': True,
'SupportsLocks': True,
'LastModifiedTime': fields.Datetime.from_string(
attachment.write_date
).isoformat(),
'UserCanNotWriteRelative': True,
'DisablePrint': False,
'DisableExport': False,
'DisableCopy': False,
'DisableInactiveMessages': False,
'HidePrintOption': False,
'HideSaveOption': False,
'HideExportOption': False,
'EnableOwnerTermination': False,
'UserExtraInfo': False,
'WatermarkText': '',
'PostMessageOrigin': False,
})
@http.route(
'/wopi/files/<model("ir.attachment"):attachment>/contents',
type='http', auth='public',
)
def files_contents(self, attachment, access_token, **kwargs):
token = self._verify(access_token)
attachment = token.attachment_id
if not token:
_logger.error(request.httprequest.headers)
return request.not_found()
_logger.debug(request.httprequest.headers)
if 'X-WOPI-Override' in request.httprequest.headers:
name = '_files_contents_%s' % (
request.httprequest.headers['X-WOPI-Override']
)
if hasattr(self, name):
return getattr(self, name)(attachment, token, **kwargs)
return request.not_found()
return request.make_response(
base64.b64decode(attachment.datas),
[('X-WOPI-ItemVersion', str(self._timestamp(attachment)))],
)
def _files_contents_PUT(self, attachment, token, **kwargs):
if not attachment.can_lock:
return request.not_found()
if not attachment.locked:
attachment.lock()
attachment.write({'datas': base64.b64encode(request.httprequest.data)})
return request.make_response(
json.dumps({
'LastModifiedTime': fields.Datetime.from_string(
attachment.create_date
).isoformat(),
}),
[
('X-WOPI-Lock', str(attachment.lock_ids.ids)),
('X-WOPI-ItemVersion', str(self._timestamp(attachment))),
],
)
def _verify(self, access_token):
return request.env['document.wopi.access.token'].sudo()._verify_token(
access_token
)
def _timestamp(self, attachment):
return int(time.mktime(
fields.Datetime.from_string(attachment.write_date).timetuple()
))

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data noupdate="1">
<record id="document_wopi_todo" model="ir.actions.todo">
<field name="name">Configure document_wopi</field>
<field name="action_id" ref="knowledge.action_knowledge_configuration" />
<field name="type">once</field>
</record>
</data>
</openerp>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data noupdate="1">
<record id="param_client" model="ir.config_parameter">
<field name="key">document_wopi.client</field>
<field name="value">https://docs.therp.nl</field>
</record>
</data>
</openerp>

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import knowledge_config_settings
from . import document_wopi
from . import document_wopi_access_token

View File

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import requests
import urlparse
from openerp import api, models, tools
from lxml import etree
class DocumentWopi(models.AbstractModel):
_name = 'document.wopi'
_description = 'Implement the WOPI protocol'
@api.model
def _get_url(self, path):
client = self.env['ir.config_parameter'].get_param(
'document_wopi.client'
)
assert client, 'You need to define your WOPI client in the settings'
return urlparse.urljoin(client, path)
@api.model
def _call(self, path, query=None, headers=None):
return requests.get(
self._get_url(path), params=query, headers=headers
)
@api.model
def get_access_token(self, attachment_id):
"""Return an access token to pass to the WOPI client. Make sure the
user in question can access the resource"""
self.env['ir.attachment'].browse(attachment_id).read([])
return self.env['document.wopi.access.token']._get_token(
self.env['ir.attachment'].browse(attachment_id)
).id
@tools.ormcache()
@api.model
def _discovery(self):
response = self.env['document.wopi']._call('hosting/discovery')
xml = etree.fromstring(response.text)
result = {}
for action in xml.xpath('//action'):
ext = action.attrib['ext']
name = action.attrib['name']
result[(ext, name)] = dict(action.attrib)
return result

View File

@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import time
import mimetypes
import os.path
import uuid
import urllib
import urlparse
from datetime import datetime, timedelta
from openerp import api, fields, models
from openerp.exceptions import AccessDenied
class DocumentWopiAccessToken(models.Model):
_name = 'document.wopi.access.token'
_description = 'Access token for WOPI requests'
user_id = fields.Many2one(
'res.users', 'User', required=True, ondelete='cascade',
)
attachment_id = fields.Many2one(
'ir.attachment', 'Attachment', required=True, ondelete='cascade',
)
valid_until = fields.Datetime()
token = fields.Char(required=True)
token_ttl = fields.Integer(compute='_compute_token_ttl')
action_url = fields.Char(compute='_compute_action_url')
@api.model
def _get_token(self, attachment):
"""Create or return a token ID for some attachment for the current
user. This implies creating a shared lock for the attachment"""
result = self.search([
('user_id', '=', self.env.user.id),
('attachment_id', '=', attachment.id),
'|',
('valid_until', '=', False),
('valid_until', '>=', fields.Datetime.now()),
], limit=1)
valid_until = fields.Datetime.to_string(
datetime.now() +
timedelta(self.env['ir.config_parameter'].get_param(
'document_wopi.token_validity', 1800,
))
)
if result:
result.write({'valid_until': valid_until})
else:
result = self.create({
'user_id': self.env.user.id,
'attachment_id': attachment.id,
'valid_until': valid_until,
# TODO: is this random enough or do we need some secret
# generator?
'token': str(uuid.uuid4()),
})
result.attachment_id.lock(lock_type='shared', valid_until=valid_until)
return result
@api.multi
def _compute_token_ttl(self):
for this in self:
if not this.valid_until:
continue
this.token_ttl = int(time.mktime(
fields.Datetime.from_string(this.valid_until).timetuple()
))
@api.multi
def _compute_action_url(self):
wopi = self.env['document.wopi']
discovery = wopi._discovery()
for this in self:
dummy, ext = os.path.splitext(this.attachment_id.datas_fname)
if not ext:
ext = mimetypes.guess_extension(this.attachment_id.file_type)
if not ext:
continue
ext = ext[1:]
action = discovery.get(
(ext, 'edit'), discovery.get((ext, 'view')),
)
if not action:
continue
this.action_url = wopi._get_url(
action.get('urlsrc', '')
) + '?' + urllib.urlencode({
'WOPISrc': urlparse.urljoin(
self.env['ir.config_parameter']
.get_param('web.base.url'),
'/wopi/files/%d' % this.attachment_id.id,
)
})
@api.model
def _verify_token(self, token):
"""Be sure a token is valid; return a falsy value for invalid tokens"""
access_token = self.search([('token', '=', token)])
if not access_token:
return self.browse([])
try:
access_token.attachment_id.sudo(access_token.user_id).read([])
except AccessDenied:
return self.browse([])
if access_token.valid_until < fields.Datetime.now():
return self.browse([])
return access_token.sudo(access_token.user_id)

View File

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from openerp import _, api, fields, models
from openerp.exceptions import Warning as UserError
class KnowledgeConfigSettings(models.TransientModel):
_inherit = 'knowledge.config.settings'
document_wopi_client = fields.Char(
'WOPI client', required=True,
default=lambda self: self._default_document_wopi_client()
)
@api.multi
def set_document_wopi_client(self):
self.ensure_one()
self.env['ir.config_parameter'].set_param(
'document_wopi.client', self.document_wopi_client
)
self.env['document.wopi']._discovery.clear_cache(
self.env['document.wopi']
)
response = self.env['document.wopi']._discovery()
if not response:
raise UserError(
_("%s doesn't seem to support WOPI discovery") %
self.document_wopi_client
)
@api.model
def _default_document_wopi_client(self):
return self.env['ir.config_parameter'].get_param(
'document_wopi.client'
)

View File

@ -0,0 +1,2 @@
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
access_document_wopi_access_token,access_document_wopi_access_token,model_document_wopi_access_token,base.group_document_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_document_wopi_access_token access_document_wopi_access_token model_document_wopi_access_token base.group_document_user 1 1 1 1

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<record id="rule_document_wopi_access_token" model="ir.rule">
<field name="name">Users can only work on their own access tokens</field>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="model_id" ref="document_wopi.model_document_wopi_access_token" />
</record>
</data>
</openerp>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@ -0,0 +1,9 @@
#document_wopi_iframe
{
position: absolute;
width: 100%;
height: 100%;
padding: 0px;
margin: 0px;
border: none;
}

View File

@ -0,0 +1,89 @@
//-*- coding: utf-8 -*-
//© 2017 Therp BV <http://therp.nl>
//License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
openerp.document_wopi = function(instance)
{
var _t = instance.web._t;
instance.web.Sidebar.include(
{
on_attachments_loaded: function(attachments)
{
var self = this;
return jQuery.when(this._super.apply(this, arguments))
.then(function()
{
self.$el.find('.oe-sidebar-document-wopi')
.click(self.on_document_wopi);
});
},
on_document_wopi: function(e)
{
var self = this,
$target = jQuery(e.currentTarget),
attachment_id = $target.data('id');
e.preventDefault();
e.stopPropagation();
return new instance.web.Model('document.wopi').call(
'get_access_token', [attachment_id],
).then(function(access_token_id)
{
return self.document_wopi_frame(access_token_id);
});
},
document_wopi_frame: function(access_token_id)
{
this.do_action({
type: 'ir.actions.client',
name: 'document_wopi',
tag: 'document_wopi',
params: {
access_token_id: access_token_id,
}
});
},
});
instance.document_wopi.OfficeWidget = instance.web.Widget.extend({
'template': 'DocumentWopiOfficeWidget',
init: function(parent, options)
{
this.params = options.params;
return this._super.apply(this, arguments);
},
start: function()
{
return this._super.apply(this, arguments)
.then(this.proxy('get_token_values'))
.then(this.proxy('call_wopi_client'));
},
get_token_values: function()
{
var self = this;
return new instance.web.Model('document.wopi.access.token').query(
['token', 'token_ttl', 'attachment_id', 'action_url']
)
.filter([['id', '=', this.params.access_token_id]])
.first()
.then(function(data)
{
self.params = _.extend(self.params, data);
});
},
call_wopi_client: function()
{
if(!this.params.action_url)
{
throw instance.web._t('Unsupported document!');
}
this.$('form').attr('action', this.params.action_url);
this.$('form input[name="access_token"]').val(this.params.token);
this.$('form input[name="access_token_ttl"]').val(
this.params.token_ttl
);
this.$('form').submit();
},
});
instance.web.client_actions.add(
'document_wopi', 'instance.document_wopi.OfficeWidget',
);
};

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-extend="Sidebar">
<t t-jquery="a.oe_sidebar_delete_item" t-operation="before">
<a t-if="section.name == 'files' and !item.callback and item.url" href="#" class="oe-sidebar-document-wopi" t-att-data-id="item.id" t-att-data-url="item.url" t-attf-title="Edit #{item.name}">
<img t-att-src='_s + "/web/static/src/img/icons/STOCK_EDIT.png"' width="12" height="12" border="0"/>
</a>
</t>
</t>
<div t-name="DocumentWopiOfficeWidget">
<form id="document_wopi_form" name="document_wopi_form" target="document_wopi_iframe" action="" method="post">
<input name="access_token" value="" type="hidden"/>
<input name="access_token_ttl" value="" type="hidden"/>
</form>
<iframe id="document_wopi_iframe" name="document_wopi_iframe" allowfullscreen="allowfullscreen" />
</div>
</templates>

View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from . import test_document_wopi

View File

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# © 2017 Therp BV <http://therp.nl>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from base64 import b64encode
from openerp.tests.common import TransactionCase
from openerp.exceptions import AccessError
from openerp.tools import mute_logger
class TestDocumentWopi(TransactionCase):
def test_document_wopi(self):
config = self.env['knowledge.config.settings'].create({})
self.assertEqual(
config.document_wopi_client,
self.env['ir.config_parameter'].get_param('document_wopi.client'),
)
config.set_document_wopi_client()
discovery = self.env['document.wopi']._discovery()
self.assertIn(('odt', 'edit'), discovery)
attachment = self.env['ir.attachment'].create({
'name': 'testattachment',
'type': 'binary',
'datas': b64encode('hello world'),
'datas_fname': 'testattachment.fodt',
})
demo_user = self.env.ref('base.user_demo')
with mute_logger('openerp.addons.base.ir.ir_model'):
with self.assertRaises(AccessError):
token = self.env['document.wopi.access.token'].sudo(
demo_user
)._get_token(attachment)
token = self.env['document.wopi.access.token']._get_token(attachment)
self.assertTrue(token.action_url)
self.assertTrue(
self.env['document.wopi.access.token']
._verify_token(token.token)
)
self.assertFalse(
self.env['document.wopi.access.token'].sudo(demo_user)
._verify_token(token.token)
)
demo_user.write({
'groups_id': [
(4, self.env.ref('attachment_lock.group_attachment_lock').id),
],
})
token = self.env['document.wopi.access.token'].sudo(
demo_user
)._get_token(attachment)
self.assertTrue(token)
self.assertTrue(
self.env['document.wopi.access.token'].sudo(demo_user)
._verify_token(token.token)
)

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<record id="view_knowledge_configuration" model="ir.ui.view">
<field name="model">knowledge.config.settings</field>
<field name="inherit_id" ref="knowledge.view_knowledge_configuration" />
<field name="arch" type="xml">
<group position="after">
<group name="document_wopi">
<field name="document_wopi_client" placeholder="https://your.wopi.client" />
</group>
</group>
</field>
</record>
</data>
</openerp>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<openerp>
<data>
<template id="assets_backend" name="document_wopi assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/document_wopi/static/src/js/document_wopi.js"></script>
<link rel="stylesheet" href="/document_wopi/static/src/css/document_wopi.css"/>
</xpath>
</template>
</data>
</openerp>