diff --git a/document_wopi/README.rst b/document_wopi/README.rst new file mode 100644 index 00000000..abcf08c9 --- /dev/null +++ b/document_wopi/README.rst @@ -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 `_ in order to allow online collaborative editing and viewing of office files. This works with `LibreOffice online `_/`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 `_ or `Libreoffice `_ docker images. The first works out of the box, but is rate limited, the second needs manual interaction, but is unmodified. The `Nextcloud documentation `_ 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 +`_. 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 `_. + +Contributors +------------ + +* Holger Brunn + +Do not contact contributors directly about help with questions or problems concerning this addon, but use the `community mailing list `_ or the `appropriate specialized mailinglist `_ 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. diff --git a/document_wopi/__init__.py b/document_wopi/__init__.py new file mode 100644 index 00000000..c897fc16 --- /dev/null +++ b/document_wopi/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import models +from . import controllers diff --git a/document_wopi/__openerp__.py b/document_wopi/__openerp__.py new file mode 100644 index 00000000..0cc25b16 --- /dev/null +++ b/document_wopi/__openerp__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# 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", + ], +} diff --git a/document_wopi/controllers/__init__.py b/document_wopi/controllers/__init__.py new file mode 100644 index 00000000..1b21ddd3 --- /dev/null +++ b/document_wopi/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import main diff --git a/document_wopi/controllers/main.py b/document_wopi/controllers/main.py new file mode 100644 index 00000000..7116d829 --- /dev/null +++ b/document_wopi/controllers/main.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# 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/', 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//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() + )) diff --git a/document_wopi/data/ir_actions_todo.xml b/document_wopi/data/ir_actions_todo.xml new file mode 100644 index 00000000..2e5ad68d --- /dev/null +++ b/document_wopi/data/ir_actions_todo.xml @@ -0,0 +1,10 @@ + + + + + Configure document_wopi + + once + + + diff --git a/document_wopi/demo/ir_config_parameter.xml b/document_wopi/demo/ir_config_parameter.xml new file mode 100644 index 00000000..f320b3f2 --- /dev/null +++ b/document_wopi/demo/ir_config_parameter.xml @@ -0,0 +1,9 @@ + + + + + document_wopi.client + https://docs.therp.nl + + + diff --git a/document_wopi/images/screenshot.png b/document_wopi/images/screenshot.png new file mode 100644 index 00000000..2c0be5b7 Binary files /dev/null and b/document_wopi/images/screenshot.png differ diff --git a/document_wopi/models/__init__.py b/document_wopi/models/__init__.py new file mode 100644 index 00000000..7f2c31a3 --- /dev/null +++ b/document_wopi/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# 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 diff --git a/document_wopi/models/document_wopi.py b/document_wopi/models/document_wopi.py new file mode 100644 index 00000000..7d66ad39 --- /dev/null +++ b/document_wopi/models/document_wopi.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# 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 diff --git a/document_wopi/models/document_wopi_access_token.py b/document_wopi/models/document_wopi_access_token.py new file mode 100644 index 00000000..05317b1d --- /dev/null +++ b/document_wopi/models/document_wopi_access_token.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# 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) diff --git a/document_wopi/models/knowledge_config_settings.py b/document_wopi/models/knowledge_config_settings.py new file mode 100644 index 00000000..19d64e1d --- /dev/null +++ b/document_wopi/models/knowledge_config_settings.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# © 2017 Therp BV +# 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' + ) diff --git a/document_wopi/security/ir.model.access.csv b/document_wopi/security/ir.model.access.csv new file mode 100644 index 00000000..02498f9c --- /dev/null +++ b/document_wopi/security/ir.model.access.csv @@ -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 diff --git a/document_wopi/security/ir_rule.xml b/document_wopi/security/ir_rule.xml new file mode 100644 index 00000000..e77dff5d --- /dev/null +++ b/document_wopi/security/ir_rule.xml @@ -0,0 +1,10 @@ + + + + + Users can only work on their own access tokens + [('user_id', '=', user.id)] + + + + diff --git a/document_wopi/static/description/icon.png b/document_wopi/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/document_wopi/static/description/icon.png differ diff --git a/document_wopi/static/src/css/document_wopi.css b/document_wopi/static/src/css/document_wopi.css new file mode 100644 index 00000000..9e1e7787 --- /dev/null +++ b/document_wopi/static/src/css/document_wopi.css @@ -0,0 +1,9 @@ +#document_wopi_iframe +{ + position: absolute; + width: 100%; + height: 100%; + padding: 0px; + margin: 0px; + border: none; +} diff --git a/document_wopi/static/src/js/document_wopi.js b/document_wopi/static/src/js/document_wopi.js new file mode 100644 index 00000000..ec1374d3 --- /dev/null +++ b/document_wopi/static/src/js/document_wopi.js @@ -0,0 +1,89 @@ +//-*- coding: utf-8 -*- +//© 2017 Therp BV +//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', + ); +}; diff --git a/document_wopi/static/src/xml/document_wopi.xml b/document_wopi/static/src/xml/document_wopi.xml new file mode 100644 index 00000000..7e4e0163 --- /dev/null +++ b/document_wopi/static/src/xml/document_wopi.xml @@ -0,0 +1,17 @@ + + + + + + + + + +
+
+ + +
+