main.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799
  1. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  2. import contextlib
  3. import io
  4. import json
  5. import logging
  6. import re
  7. import time
  8. import requests
  9. import werkzeug.exceptions
  10. import werkzeug.urls
  11. from PIL import Image, ImageFont, ImageDraw
  12. from lxml import etree
  13. from base64 import b64decode, b64encode
  14. from math import floor
  15. from odoo.http import request, Response
  16. from odoo import http, tools, _, SUPERUSER_ID
  17. from odoo.addons.http_routing.models.ir_http import slug, unslug
  18. from odoo.addons.web_editor.tools import get_video_url_data
  19. from odoo.exceptions import UserError, MissingError, ValidationError
  20. from odoo.modules.module import get_resource_path
  21. from odoo.tools import file_open
  22. from odoo.tools.mimetypes import guess_mimetype
  23. from odoo.tools.image import image_data_uri, binary_to_image
  24. from odoo.addons.base.models.assetsbundle import AssetsBundle
  25. from ..models.ir_attachment import SUPPORTED_IMAGE_EXTENSIONS, SUPPORTED_IMAGE_MIMETYPES
  26. logger = logging.getLogger(__name__)
  27. DEFAULT_LIBRARY_ENDPOINT = 'https://media-api.odoo.com'
  28. diverging_history_regex = 'data-last-history-steps="([0-9,]+)"'
  29. def ensure_no_history_divergence(record, html_field_name, incoming_history_ids):
  30. server_history_matches = re.search(diverging_history_regex, record[html_field_name] or '')
  31. # Do not check old documents without data-last-history-steps.
  32. if server_history_matches:
  33. server_last_history_id = server_history_matches[1].split(',')[-1]
  34. if server_last_history_id not in incoming_history_ids:
  35. logger.warning('The document was already saved from someone with a different history for model %r, field %r with id %r.', record._name, html_field_name, record.id)
  36. raise ValidationError(_('The document was already saved from someone with a different history for model %r, field %r with id %r.', record._name, html_field_name, record.id))
  37. # This method must be called in a context that has write access to the record as
  38. # it will write to the bus.
  39. def handle_history_divergence(record, html_field_name, vals):
  40. # Do not handle history divergence if the field is not in the values.
  41. if html_field_name not in vals:
  42. return
  43. incoming_html = vals[html_field_name]
  44. incoming_history_matches = re.search(diverging_history_regex, incoming_html or '')
  45. # When there is no incoming history id, it means that the value does not
  46. # comes from the odoo editor or the collaboration was not activated. In
  47. # project, it could come from the collaboration pad. In that case, we do not
  48. # handle history divergences.
  49. if request:
  50. channel = (request.db, 'editor_collaboration', record._name, html_field_name, record.id)
  51. if incoming_history_matches is None:
  52. if request:
  53. bus_data = {
  54. 'model_name': record._name,
  55. 'field_name': html_field_name,
  56. 'res_id': record.id,
  57. 'notificationName': 'html_field_write',
  58. 'notificationPayload': {'last_step_id': None},
  59. }
  60. request.env['bus.bus']._sendone(channel, 'editor_collaboration', bus_data)
  61. return
  62. incoming_history_ids = incoming_history_matches[1].split(',')
  63. last_step_id = incoming_history_ids[-1]
  64. bus_data = {
  65. 'model_name': record._name,
  66. 'field_name': html_field_name,
  67. 'res_id': record.id,
  68. 'notificationName': 'html_field_write',
  69. 'notificationPayload': {'last_step_id': last_step_id},
  70. }
  71. if request:
  72. request.env['bus.bus']._sendone(channel, 'editor_collaboration', bus_data)
  73. if record[html_field_name]:
  74. ensure_no_history_divergence(record, html_field_name, incoming_history_ids)
  75. # Save only the latest id.
  76. vals[html_field_name] = incoming_html[0:incoming_history_matches.start(1)] + last_step_id + incoming_html[incoming_history_matches.end(1):]
  77. class Web_Editor(http.Controller):
  78. #------------------------------------------------------
  79. # convert font into picture
  80. #------------------------------------------------------
  81. @http.route([
  82. '/web_editor/font_to_img/<icon>',
  83. '/web_editor/font_to_img/<icon>/<color>',
  84. '/web_editor/font_to_img/<icon>/<color>/<int:size>',
  85. '/web_editor/font_to_img/<icon>/<color>/<int:width>x<int:height>',
  86. '/web_editor/font_to_img/<icon>/<color>/<int:size>/<int:alpha>',
  87. '/web_editor/font_to_img/<icon>/<color>/<int:width>x<int:height>/<int:alpha>',
  88. '/web_editor/font_to_img/<icon>/<color>/<bg>',
  89. '/web_editor/font_to_img/<icon>/<color>/<bg>/<int:size>',
  90. '/web_editor/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>',
  91. '/web_editor/font_to_img/<icon>/<color>/<bg>/<int:width>x<int:height>/<int:alpha>',
  92. ], type='http', auth="none")
  93. def export_icon_to_png(self, icon, color='#000', bg=None, size=100, alpha=255, font='/web/static/src/libs/fontawesome/fonts/fontawesome-webfont.ttf', width=None, height=None):
  94. """ This method converts an unicode character to an image (using Font
  95. Awesome font by default) and is used only for mass mailing because
  96. custom fonts are not supported in mail.
  97. :param icon : decimal encoding of unicode character
  98. :param color : RGB code of the color
  99. :param bg : RGB code of the background color
  100. :param size : Pixels in integer
  101. :param alpha : transparency of the image from 0 to 255
  102. :param font : font path
  103. :param width : Pixels in integer
  104. :param height : Pixels in integer
  105. :returns PNG image converted from given font
  106. """
  107. size = max(width, height, 1) if width else size
  108. width = width or size
  109. height = height or size
  110. # Make sure we have at least size=1
  111. width = max(1, min(width, 512))
  112. height = max(1, min(height, 512))
  113. # Initialize font
  114. if font.startswith('/'):
  115. font = font[1:]
  116. font_obj = ImageFont.truetype(file_open(font, 'rb'), height)
  117. # if received character is not a number, keep old behaviour (icon is character)
  118. icon = chr(int(icon)) if icon.isdigit() else icon
  119. # Background standardization
  120. if bg is not None and bg.startswith('rgba'):
  121. bg = bg.replace('rgba', 'rgb')
  122. bg = ','.join(bg.split(',')[:-1])+')'
  123. # Convert the opacity value compatible with PIL Image color (0 to 255)
  124. # when color specifier is 'rgba'
  125. if color is not None and color.startswith('rgba'):
  126. *rgb, a = color.strip(')').split(',')
  127. opacity = str(floor(float(a) * 255))
  128. color = ','.join([*rgb, opacity]) + ')'
  129. # Determine the dimensions of the icon
  130. image = Image.new("RGBA", (width, height), color)
  131. draw = ImageDraw.Draw(image)
  132. boxw, boxh = draw.textsize(icon, font=font_obj)
  133. draw.text((0, 0), icon, font=font_obj)
  134. left, top, right, bottom = image.getbbox()
  135. # Create an alpha mask
  136. imagemask = Image.new("L", (boxw, boxh), 0)
  137. drawmask = ImageDraw.Draw(imagemask)
  138. drawmask.text((-left, -top), icon, font=font_obj, fill=255)
  139. # Create a solid color image and apply the mask
  140. if color.startswith('rgba'):
  141. color = color.replace('rgba', 'rgb')
  142. color = ','.join(color.split(',')[:-1])+')'
  143. iconimage = Image.new("RGBA", (boxw, boxh), color)
  144. iconimage.putalpha(imagemask)
  145. # Create output image
  146. outimage = Image.new("RGBA", (boxw, height), bg or (0, 0, 0, 0))
  147. outimage.paste(iconimage, (left, top), iconimage)
  148. # output image
  149. output = io.BytesIO()
  150. outimage.save(output, format="PNG")
  151. response = Response()
  152. response.mimetype = 'image/png'
  153. response.data = output.getvalue()
  154. response.headers['Cache-Control'] = 'public, max-age=604800'
  155. response.headers['Access-Control-Allow-Origin'] = '*'
  156. response.headers['Access-Control-Allow-Methods'] = 'GET, POST'
  157. response.headers['Connection'] = 'close'
  158. response.headers['Date'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime())
  159. response.headers['Expires'] = time.strftime("%a, %d-%b-%Y %T GMT", time.gmtime(time.time()+604800*60))
  160. return response
  161. #------------------------------------------------------
  162. # Update a checklist in the editor on check/uncheck
  163. #------------------------------------------------------
  164. @http.route('/web_editor/checklist', type='json', auth='user')
  165. def update_checklist(self, res_model, res_id, filename, checklistId, checked, **kwargs):
  166. record = request.env[res_model].browse(res_id)
  167. value = filename in record._fields and record[filename]
  168. htmlelem = etree.fromstring("<div>%s</div>" % value, etree.HTMLParser())
  169. checked = bool(checked)
  170. li = htmlelem.find(".//li[@id='checkId-%s']" % checklistId)
  171. if li is None:
  172. return value
  173. classname = li.get('class', '')
  174. if ('o_checked' in classname) != checked:
  175. if checked:
  176. classname = '%s o_checked' % classname
  177. else:
  178. classname = re.sub(r"\s?o_checked\s?", '', classname)
  179. li.set('class', classname)
  180. else:
  181. return value
  182. value = etree.tostring(htmlelem[0][0], encoding='utf-8', method='html')[5:-6].decode("utf-8")
  183. record.write({filename: value})
  184. return value
  185. #------------------------------------------------------
  186. # Update a stars rating in the editor on check/uncheck
  187. #------------------------------------------------------
  188. @http.route('/web_editor/stars', type='json', auth='user')
  189. def update_stars(self, res_model, res_id, filename, starsId, rating):
  190. record = request.env[res_model].browse(res_id)
  191. value = filename in record._fields and record[filename]
  192. htmlelem = etree.fromstring("<div>%s</div>" % value, etree.HTMLParser())
  193. stars_widget = htmlelem.find(".//span[@id='checkId-%s']" % starsId)
  194. if stars_widget is None:
  195. return value
  196. # Check the `rating` first stars and uncheck the others if any.
  197. stars = []
  198. for star in stars_widget.getchildren():
  199. if 'fa-star' in star.get('class', ''):
  200. stars.append(star)
  201. star_index = 0
  202. for star in stars:
  203. classname = star.get('class', '')
  204. if star_index < rating and (not 'fa-star' in classname or 'fa-star-o' in classname):
  205. classname = re.sub(r"\s?fa-star-o\s?", '', classname)
  206. classname = '%s fa-star' % classname
  207. star.set('class', classname)
  208. elif star_index >= rating and not 'fa-star-o' in classname:
  209. classname = re.sub(r"\s?fa-star\s?", '', classname)
  210. classname = '%s fa-star-o' % classname
  211. star.set('class', classname)
  212. star_index += 1
  213. value = etree.tostring(htmlelem[0][0], encoding='utf-8', method='html')[5:-6]
  214. record.write({filename: value})
  215. return value
  216. @http.route('/web_editor/video_url/data', type='json', auth='user', website=True)
  217. def video_url_data(self, video_url, autoplay=False, loop=False,
  218. hide_controls=False, hide_fullscreen=False, hide_yt_logo=False,
  219. hide_dm_logo=False, hide_dm_share=False):
  220. if not request.env.user._is_internal():
  221. raise werkzeug.exceptions.Forbidden()
  222. return get_video_url_data(
  223. video_url, autoplay=autoplay, loop=loop,
  224. hide_controls=hide_controls, hide_fullscreen=hide_fullscreen,
  225. hide_yt_logo=hide_yt_logo, hide_dm_logo=hide_dm_logo,
  226. hide_dm_share=hide_dm_share
  227. )
  228. @http.route('/web_editor/attachment/add_data', type='json', auth='user', methods=['POST'], website=True)
  229. def add_data(self, name, data, is_image, quality=0, width=0, height=0, res_id=False, res_model='ir.ui.view', **kwargs):
  230. data = b64decode(data)
  231. if is_image:
  232. format_error_msg = _("Uploaded image's format is not supported. Try with: %s", ', '.join(SUPPORTED_IMAGE_EXTENSIONS))
  233. try:
  234. data = tools.image_process(data, size=(width, height), quality=quality, verify_resolution=True)
  235. mimetype = guess_mimetype(data)
  236. if mimetype not in SUPPORTED_IMAGE_MIMETYPES:
  237. return {'error': format_error_msg}
  238. except UserError:
  239. # considered as an image by the browser file input, but not
  240. # recognized as such by PIL, eg .webp
  241. return {'error': format_error_msg}
  242. except ValueError as e:
  243. return {'error': e.args[0]}
  244. self._clean_context()
  245. attachment = self._attachment_create(name=name, data=data, res_id=res_id, res_model=res_model)
  246. return attachment._get_media_info()
  247. @http.route('/web_editor/attachment/add_url', type='json', auth='user', methods=['POST'], website=True)
  248. def add_url(self, url, res_id=False, res_model='ir.ui.view', **kwargs):
  249. self._clean_context()
  250. attachment = self._attachment_create(url=url, res_id=res_id, res_model=res_model)
  251. return attachment._get_media_info()
  252. @http.route('/web_editor/attachment/remove', type='json', auth='user', website=True)
  253. def remove(self, ids, **kwargs):
  254. """ Removes a web-based image attachment if it is used by no view (template)
  255. Returns a dict mapping attachments which would not be removed (if any)
  256. mapped to the views preventing their removal
  257. """
  258. self._clean_context()
  259. Attachment = attachments_to_remove = request.env['ir.attachment']
  260. Views = request.env['ir.ui.view']
  261. # views blocking removal of the attachment
  262. removal_blocked_by = {}
  263. for attachment in Attachment.browse(ids):
  264. # in-document URLs are html-escaped, a straight search will not
  265. # find them
  266. url = tools.html_escape(attachment.local_url)
  267. views = Views.search([
  268. "|",
  269. ('arch_db', 'like', '"%s"' % url),
  270. ('arch_db', 'like', "'%s'" % url)
  271. ])
  272. if views:
  273. removal_blocked_by[attachment.id] = views.read(['name'])
  274. else:
  275. attachments_to_remove += attachment
  276. if attachments_to_remove:
  277. attachments_to_remove.unlink()
  278. return removal_blocked_by
  279. @http.route('/web_editor/get_image_info', type='json', auth='user', website=True)
  280. def get_image_info(self, src=''):
  281. """This route is used to determine the original of an attachment so that
  282. it can be used as a base to modify it again (crop/optimization/filters).
  283. """
  284. attachment = None
  285. if src.startswith('/web/image'):
  286. with contextlib.suppress(werkzeug.exceptions.NotFound, MissingError):
  287. _, args = request.env['ir.http']._match(src)
  288. record = request.env['ir.binary']._find_record(
  289. xmlid=args.get('xmlid'),
  290. res_model=args.get('model', 'ir.attachment'),
  291. res_id=args.get('id'),
  292. )
  293. if record._name == 'ir.attachment':
  294. attachment = record
  295. if not attachment:
  296. # Find attachment by url. There can be multiple matches because of default
  297. # snippet images referencing the same image in /static/, so we limit to 1
  298. attachment = request.env['ir.attachment'].search([
  299. '|', ('url', '=like', src), ('url', '=like', '%s?%%' % src),
  300. ('mimetype', 'in', SUPPORTED_IMAGE_MIMETYPES),
  301. ], limit=1)
  302. if not attachment:
  303. return {
  304. 'attachment': False,
  305. 'original': False,
  306. }
  307. return {
  308. 'attachment': attachment.read(['id'])[0],
  309. 'original': (attachment.original_id or attachment).read(['id', 'image_src', 'mimetype'])[0],
  310. }
  311. def _attachment_create(self, name='', data=False, url=False, res_id=False, res_model='ir.ui.view'):
  312. """Create and return a new attachment."""
  313. IrAttachment = request.env['ir.attachment']
  314. if name.lower().endswith('.bmp'):
  315. # Avoid mismatch between content type and mimetype, see commit msg
  316. name = name[:-4]
  317. if not name and url:
  318. name = url.split("/").pop()
  319. if res_model != 'ir.ui.view' and res_id:
  320. res_id = int(res_id)
  321. else:
  322. res_id = False
  323. attachment_data = {
  324. 'name': name,
  325. 'public': res_model == 'ir.ui.view',
  326. 'res_id': res_id,
  327. 'res_model': res_model,
  328. }
  329. if data:
  330. attachment_data['raw'] = data
  331. if url:
  332. attachment_data['url'] = url
  333. elif url:
  334. attachment_data.update({
  335. 'type': 'url',
  336. 'url': url,
  337. })
  338. else:
  339. raise UserError(_("You need to specify either data or url to create an attachment."))
  340. # Despite the user having no right to create an attachment, he can still
  341. # create an image attachment through some flows
  342. if (
  343. not request.env.is_admin()
  344. and IrAttachment._can_bypass_rights_on_media_dialog(**attachment_data)
  345. ):
  346. attachment = IrAttachment.sudo().create(attachment_data)
  347. # When portal users upload an attachment with the wysiwyg widget,
  348. # the access token is needed to use the image in the editor. If
  349. # the attachment is not public, the user won't be able to generate
  350. # the token, so we need to generate it using sudo
  351. if not attachment_data['public']:
  352. attachment.sudo().generate_access_token()
  353. else:
  354. attachment = IrAttachment.create(attachment_data)
  355. return attachment
  356. def _clean_context(self):
  357. # avoid allowed_company_ids which may erroneously restrict based on website
  358. context = dict(request.context)
  359. context.pop('allowed_company_ids', None)
  360. request.update_env(context=context)
  361. @http.route("/web_editor/get_assets_editor_resources", type="json", auth="user", website=True)
  362. def get_assets_editor_resources(self, key, get_views=True, get_scss=True, get_js=True, bundles=False, bundles_restriction=[], only_user_custom_files=True):
  363. """
  364. Transmit the resources the assets editor needs to work.
  365. Params:
  366. key (str): the key of the view the resources are related to
  367. get_views (bool, default=True):
  368. True if the views must be fetched
  369. get_scss (bool, default=True):
  370. True if the style must be fetched
  371. get_js (bool, default=True):
  372. True if the javascript must be fetched
  373. bundles (bool, default=False):
  374. True if the bundles views must be fetched
  375. bundles_restriction (list, default=[]):
  376. Names of the bundles in which to look for scss files
  377. (if empty, search in all of them)
  378. only_user_custom_files (bool, default=True):
  379. True if only user custom files must be fetched
  380. Returns:
  381. dict: views, scss, js
  382. """
  383. # Related views must be fetched if the user wants the views and/or the style
  384. views = request.env["ir.ui.view"].with_context(no_primary_children=True, __views_get_original_hierarchy=[]).get_related_views(key, bundles=bundles)
  385. views = views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id'])
  386. scss_files_data_by_bundle = []
  387. js_files_data_by_bundle = []
  388. if get_scss:
  389. scss_files_data_by_bundle = self._load_resources('scss', views, bundles_restriction, only_user_custom_files)
  390. if get_js:
  391. js_files_data_by_bundle = self._load_resources('js', views, bundles_restriction, only_user_custom_files)
  392. return {
  393. 'views': get_views and views or [],
  394. 'scss': get_scss and scss_files_data_by_bundle or [],
  395. 'js': get_js and js_files_data_by_bundle or [],
  396. }
  397. def _load_resources(self, file_type, views, bundles_restriction, only_user_custom_files):
  398. AssetsUtils = request.env['web_editor.assets']
  399. files_data_by_bundle = []
  400. resources_type_info = {'t_call_assets_attribute': 't-js', 'mimetype': 'text/javascript'}
  401. if file_type == 'scss':
  402. resources_type_info = {'t_call_assets_attribute': 't-css', 'mimetype': 'text/scss'}
  403. # Compile regex outside of the loop
  404. # This will used to exclude library scss files from the result
  405. excluded_url_matcher = re.compile("^(.+/lib/.+)|(.+import_bootstrap.+\.scss)$")
  406. # First check the t-call-assets used in the related views
  407. url_infos = dict()
  408. for v in views:
  409. for asset_call_node in etree.fromstring(v["arch"]).xpath("//t[@t-call-assets]"):
  410. attr = asset_call_node.get(resources_type_info['t_call_assets_attribute'])
  411. if attr and not json.loads(attr.lower()):
  412. continue
  413. asset_name = asset_call_node.get("t-call-assets")
  414. # Loop through bundle files to search for file info
  415. files_data = []
  416. for file_info in request.env["ir.qweb"]._get_asset_content(asset_name)[0]:
  417. if file_info["atype"] != resources_type_info['mimetype']:
  418. continue
  419. url = file_info["url"]
  420. # Exclude library files (see regex above)
  421. if excluded_url_matcher.match(url):
  422. continue
  423. # Check if the file is customized and get bundle/path info
  424. file_data = AssetsUtils._get_data_from_url(url)
  425. if not file_data:
  426. continue
  427. # Save info according to the filter (arch will be fetched later)
  428. url_infos[url] = file_data
  429. if '/user_custom_' in url \
  430. or file_data['customized'] \
  431. or file_type == 'scss' and not only_user_custom_files:
  432. files_data.append(url)
  433. # scss data is returned sorted by bundle, with the bundles
  434. # names and xmlids
  435. if len(files_data):
  436. files_data_by_bundle.append([asset_name, files_data])
  437. # Filter bundles/files:
  438. # - A file which appears in multiple bundles only appears in the
  439. # first one (the first in the DOM)
  440. # - Only keep bundles with files which appears in the asked bundles
  441. # and only keep those files
  442. for i in range(0, len(files_data_by_bundle)):
  443. bundle_1 = files_data_by_bundle[i]
  444. for j in range(0, len(files_data_by_bundle)):
  445. bundle_2 = files_data_by_bundle[j]
  446. # In unwanted bundles, keep only the files which are in wanted bundles too (web._helpers)
  447. if bundle_1[0] not in bundles_restriction and bundle_2[0] in bundles_restriction:
  448. bundle_1[1] = [item_1 for item_1 in bundle_1[1] if item_1 in bundle_2[1]]
  449. for i in range(0, len(files_data_by_bundle)):
  450. bundle_1 = files_data_by_bundle[i]
  451. for j in range(i + 1, len(files_data_by_bundle)):
  452. bundle_2 = files_data_by_bundle[j]
  453. # In every bundle, keep only the files which were not found
  454. # in previous bundles
  455. bundle_2[1] = [item_2 for item_2 in bundle_2[1] if item_2 not in bundle_1[1]]
  456. # Only keep bundles which still have files and that were requested
  457. files_data_by_bundle = [
  458. data for data in files_data_by_bundle
  459. if (len(data[1]) > 0 and (not bundles_restriction or data[0] in bundles_restriction))
  460. ]
  461. # Fetch the arch of each kept file, in each bundle
  462. urls = []
  463. for bundle_data in files_data_by_bundle:
  464. urls += bundle_data[1]
  465. custom_attachments = AssetsUtils._get_custom_attachment(urls, op='in')
  466. for bundle_data in files_data_by_bundle:
  467. for i in range(0, len(bundle_data[1])):
  468. url = bundle_data[1][i]
  469. url_info = url_infos[url]
  470. content = AssetsUtils._get_content_from_url(url, url_info, custom_attachments)
  471. bundle_data[1][i] = {
  472. 'url': "/%s/%s" % (url_info["module"], url_info["resource_path"]),
  473. 'arch': content,
  474. 'customized': url_info["customized"],
  475. }
  476. return files_data_by_bundle
  477. @http.route('/web_editor/modify_image/<model("ir.attachment"):attachment>', type="json", auth="user", website=True)
  478. def modify_image(self, attachment, res_model=None, res_id=None, name=None, data=None, original_id=None, mimetype=None):
  479. """
  480. Creates a modified copy of an attachment and returns its image_src to be
  481. inserted into the DOM.
  482. """
  483. fields = {
  484. 'original_id': attachment.id,
  485. 'datas': data,
  486. 'type': 'binary',
  487. 'res_model': res_model or 'ir.ui.view',
  488. 'mimetype': mimetype or attachment.mimetype,
  489. }
  490. if fields['res_model'] == 'ir.ui.view':
  491. fields['res_id'] = 0
  492. elif res_id:
  493. fields['res_id'] = res_id
  494. if name:
  495. fields['name'] = name
  496. attachment = attachment.copy(fields)
  497. if attachment.url:
  498. # Don't keep url if modifying static attachment because static images
  499. # are only served from disk and don't fallback to attachments.
  500. if re.match(r'^/\w+/static/', attachment.url):
  501. attachment.url = None
  502. # Uniquify url by adding a path segment with the id before the name.
  503. # This allows us to keep the unsplash url format so it still reacts
  504. # to the unsplash beacon.
  505. else:
  506. url_fragments = attachment.url.split('/')
  507. url_fragments.insert(-1, str(attachment.id))
  508. attachment.url = '/'.join(url_fragments)
  509. if attachment.public:
  510. return attachment.image_src
  511. attachment.generate_access_token()
  512. return '%s?access_token=%s' % (attachment.image_src, attachment.access_token)
  513. def _get_shape_svg(self, module, *segments):
  514. shape_path = get_resource_path(module, 'static', *segments)
  515. if not shape_path:
  516. raise werkzeug.exceptions.NotFound()
  517. with tools.file_open(shape_path, 'r', filter_ext=('.svg',)) as file:
  518. return file.read()
  519. def _update_svg_colors(self, options, svg):
  520. user_colors = []
  521. svg_options = {}
  522. default_palette = {
  523. '1': '#3AADAA',
  524. '2': '#7C6576',
  525. '3': '#F6F6F6',
  526. '4': '#FFFFFF',
  527. '5': '#383E45',
  528. }
  529. bundle_css = None
  530. regex_hex = r'#[0-9A-F]{6,8}'
  531. regex_rgba = r'rgba?\(\d{1,3},\d{1,3},\d{1,3}(?:,[0-9.]{1,4})?\)'
  532. for key, value in options.items():
  533. colorMatch = re.match('^c([1-5])$', key)
  534. if colorMatch:
  535. css_color_value = value
  536. # Check that color is hex or rgb(a) to prevent arbitrary injection
  537. if not re.match(r'(?i)^%s$|^%s$' % (regex_hex, regex_rgba), css_color_value.replace(' ', '')):
  538. if re.match('^o-color-([1-5])$', css_color_value):
  539. if not bundle_css:
  540. bundle = 'web.assets_frontend'
  541. files, _ = request.env["ir.qweb"]._get_asset_content(bundle)
  542. asset = AssetsBundle(bundle, files)
  543. bundle_css = asset.css().index_content
  544. color_search = re.search(r'(?i)--%s:\s+(%s|%s)' % (css_color_value, regex_hex, regex_rgba), bundle_css)
  545. if not color_search:
  546. raise werkzeug.exceptions.BadRequest()
  547. css_color_value = color_search.group(1)
  548. else:
  549. raise werkzeug.exceptions.BadRequest()
  550. user_colors.append([tools.html_escape(css_color_value), colorMatch.group(1)])
  551. else:
  552. svg_options[key] = value
  553. color_mapping = {default_palette[palette_number]: color for color, palette_number in user_colors}
  554. # create a case-insensitive regex to match all the colors to replace, eg: '(?i)(#3AADAA)|(#7C6576)'
  555. regex = '(?i)%s' % '|'.join('(%s)' % color for color in color_mapping.keys())
  556. def subber(match):
  557. key = match.group().upper()
  558. return color_mapping[key] if key in color_mapping else key
  559. return re.sub(regex, subber, svg), svg_options
  560. @http.route(['/web_editor/shape/<module>/<path:filename>'], type='http', auth="public", website=True)
  561. def shape(self, module, filename, **kwargs):
  562. """
  563. Returns a color-customized svg (background shape or illustration).
  564. """
  565. svg = None
  566. if module == 'illustration':
  567. attachment = request.env['ir.attachment'].sudo().browse(unslug(filename)[1])
  568. if (not attachment.exists()
  569. or attachment.type != 'binary'
  570. or not attachment.public
  571. or not attachment.url.startswith(request.httprequest.path)):
  572. # Fallback to URL lookup to allow using shapes that were
  573. # imported from data files.
  574. attachment = request.env['ir.attachment'].sudo().search([
  575. ('type', '=', 'binary'),
  576. ('public', '=', True),
  577. ('url', '=', request.httprequest.path),
  578. ], limit=1)
  579. if not attachment:
  580. raise werkzeug.exceptions.NotFound()
  581. svg = attachment.raw.decode('utf-8')
  582. else:
  583. svg = self._get_shape_svg(module, 'shapes', filename)
  584. svg, options = self._update_svg_colors(kwargs, svg)
  585. flip_value = options.get('flip', False)
  586. if flip_value == 'x':
  587. svg = svg.replace('<svg ', '<svg style="transform: scaleX(-1);" ', 1)
  588. elif flip_value == 'y':
  589. svg = svg.replace('<svg ', '<svg style="transform: scaleY(-1)" ', 1)
  590. elif flip_value == 'xy':
  591. svg = svg.replace('<svg ', '<svg style="transform: scale(-1)" ', 1)
  592. return request.make_response(svg, [
  593. ('Content-type', 'image/svg+xml'),
  594. ('Cache-control', 'max-age=%s' % http.STATIC_CACHE_LONG),
  595. ])
  596. @http.route(['/web_editor/image_shape/<string:img_key>/<module>/<path:filename>'], type='http', auth="public", website=True)
  597. def image_shape(self, module, filename, img_key, **kwargs):
  598. svg = self._get_shape_svg(module, 'image_shapes', filename)
  599. record = request.env['ir.binary']._find_record(img_key)
  600. stream = request.env['ir.binary']._get_image_stream_from(record)
  601. if stream.type == 'url':
  602. return stream.get_response()
  603. image = stream.read()
  604. img = binary_to_image(image)
  605. width, height = tuple(str(size) for size in img.size)
  606. root = etree.fromstring(svg)
  607. root.attrib.update({'width': width, 'height': height})
  608. # Update default color palette on shape SVG.
  609. svg, _ = self._update_svg_colors(kwargs, etree.tostring(root, pretty_print=True).decode('utf-8'))
  610. # Add image in base64 inside the shape.
  611. uri = image_data_uri(b64encode(image))
  612. svg = svg.replace('<image xlink:href="', '<image xlink:href="%s' % uri)
  613. return request.make_response(svg, [
  614. ('Content-type', 'image/svg+xml'),
  615. ('Cache-control', 'max-age=%s' % http.STATIC_CACHE_LONG),
  616. ])
  617. @http.route(['/web_editor/media_library_search'], type='json', auth="user", website=True)
  618. def media_library_search(self, **params):
  619. ICP = request.env['ir.config_parameter'].sudo()
  620. endpoint = ICP.get_param('web_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT)
  621. params['dbuuid'] = ICP.get_param('database.uuid')
  622. response = requests.post('%s/media-library/1/search' % endpoint, data=params)
  623. if response.status_code == requests.codes.ok and response.headers['content-type'] == 'application/json':
  624. return response.json()
  625. else:
  626. return {'error': response.status_code}
  627. @http.route('/web_editor/save_library_media', type='json', auth='user', methods=['POST'])
  628. def save_library_media(self, media):
  629. """
  630. Saves images from the media library as new attachments, making them
  631. dynamic SVGs if needed.
  632. media = {
  633. <media_id>: {
  634. 'query': 'space separated search terms',
  635. 'is_dynamic_svg': True/False,
  636. 'dynamic_colors': maps color names to their color,
  637. }, ...
  638. }
  639. """
  640. attachments = []
  641. ICP = request.env['ir.config_parameter'].sudo()
  642. library_endpoint = ICP.get_param('web_editor.media_library_endpoint', DEFAULT_LIBRARY_ENDPOINT)
  643. media_ids = ','.join(media.keys())
  644. params = {
  645. 'dbuuid': ICP.get_param('database.uuid'),
  646. 'media_ids': media_ids,
  647. }
  648. response = requests.post('%s/media-library/1/download_urls' % library_endpoint, data=params)
  649. if response.status_code != requests.codes.ok:
  650. raise Exception(_("ERROR: couldn't get download urls from media library."))
  651. for id, url in response.json().items():
  652. req = requests.get(url)
  653. name = '_'.join([media[id]['query'], url.split('/')[-1]])
  654. # Need to bypass security check to write image with mimetype image/svg+xml
  655. # ok because svgs come from whitelisted origin
  656. context = {'binary_field_real_user': request.env['res.users'].sudo().browse([SUPERUSER_ID])}
  657. attachment = request.env['ir.attachment'].sudo().with_context(context).create({
  658. 'name': name,
  659. 'mimetype': req.headers['content-type'],
  660. 'datas': b64encode(req.content),
  661. 'public': True,
  662. 'res_model': 'ir.ui.view',
  663. 'res_id': 0,
  664. })
  665. if media[id]['is_dynamic_svg']:
  666. colorParams = werkzeug.urls.url_encode(media[id]['dynamic_colors'])
  667. attachment['url'] = '/web_editor/shape/illustration/%s?%s' % (slug(attachment), colorParams)
  668. attachments.append(attachment._get_media_info())
  669. return attachments
  670. @http.route("/web_editor/get_ice_servers", type='json', auth="user")
  671. def get_ice_servers(self):
  672. return request.env['mail.ice.server']._get_ice_servers()
  673. @http.route("/web_editor/bus_broadcast", type="json", auth="user")
  674. def bus_broadcast(self, model_name, field_name, res_id, bus_data):
  675. document = request.env[model_name].browse([res_id])
  676. document.check_access_rights('read')
  677. document.check_field_access_rights('read', [field_name])
  678. document.check_access_rule('read')
  679. document.check_access_rights('write')
  680. document.check_field_access_rights('write', [field_name])
  681. document.check_access_rule('write')
  682. channel = (request.db, 'editor_collaboration', model_name, field_name, int(res_id))
  683. bus_data.update({'model_name': model_name, 'field_name': field_name, 'res_id': res_id})
  684. request.env['bus.bus']._sendone(channel, 'editor_collaboration', bus_data)
  685. @http.route('/web_editor/tests', type='http', auth="user")
  686. def test_suite(self, mod=None, **kwargs):
  687. return request.render('web_editor.tests')
  688. @http.route("/web_editor/ensure_common_history", type="json", auth="user")
  689. def ensure_common_history(self, model_name, field_name, res_id, history_ids):
  690. record = request.env[model_name].browse([res_id])
  691. try:
  692. ensure_no_history_divergence(record, field_name, history_ids)
  693. except ValidationError:
  694. return record[field_name]