diff --git a/setup/web_editor_clean_url/odoo/addons/web_editor_clean_url b/setup/web_editor_clean_url/odoo/addons/web_editor_clean_url new file mode 120000 index 0000000000..82da38eae8 --- /dev/null +++ b/setup/web_editor_clean_url/odoo/addons/web_editor_clean_url @@ -0,0 +1 @@ +../../../../web_editor_clean_url \ No newline at end of file diff --git a/setup/web_editor_clean_url/setup.py b/setup/web_editor_clean_url/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/web_editor_clean_url/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/web_editor_clean_url/README.rst b/web_editor_clean_url/README.rst new file mode 100644 index 0000000000..6e6ae71913 --- /dev/null +++ b/web_editor_clean_url/README.rst @@ -0,0 +1,120 @@ +============================ +Clean URLs in website editor +============================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2f68fbe3909f6e172b2af03d877a484e1b3690fd85a91b130e4ab51229b28034 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwebsite-lightgray.png?logo=github + :target: https://github.com/OCA/website/tree/16.0/web_editor_clean_url + :alt: OCA/website +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/website-16-0/website-16-0-web_editor_clean_url + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/website&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows administrators to configure cleanup rules for links +inserted by their users, as inexperienced people tend to just copy&paste +whatever they find in their address bar, which might contain data you do +not want to see published on your website, be it tracking tokens or +session identifiers. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +You find the configuration in Website / Configuration / Clean URLs. + +For every configuration, you can fill in: + +``Hostname``: allowing to restrict this configuration to some domain + +``Remove Query Parameter``: removes parameters from the URL. Separate +with comma + +``Replace Regex``: replaces a whole portion of the url + +``Substitution``: if the regex contains brackets, you can refer to the +matched content with \\1, \\2 etc + +Usage +===== + +To use this module, you need to: + +1. Review the configuration +2. Edit a website page, insert links and observe they are transformed as + configured after saving + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Hunki Enterprises BV + +Contributors +------------ + +- Holger Brunn + (https://hunki-enterprises.com) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +.. |maintainer-hbrunn| image:: https://github.com/hbrunn.png?size=40px + :target: https://github.com/hbrunn + :alt: hbrunn + +Current `maintainer `__: + +|maintainer-hbrunn| + +This module is part of the `OCA/website `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_editor_clean_url/__init__.py b/web_editor_clean_url/__init__.py new file mode 100644 index 0000000000..e035fa5bd3 --- /dev/null +++ b/web_editor_clean_url/__init__.py @@ -0,0 +1,6 @@ +# from . import controllers +from . import models + +# from . import report +# from . import wizards +# from .hooks import post_init_hook, post_load, pre_init_hook, uninstall_hook diff --git a/web_editor_clean_url/__manifest__.py b/web_editor_clean_url/__manifest__.py new file mode 100644 index 0000000000..321e6228d1 --- /dev/null +++ b/web_editor_clean_url/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +{ + "name": "Clean URLs in website editor", + "summary": "Remove tracking, ephemeral and similar parameters from URLs", + "version": "16.0.1.0.0", + "development_status": "Alpha", + "category": "Website/Website", + "website": "https://github.com/OCA/website", + "author": "Hunki Enterprises BV, Odoo Community Association (OCA)", + "maintainers": ["hbrunn"], + "license": "AGPL-3", + "depends": [ + "website", + ], + "data": [ + "data/ir_actions_server.xml", + "data/web_editor_clean_url_configuration.xml", + "security/ir.model.access.csv", + "views/web_editor_clean_url_configuration.xml", + "views/menu.xml", + ], +} diff --git a/web_editor_clean_url/data/ir_actions_server.xml b/web_editor_clean_url/data/ir_actions_server.xml new file mode 100644 index 0000000000..d93baa810b --- /dev/null +++ b/web_editor_clean_url/data/ir_actions_server.xml @@ -0,0 +1,13 @@ + + + + + Apply to all views + + code + records.apply_all() + + action + + diff --git a/web_editor_clean_url/data/web_editor_clean_url_configuration.xml b/web_editor_clean_url/data/web_editor_clean_url_configuration.xml new file mode 100644 index 0000000000..53f5e2b456 --- /dev/null +++ b/web_editor_clean_url/data/web_editor_clean_url_configuration.xml @@ -0,0 +1,8 @@ + + + + + fbclid + + diff --git a/web_editor_clean_url/models/__init__.py b/web_editor_clean_url/models/__init__.py new file mode 100644 index 0000000000..e87de6ba0a --- /dev/null +++ b/web_editor_clean_url/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_ui_view +from . import web_editor_clean_url_configuration diff --git a/web_editor_clean_url/models/ir_ui_view.py b/web_editor_clean_url/models/ir_ui_view.py new file mode 100644 index 0000000000..594fd01d2c --- /dev/null +++ b/web_editor_clean_url/models/ir_ui_view.py @@ -0,0 +1,60 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + + +from lxml import html + +from odoo import models + + +class IrUiView(models.Model): + _inherit = "ir.ui.view" + + def save(self, value, xpath=None): + arch = html.fromstring(value, parser=html.HTMLParser(encoding="utf-8")) + value = html.tostring(self._web_editor_clean_url_clean_arch(arch)) + return super().save(value, xpath) + + def _web_editor_clean_url_clean_arch(self, arch, configurations=None): + """ + Clean all urls in given arch + """ + for xpath, attributes in self._web_editor_clean_url_selectors(): + for element in arch.xpath(xpath): + self._web_editor_clean_url_clean_element( + element, attributes, configurations=configurations + ) + return arch + + def _web_editor_clean_url_selectors(self): + """ + Return xpaths for to find elements with urls to be cleaned and the attributes + containing them + """ + return [ + ("//a[@href]", ("href",)), + ] + + def _web_editor_clean_url_clean_element( + self, element, attributes, configurations=None + ): + """ + Clean urls of element found in attributes + """ + for attribute in attributes: + if not element.get(attribute): + continue + element.attrib[attribute] = self._web_editor_clean_url( + element.attrib[attribute], + configurations=configurations, + ) + + def _web_editor_clean_url(self, url, configurations=None): + """ + Clean url + """ + for configuration in configurations or self.env[ + "web.editor.clean.url.configuration" + ].sudo().search([]): + url = configuration.clean_url(url) + return url diff --git a/web_editor_clean_url/models/web_editor_clean_url_configuration.py b/web_editor_clean_url/models/web_editor_clean_url_configuration.py new file mode 100644 index 0000000000..634dbf8dcf --- /dev/null +++ b/web_editor_clean_url/models/web_editor_clean_url_configuration.py @@ -0,0 +1,185 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +import re +from urllib.parse import urlparse, urlunparse + +from lxml import etree, html + +from odoo import api, exceptions, fields, models + + +class WebEditorCleanUrlConfiguration(models.Model): + _name = "web.editor.clean.url.configuration" + _description = "Clean URLs configuration" + _order = "sequence" + + active = fields.Boolean(default=True) + sequence = fields.Integer() + hostname = fields.Char( + help="Fill in a domain to restrict this configuration to this domain", + ) + query_parameter_separator = fields.Char(default="&") + remove_query_parameter = fields.Char( + help="Fill in comma separated query parameters to remove" + ) + replace_regex = fields.Char(help="Fill in a regex applied to the whole URL") + replace_regex_sub = fields.Char( + "Substitution", + help="Fill in a substitution for the regex above. \\1, \\2 etc contain matched " + "content from brackets", + ) + test_url = fields.Char(store=False) + preview_url = fields.Char(compute="_compute_preview_url") + + @api.depends(lambda self: self._fields) + def _compute_preview_url(self): + for this in self: + try: + this.preview_url = ( + this.test_url and this.clean_url(this.test_url) or False + ) + except Exception as e: + raise exceptions.ValidationError(e) from e + + @api.constrains(lambda self: (f for f in self._fields if self._fields[f].store)) + def _check_all(self): + for this in self: + try: + # flake8 complains about missing whilespace after : + this.clean_url(f"https://{this.hostname or 'test.com'}") # noqa: E231 + except Exception as e: + raise exceptions.ValidationError(e) from e + + def name_get(self): + return [ + ( + this.id, + this.hostname or this.remove_query_parameter or this.replace_regex, + ) + for this in self + ] + + def apply_all(self): + """ + Apply configurations in self to all views + """ + + def html_fromstring(src): + return html.fromstring( + f"
{src}
", parser=html.HTMLParser(encoding="utf8") + ) + + def html_tostring(doc): + return html.tostring(doc, encoding="utf8").decode("utf8")[5:-6] + + def xml_fromstring(src): + return etree.fromstring(src) + + def xml_tostring(doc): + return etree.tostring(doc, encoding="utf8").decode("utf8") + + IrUiView = self.env["ir.ui.view"] + for records, fieldname in self._apply_all_get_records_and_field(): + if isinstance(records._fields[fieldname], fields.Html): + fromstring = html_fromstring + tostring = html_tostring + else: + fromstring = xml_fromstring + tostring = xml_tostring + + for record in records: + new_content = tostring( + IrUiView._web_editor_clean_url_clean_arch( + fromstring(record[fieldname]), configurations=self + ) + ) + if str(record[fieldname]) != new_content: + record[fieldname] = new_content + + def _apply_all_get_records_and_field(self): + """ + Find all views and other records to apply a configuration on, + return records, fieldname to be processed + """ + result = [] + + views_with_xmlid = self.env["ir.ui.view"].browse( + self.env["ir.model.data"] + .search([("model", "=", "ir.ui.view")]) + .mapped("res_id") + ) + views = self.env["ir.ui.view"].search( + [("type", "=", "qweb")] + ) - views_with_xmlid.filtered(lambda x: not x.arch_updated) + result.append((views, "arch_db")) + + if "blog.post" in self.env: + result.append((self.env["blog.post"].search([]), "content")) + if "event.event" in self.env: + result.append((self.env["event.event"].search([]), "description")) + if "product.template" in self.env: + result.append((self.env["product.template"].search([]), "description")) + + return result + + def clean_url(self, url): + """ + Clean url + """ + if self._clean_url_ignore(url): + return url + parsed_url = urlparse(url) + hostnames = list(map(str.strip, filter(None, (self.hostname or "").split(",")))) + hostnames += [("www." + hostname) for hostname in hostnames] + if self.hostname and parsed_url.netloc not in hostnames: + return url + if self.remove_query_parameter: + parameters = self._parse_query_parameters(parsed_url) + for parameter in self.remove_query_parameter.split(","): + parameters.pop(parameter.strip(), None) + parsed_url = self._join_query_parameters(parsed_url, parameters) + if self.replace_regex: + parsed_url = urlparse( + re.sub( + self.replace_regex, + self.replace_regex_sub or "", + urlunparse(parsed_url), + ) + ) + return urlunparse(parsed_url) + + def _clean_url_ignore(self, url): + """ + Return true if url shouldn't be rewritten + """ + return url.startswith("#") or url.startswith("mailto:") + + def _parse_query_parameters(self, parsed_url): + """ + Return a dict of parameters for parsed_url + """ + result = {} + if not parsed_url.query: + return result + for parameter_tuple in (parsed_url.query or "").split( + self.query_parameter_separator + ): + split_parameter = parameter_tuple.split("=", 1) + if len(split_parameter) > 1: + name, value = split_parameter + else: + name = split_parameter[0] + value = "" + result[name] = value + return result + + def _join_query_parameters(self, parsed_url, parameters): + """ + Return a version of url with params replaced by parameters + """ + return parsed_url._replace( + query=self.query_parameter_separator.join( + "=".join(parameter_tuple) for parameter_tuple in parameters.items() + ) + ) diff --git a/web_editor_clean_url/readme/CONFIGURE.md b/web_editor_clean_url/readme/CONFIGURE.md new file mode 100644 index 0000000000..100e8a4c10 --- /dev/null +++ b/web_editor_clean_url/readme/CONFIGURE.md @@ -0,0 +1,11 @@ +You find the configuration in Website / Configuration / Clean URLs. + +For every configuration, you can fill in: + +`Hostname`: allowing to restrict this configuration to some domain + +`Remove Query Parameter`: removes parameters from the URL. Separate with comma + +`Replace Regex`: replaces a whole portion of the url + +`Substitution`: if the regex contains brackets, you can refer to the matched content with \1, \2 etc diff --git a/web_editor_clean_url/readme/CONTRIBUTORS.md b/web_editor_clean_url/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..b28199e1f4 --- /dev/null +++ b/web_editor_clean_url/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Holger Brunn \ (https://hunki-enterprises.com) diff --git a/web_editor_clean_url/readme/DESCRIPTION.md b/web_editor_clean_url/readme/DESCRIPTION.md new file mode 100644 index 0000000000..faeb5d72a2 --- /dev/null +++ b/web_editor_clean_url/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module allows administrators to configure cleanup rules for links inserted by their users, as inexperienced people tend to just copy&paste whatever they find in their address bar, which might contain data you do not want to see published on your website, be it tracking tokens or session identifiers. diff --git a/web_editor_clean_url/readme/USAGE.md b/web_editor_clean_url/readme/USAGE.md new file mode 100644 index 0000000000..8abb1a9776 --- /dev/null +++ b/web_editor_clean_url/readme/USAGE.md @@ -0,0 +1,4 @@ +To use this module, you need to: + +1. Review the configuration +2. Edit a website page, insert links and observe they are transformed as configured after saving diff --git a/web_editor_clean_url/security/ir.model.access.csv b/web_editor_clean_url/security/ir.model.access.csv new file mode 100644 index 0000000000..2e33342a48 --- /dev/null +++ b/web_editor_clean_url/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_web_editor_clean_url_configuration_manager,access_web_editor_clean_url_configuration_manager,model_web_editor_clean_url_configuration,base.group_system,1,1,1,1 diff --git a/web_editor_clean_url/static/description/icon.png b/web_editor_clean_url/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/web_editor_clean_url/static/description/icon.png differ diff --git a/web_editor_clean_url/static/description/index.html b/web_editor_clean_url/static/description/index.html new file mode 100644 index 0000000000..b9ae081962 --- /dev/null +++ b/web_editor_clean_url/static/description/index.html @@ -0,0 +1,458 @@ + + + + + +Clean URLs in website editor + + + +
+

Clean URLs in website editor

+ + +

Alpha License: AGPL-3 OCA/website Translate me on Weblate Try me on Runboat

+

This module allows administrators to configure cleanup rules for links +inserted by their users, as inexperienced people tend to just copy&paste +whatever they find in their address bar, which might contain data you do +not want to see published on your website, be it tracking tokens or +session identifiers.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+

You find the configuration in Website / Configuration / Clean URLs.

+

For every configuration, you can fill in:

+

Hostname: allowing to restrict this configuration to some domain

+

Remove Query Parameter: removes parameters from the URL. Separate +with comma

+

Replace Regex: replaces a whole portion of the url

+

Substitution: if the regex contains brackets, you can refer to the +matched content with \1, \2 etc

+
+
+

Usage

+

To use this module, you need to:

+
    +
  1. Review the configuration
  2. +
  3. Edit a website page, insert links and observe they are transformed as +configured after saving
  4. +
+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Hunki Enterprises BV
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

hbrunn

+

This module is part of the OCA/website project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_editor_clean_url/tests/__init__.py b/web_editor_clean_url/tests/__init__.py new file mode 100644 index 0000000000..258c946278 --- /dev/null +++ b/web_editor_clean_url/tests/__init__.py @@ -0,0 +1 @@ +from . import test_web_editor_clean_url diff --git a/web_editor_clean_url/tests/test_web_editor_clean_url.py b/web_editor_clean_url/tests/test_web_editor_clean_url.py new file mode 100644 index 0000000000..77bf3b8f48 --- /dev/null +++ b/web_editor_clean_url/tests/test_web_editor_clean_url.py @@ -0,0 +1,104 @@ +# Copyright 2026 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) +from lxml import etree + +from odoo import exceptions +from odoo.tests.common import Form, TransactionCase + + +class TestWebEditorCleanUrl(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + Configuration = cls.env["web.editor.clean.url.configuration"] + Configuration.search([]).active = False + cls.config = Configuration.create( + { + "hostname": "admafia.com", + "remove_query_parameter": "set,__cft__%5B0%5D,__tn__", + } + ) + cls.test_url = ( + "https://www.admafia.com/photo/?fbid=42&set=a&__cft__%5B0%5D=b&__tn__=c" + ) + cls.test_url_escaped = ( + "https://www.admafia.com/photo/?fbid=42&set=a&__cft__%5B0%5D=b" + "&__tn__=c" + ) + cls.cleaned_test_url = "https://www.admafia.com/photo/?fbid=42" + + def test_preview(self): + with Form(self.config) as form: + form.test_url = self.test_url + self.assertEqual(form.preview_url, self.cleaned_test_url) + + def test_failure(self): + with self.assertRaises(exceptions.ValidationError), Form(self.config) as form: + form.replace_regex = "?" + with self.assertRaises(exceptions.ValidationError), Form(self.config) as form: + form.replace_regex = "?" + form.test_url = "https://www.admafia.com/?debug" + + def test_apply_all(self): + view = self.env["ir.ui.view"].create( + { + "type": "qweb", + "arch_db": self.env["ir.qweb"]._render( + etree.fromstring( + "
" + '' + '' + '' + '' + "
" + ), + {"test_url": self.test_url}, + ), + } + ) + self.config.apply_all() + self.assertNotIn(self.test_url_escaped, view.arch_db) + self.assertIn(self.cleaned_test_url, view.arch_db) + self.assertIn('', view.arch_db) + self.assertIn('', view.arch_db) + self.assertIn(self.test_url_escaped, view.arch_prev) + + def test_replacement(self): + self.env["web.editor.clean.url.configuration"].create({}) + view = self.env["ir.ui.view"].create( + { + "type": "qweb", + "arch_db": "
", + } + ) + view.save( + f'', + xpath=".", + ) + self.assertNotIn(self.test_url_escaped, view.arch_db) + self.assertIn(self.cleaned_test_url, view.arch_db) + + def test_regex(self): + config = self.config + config.remove_query_parameter = False + config.replace_regex = r"\?fbid=([^&]+).*$" + config.replace_regex_sub = r"?fbid=\1" + config.test_url = self.test_url + self.assertEqual(config.preview_url, self.cleaned_test_url) + + def test_idempotence(self): + apply_all_records = [ + records + for records, _dummy in self.config._apply_all_get_records_and_field() + ] + all_views = sum( + [records for records in apply_all_records if records._name == "ir.ui.view"], + self.env["ir.ui.view"], + ) + all_views.write( + { + "arch_prev": False, + } + ) + self.config.apply_all() + self.assertFalse(any(all_views.mapped("arch_prev"))) diff --git a/web_editor_clean_url/views/menu.xml b/web_editor_clean_url/views/menu.xml new file mode 100644 index 0000000000..efc67d11a3 --- /dev/null +++ b/web_editor_clean_url/views/menu.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/web_editor_clean_url/views/web_editor_clean_url_configuration.xml b/web_editor_clean_url/views/web_editor_clean_url_configuration.xml new file mode 100644 index 0000000000..0a161758fd --- /dev/null +++ b/web_editor_clean_url/views/web_editor_clean_url_configuration.xml @@ -0,0 +1,45 @@ + + + + + web.editor.clean.url.configuration + +
+ + + + + + + + + + +
+
+
+ + web.editor.clean.url.configuration + + + + + + + + + + + + Clean URLs + web.editor.clean.url.configuration + tree,form + +