diff --git a/addons/link_tracker/models/link_tracker.py b/addons/link_tracker/models/link_tracker.py
index 9938a8ee7da..8abed891105 100644
--- a/addons/link_tracker/models/link_tracker.py
+++ b/addons/link_tracker/models/link_tracker.py
@@ -11,8 +11,7 @@ from werkzeug import urls
from odoo import tools, models, fields, api, _
from odoo.exceptions import UserError
from odoo.osv import expression
-
-URL_MAX_SIZE = 10 * 1024 * 1024
+from odoo.addons.mail.tools import link_preview
class LinkTracker(models.Model):
@@ -112,22 +111,10 @@ class LinkTracker(models.Model):
@api.model
@api.depends('url')
def _get_title_from_url(self, url):
- try:
- head = requests.head(url, allow_redirects=True, timeout=5)
- if (
- int(head.headers.get('Content-Length', 0)) > URL_MAX_SIZE
- or
- 'text/html' not in head.headers.get('Content-Type', 'text/html')
- ):
- return url
- # HTML parser can work with a part of page, so ask server to limit downloading to 50 KB
- page = requests.get(url, timeout=5, headers={"range": "bytes=0-50000"})
- p = html.fromstring(page.text.encode('utf-8'), parser=html.HTMLParser(encoding='utf-8'))
- title = p.find('.//title').text
- except:
- title = url
-
- return title
+ preview = link_preview.get_link_preview_from_url(url)
+ if preview and preview.get('og_title'):
+ return preview['og_title']
+ return url
@api.constrains('url', 'campaign_id', 'medium_id', 'source_id')
def _check_unicity(self):
diff --git a/addons/mail/models/mail_link_preview.py b/addons/mail/models/mail_link_preview.py
index 7a6b1eee905..1166383d884 100644
--- a/addons/mail/models/mail_link_preview.py
+++ b/addons/mail/models/mail_link_preview.py
@@ -3,11 +3,12 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
-from lxml import html, etree
+from lxml import html
from urllib.parse import urlparse
import requests
from odoo import api, models, fields
+from odoo.addons.mail.tools import link_preview
class LinkPreview(models.Model):
@@ -54,7 +55,7 @@ class LinkPreview(models.Model):
def _create_link_preview(self, url, message_id, request_session):
if self._is_domain_throttled(url):
return self.env['mail.link.preview']
- link_preview_data = self._get_link_preview_from_url(url, request_session)
+ link_preview_data = link_preview.get_link_preview_from_url(url, request_session)
if link_preview_data:
link_preview_data['message_id'] = message_id
return self.create(link_preview_data)
@@ -86,54 +87,6 @@ class LinkPreview(models.Model):
link_preview_throttle = int(self.env['ir.config_parameter'].get_param('mail.link_preview_throttle', 99))
return call_counter > link_preview_throttle
- @api.model
- def _get_link_preview_from_url(self, url, request_session):
- try:
- response = request_session.head(url, timeout=3, allow_redirects=True)
- except requests.exceptions.RequestException:
- return False
- if response.status_code != requests.codes.ok:
- return False
- image_mimetype = (
- 'image/bmp',
- 'image/gif',
- 'image/jpeg',
- 'image/png',
- 'image/tiff',
- 'image/x-icon',
- )
- # Content-Type header can return a charset, but we just need the
- # mimetype (eg: image/jpeg;charset=ISO-8859-1)
- content_type = response.headers['Content-Type'].split(';')
- if response.headers['Content-Type'].startswith(image_mimetype):
- return {
- 'image_mimetype': content_type[0],
- 'source_url': url,
- }
- if response.headers['Content-Type'].startswith('text/html'):
- return self._get_link_preview_from_html(url, request_session)
- return False
-
- def _get_link_preview_from_html(self, url, request_session):
- response = request_session.get(url, timeout=3)
- parser = etree.HTMLParser(encoding=response.encoding)
- tree = html.fromstring(response.content, parser=parser)
- og_title = tree.xpath('//meta[@property="og:title"]/@content')
- if not og_title:
- return False
- og_description = tree.xpath('//meta[@property="og:description"]/@content')
- og_type = tree.xpath('//meta[@property="og:type"]/@content')
- og_image = tree.xpath('//meta[@property="og:image"]/@content')
- og_mimetype = tree.xpath('//meta[@property="og:image:type"]/@content')
- return {
- 'og_description': og_description[0] if og_description else None,
- 'og_image': og_image[0] if og_image else None,
- 'og_mimetype': og_mimetype[0] if og_mimetype else None,
- 'og_title': og_title[0],
- 'og_type': og_type[0] if og_type else None,
- 'source_url': url,
- }
-
def _link_preview_format(self):
return [{
'id': preview.id,
diff --git a/addons/mail/tests/test_link_preview.py b/addons/mail/tests/test_link_preview.py
index 330def1a668..ee4108f1aa6 100644
--- a/addons/mail/tests/test_link_preview.py
+++ b/addons/mail/tests/test_link_preview.py
@@ -1,36 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from functools import partial
+import io
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.mail.tests.common import MailCommon
+from odoo.addons.mail.tools import link_preview
from unittest.mock import patch
import requests
discuss_channel_new_test_user = partial(mail_new_test_user, context={'discuss_channel_nosubscribe': False})
-def _patched_get_html(*args, **kwargs):
- response = requests.Response()
- response.status_code = 200
- response._content = b"""
-
-
-
-
-
-
- """
- response.headers["Content-Type"] = 'text/html'
- return response
-
-def _patch_head_html(*args, **kwargs):
- response = requests.Response()
- response.status_code = 200
- response.headers["Content-Type"] = 'text/html'
- return response
-
-
class TestLinkPreview(MailCommon):
@classmethod
@@ -42,29 +23,82 @@ class TestLinkPreview(MailCommon):
groups='base.group_user')
cls.thread = cls.env['res.partner'].create({'name': 'a partner'})
+ cls.title = 'Test title'
+ cls.og_title = 'Test OG title'
+ cls.og_description = 'Test OG description'
+ cls.og_image = 'https://dummy-image-url.nothing'
+ cls.source_url = 'https://thisdomainedoentexist.nothing'
+
+ def _patch_head_html(self, *args, **kwargs):
+ response = requests.Response()
+ response.status_code = 200
+ response.headers["Content-Type"] = 'text/html'
+ return response
+
+ def _patched_get_html(self, content_type, content):
+ response = requests.Response()
+ response.status_code = 200
+ response._content = content
+ # To handle chunks read on stream requests
+ response.raw = io.BytesIO(response._content)
+ response.headers["Content-Type"] = content_type
+ return response
+
+ def _patch_with_og_properties(self, *args, **kwargs):
+ content = b"""
+
+
+ Test title
+
+
+
+
+
+ """
+ return self._patched_get_html('text/html', content)
+
+ def _patch_without_og_properties(self, *args, **kwargs):
+ content = b"""
+
+
+ Test title
+
+
+ """
+ return self._patched_get_html('text/html', content)
+
+ def _patch_with_image_mimetype(self, *args, **kwargs):
+ content = b"""
+
+
+
+
+
+ """
+ return self._patched_get_html('image/png', content)
def test_01_link_preview_throttle(self):
- with patch.object(requests.Session, 'get', _patched_get_html), patch.object(requests.Session, 'head', _patch_head_html):
+ with patch.object(requests.Session, 'get', self._patch_with_og_properties), patch.object(requests.Session, 'head', self._patch_head_html):
throttle = int(self.env['ir.config_parameter'].sudo().get_param('mail.link_preview_throttle', 99))
link_previews = []
for _ in range(throttle):
- link_previews.append({'source_url': 'https://thisdomainedoentexist.nothing', 'message_id': 1})
+ link_previews.append({'source_url': self.source_url, 'message_id': 1})
self.env['mail.link.preview'].create(link_previews)
message = self.env['mail.message'].create({
'model': self.thread._name,
'res_id': self.thread.id,
- 'body': 'Nothing link',
+ 'body': f'Nothing link',
})
self.env['mail.link.preview']._create_link_previews(message)
- link_preview_count = self.env['mail.link.preview'].search_count([('source_url', '=', 'https://thisdomainedoentexist.nothing')])
+ link_preview_count = self.env['mail.link.preview'].search_count([('source_url', '=', self.source_url)])
self.assertEqual(link_preview_count, throttle + 1)
def test_02_link_preview_create(self):
- with patch.object(requests.Session, 'get', _patched_get_html), patch.object(requests.Session, 'head', _patch_head_html):
+ with patch.object(requests.Session, 'get', self._patch_with_og_properties), patch.object(requests.Session, 'head', self._patch_head_html):
message = self.env['mail.message'].create({
'model': self.thread._name,
'res_id': self.thread.id,
- 'body': 'Nothing link',
+ 'body': f'Nothing link',
})
self.env['mail.link.preview']._create_link_previews(message)
self.assertBusNotifications(
@@ -76,13 +110,48 @@ class TestLinkPreview(MailCommon):
'id': link_preview.id,
'message': {'id': message.id},
'image_mimetype': False,
- 'og_description': 'Test description',
- 'og_image': False,
+ 'og_description': self.og_description,
+ 'og_image': self.og_image,
'og_mimetype': False,
- 'og_title': 'Test title',
+ 'og_title': self.og_title,
'og_type': False,
- 'source_url': 'https://thisdomainedoentexist.nothing',
+ 'source_url': self.source_url,
}] for link_preview in message.link_preview_ids
}
}]
)
+
+ def test_get_link_preview_from_url(self):
+ test_cases = [
+ (self._patch_with_og_properties, self.source_url),
+ (self._patch_without_og_properties, self.source_url),
+ (self._patch_with_image_mimetype, self.og_image),
+ ]
+ test_asserts = [
+ {
+ 'og_description': self.og_description,
+ 'og_image': self.og_image,
+ 'og_mimetype': None,
+ 'og_title': self.og_title,
+ 'og_type': None,
+ 'source_url': self.source_url,
+ },
+ {
+ 'og_description': None,
+ 'og_image': None,
+ 'og_mimetype': None,
+ 'og_title': self.title,
+ 'og_type': None,
+ 'source_url': self.source_url,
+ },
+ {
+ 'image_mimetype': 'image/png',
+ 'og_image': self.og_image,
+ 'source_url': self.og_image,
+ },
+ ]
+ session = requests.Session()
+ for (get_patch, url), expected in zip(test_cases, test_asserts):
+ with self.subTest(get_patch=get_patch, url=url, expected=expected), patch.object(requests.Session, 'get', get_patch):
+ preview = link_preview.get_link_preview_from_url(url, session)
+ self.assertEqual(preview, expected)
diff --git a/addons/mail/tools/link_preview.py b/addons/mail/tools/link_preview.py
new file mode 100644
index 00000000000..7fe54479463
--- /dev/null
+++ b/addons/mail/tools/link_preview.py
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from lxml import html
+import requests
+
+
+def get_link_preview_from_url(url, request_session=None):
+ """
+ Get the Open Graph properties of an url. (https://ogp.me/)
+ If the url leads directly to an image mimetype, return
+ the url as preview image else retrieve the properties from
+ the html page.
+
+ Using a stream request to prevent loading the whole page
+ as those properties are declared in the tag.
+
+ The request session is optional as in some cases using
+ a session could be beneficial performance wise
+ (e.g. a lot of url could have the same domain).
+ """
+ # Some websites are blocking non browser user agent.
+ user_agent = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0'}
+ try:
+ if request_session:
+ response = request_session.get(url, timeout=3, headers=user_agent, allow_redirects=True, stream=True)
+ else:
+ response = requests.get(url, timeout=3, headers=user_agent, allow_redirects=True, stream=True)
+ except requests.exceptions.RequestException:
+ return False
+ if not response.ok or not response.headers.get('Content-Type'):
+ return False
+ # Content-Type header can return a charset, but we just need the
+ # mimetype (eg: image/jpeg;charset=ISO-8859-1)
+ content_type = response.headers['Content-Type'].split(';')
+ if response.headers['Content-Type'].startswith('image/'):
+ return {
+ 'image_mimetype': content_type[0],
+ 'og_image': url, # If the url mimetype is already an image type, set url as preview image
+ 'source_url': url,
+ }
+ elif response.headers['Content-Type'].startswith('text/html'):
+ return get_link_preview_from_html(url, response)
+ return False
+
+def get_link_preview_from_html(url, response):
+ """
+ Retrieve the Open Graph properties from the html page. (https://ogp.me/)
+ Load the page with chunks of 8kb to prevent loading the whole
+ html when we only need the tag content.
+ Fallback on the tag if the html doesn't have
+ any Open Graph title property.
+ """
+ content = b""
+ for chunk in response.iter_content(chunk_size=8192):
+ content += chunk
+ # Stop reading once all the data is found
+ if b"" in content:
+ break
+ content = content.decode()
+ if not content:
+ return False
+ tree = html.fromstring(content)
+ og_title = tree.xpath('//meta[@property="og:title"]/@content')
+ if og_title:
+ og_title = og_title[0]
+ elif tree.find('.//title') is not None:
+ # Fallback on the tag if it exists
+ og_title = tree.find('.//title').text
+ else:
+ return False
+ og_description = tree.xpath('//meta[@property="og:description"]/@content')
+ og_type = tree.xpath('//meta[@property="og:type"]/@content')
+ og_image = tree.xpath('//meta[@property="og:image"]/@content')
+ og_mimetype = tree.xpath('//meta[@property="og:image:type"]/@content')
+ return {
+ 'og_description': og_description[0] if og_description else None,
+ 'og_image': og_image[0] if og_image else None,
+ 'og_mimetype': og_mimetype[0] if og_mimetype else None,
+ 'og_title': og_title,
+ 'og_type': og_type[0] if og_type else None,
+ 'source_url': url,
+ }