diff --git a/website_event_hidden_by_password/README.rst b/website_event_hidden_by_password/README.rst new file mode 100644 index 000000000..156e80f42 --- /dev/null +++ b/website_event_hidden_by_password/README.rst @@ -0,0 +1,170 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================ +Website Event Hidden by Password +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:de39157b2d18e9098cbca7d99c039325ba1a17de4d0355a1c9fb9aeeed9b570f + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-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%2Fevent-lightgray.png?logo=github + :target: https://github.com/OCA/event/tree/19.0/website_event_hidden_by_password + :alt: OCA/event +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/event-19-0/event-19-0-website_event_hidden_by_password + :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/event&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module lets each website event be made **private**, hidden behind a +shared access password. + +A private event: + +- is **redacted** on the website events listing: its card shows no name, + date, location or image, only a lock, "Private" and an optional public + **codename** (the slug-free gate link keeps the name out of the URL + too); +- is **not searchable by its real name, subtitle or venue**, and never + leaks through the search facet counts. A visitor can only find it by + typing its **codename**, and then it surfaces as the same redacted + card, shown as ``{codename}`` (both on the events page and in the + search autocomplete); +- shows an **info-free password wall** when opened: the event page (and + its sub-pages) only render once the visitor enters the correct + password; +- keeps its name out of every direct entry point. Guessing the integer + id (``/event/42``), the registration page or the iCal download + (``/ics``) sends the visitor to the gate instead of leaking the name, + and the event is left out of ``sitemap.xml``; +- keeps its password on the event record itself, next to the *Private* + toggle, with a **Generate** button for a random one. + +The password is a *shared secret* (like a webinar password), not a user +account credential: it is stored and shown in clear text and is only +readable by event managers. Managers always bypass the wall so they can +preview and edit the page. + +Scope and limitations +--------------------- + +This module is designed to close **every entry point in Odoo core** +through which a private event could otherwise be discovered or have its +details exposed: the events listing, the search, the search autocomplete +and the filter counts, the canonical-URL redirect, direct page access, +the iCal exports and ``sitemap.xml``. The access guard is applied at the +routing layer, so it also covers the standard event add-ons (tracks, +agenda, booth) without this module depending on them. + +It cannot, however, protect entry points it has no knowledge of. If you +install additional modules, or otherwise make events reachable through a +custom route, report, API, feed, export or third-party integration, +securing those new entry points is **your responsibility**, typically in +a small custom bridge module (this module deliberately avoids hard +dependencies on optional add-ons). + +If you discover an entry point in **Odoo core** that still exposes a +private event's name or details, please open an issue on the project's +GitHub tracker and tag the maintainer (``@odrakirmusic``) so it can be +closed in this module. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To make an event private: + +1. Go to *Events* and open (or create) an event. +2. In the **Private Access** group, switch on **Private**. A random + password is proposed automatically. +3. Keep the proposed **Access Password**, type your own, or click + **Generate** for a new random one. +4. Optionally set a **Codename**, a public, non-secret hint (e.g. + *Aurora*) shown under "Private" on the redacted card, so the people + you invite can tell which event to open. +5. Save and publish the event. + +Share the password (and codename, if any) with the people who should be +able to open the event. + +Usage +===== + +On the website: + +- The events listing shows private events redacted: no name, date, + location or image, only a lock and "Private". +- Opening a private event displays an info-free password wall instead of + the event page. +- Once a visitor enters the correct password, the event page and its + sub-pages are shown for the rest of the browsing session. +- Event managers (group *Event / User*) skip the wall, so they can + preview and edit private events as usual. + +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 +------- + +* Riccardo Fiore + +Contributors +------------ + +- Riccardo Fiore (Odrakir) + +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-odrakirmusic| image:: https://github.com/odrakirmusic.png?size=40px + :target: https://github.com/odrakirmusic + :alt: odrakirmusic + +Current `maintainer `__: + +|maintainer-odrakirmusic| + +This module is part of the `OCA/event `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/website_event_hidden_by_password/__init__.py b/website_event_hidden_by_password/__init__.py new file mode 100644 index 000000000..91c5580fe --- /dev/null +++ b/website_event_hidden_by_password/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/website_event_hidden_by_password/__manifest__.py b/website_event_hidden_by_password/__manifest__.py new file mode 100644 index 000000000..675c6efea --- /dev/null +++ b/website_event_hidden_by_password/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2026 Riccardo Fiore +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Website Event Hidden by Password", + "summary": "Hide website events behind a per-event access password", + "version": "19.0.1.0.0", + "category": "Marketing/Events", + "author": "Riccardo Fiore, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/event", + "license": "AGPL-3", + "development_status": "Beta", + "maintainers": ["odrakirmusic"], + "depends": ["website_event"], + "data": [ + "views/event_event_views.xml", + "views/website_event_templates.xml", + ], + "assets": { + "web.assets_frontend": [ + "website_event_hidden_by_password/static/src/scss/website_event_hidden_by_password.scss", + ], + }, + "installable": True, + "application": False, +} diff --git a/website_event_hidden_by_password/controllers/__init__.py b/website_event_hidden_by_password/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/website_event_hidden_by_password/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/website_event_hidden_by_password/controllers/main.py b/website_event_hidden_by_password/controllers/main.py new file mode 100644 index 000000000..02c2ad814 --- /dev/null +++ b/website_event_hidden_by_password/controllers/main.py @@ -0,0 +1,142 @@ +# Copyright 2026 Riccardo Fiore +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import http +from odoo.fields import Domain +from odoo.http import request + +from odoo.addons.website.models.ir_http import sitemap_qs2dom +from odoo.addons.website_event.controllers.main import WebsiteEventController + +from ..utils import SESSION_KEY, event_visitor_unlocked + + +def sitemap_event_private(env, rule, qs): + """Sitemap for the ``/event/`` detail route. + + Yields the published events' canonical URLs like the core default would, + but skips private events so their real name (the slug) never appears in + ``sitemap.xml``. + """ + Event = env["event.event"] + # The public sitemap env already restricts to published events via the + # record rule; we only add the privacy exclusion. + dom = sitemap_qs2dom(qs, "/event", Event._rec_name) & Domain( + "is_private", "=", False + ) + for event in Event.search(dom): + loc = "/event/{}".format(env["ir.http"]._slug(event)) + if not qs or qs.lower() in loc.lower(): + yield {"loc": loc} + + +class WebsiteEventPrivate(WebsiteEventController): + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + def _event_private_is_unlocked(self, event): + """Whether the current visitor may see this (possibly private) event.""" + return event_visitor_unlocked(request.env, request.session, event) + + def _event_private_mark_unlocked(self, event): + unlocked = request.session.get(SESSION_KEY) or [] + if event.id not in unlocked: + # Reassign (not append in place) so the session is marked dirty. + request.session[SESSION_KEY] = unlocked + [event.id] + + def _event_private_safe_redirect(self, event, redirect): + """Only allow redirects back to this event's own pages.""" + prefix = f"/event/{event.id}" + if redirect and redirect.startswith(prefix): + return redirect + return prefix + + def _event_private_render_gate(self, event, redirect=None, password_error=False): + values = { + "event": event, + "redirect": self._event_private_safe_redirect(event, redirect), + "password_error": password_error, + } + return request.render( + "website_event_hidden_by_password.event_private_gate", values + ) + + # ------------------------------------------------------------------ + # Gated pages + # + # These override routed endpoints of the parent controller. The empty + # ``@http.route()`` re-declares them as routes that inherit the parent's + # routing config, which is the documented way to extend a routed method + # (without it Odoo warns that the override is "not decorated by @route"). + # ------------------------------------------------------------------ + @http.route() + def event_register(self, event, **post): + if not self._event_private_is_unlocked(event): + return self._event_private_render_gate( + event, redirect=request.httprequest.path + ) + return super().event_register(event, **post) + + @http.route() + def event_page(self, event, page, **post): + if not self._event_private_is_unlocked(event): + return self._event_private_render_gate( + event, redirect=request.httprequest.path + ) + return super().event_page(event, page, **post) + + @http.route( + ["""/event/"""], + type="http", + auth="public", + website=True, + sitemap=sitemap_event_private, + readonly=True, + ) + def event(self, event, **post): + # Re-declared only to swap the core ``sitemap=True`` for one that + # omits private events (so their name never lands in sitemap.xml); + # the behaviour is otherwise the core redirect. Locked visitors are + # already sent to the gate by the ir.http pre-dispatch guard. + return super().event(event, **post) + + # ------------------------------------------------------------------ + # Unlock endpoint + # ------------------------------------------------------------------ + @http.route( + ["/event//private"], + type="http", + auth="public", + website=True, + sitemap=False, + ) + def event_private_locked(self, event_id, **post): + """Slug-free gate page for a private event. + + Reached from the redacted listing card. Using a plain ```` (not + the ```` slug converter) keeps the event name out of the URL — + otherwise Odoo would 301 to the canonical ``/event/-`` URL + and leak it. Managers / already-unlocked visitors are sent straight to + the real page. + """ + event = request.env["event.event"].browse(event_id).sudo().exists() + if not event or not event.is_private: + return request.redirect("/event") + if self._event_private_is_unlocked(event): + return request.redirect(f"/event/{event.id}") + return self._event_private_render_gate(event, redirect=f"/event/{event.id}") + + @http.route( + ['/event//unlock'], + type="http", + auth="public", + website=True, + methods=["POST"], + sitemap=False, + ) + def event_private_unlock(self, event, password=None, redirect=None, **post): + if event.is_private and event.sudo()._is_access_password_valid(password): + self._event_private_mark_unlocked(event) + return request.redirect(self._event_private_safe_redirect(event, redirect)) + return self._event_private_render_gate( + event, redirect=redirect, password_error=True + ) diff --git a/website_event_hidden_by_password/i18n/website_event_hidden_by_password.pot b/website_event_hidden_by_password/i18n/website_event_hidden_by_password.pot new file mode 100644 index 000000000..1bc697bfb --- /dev/null +++ b/website_event_hidden_by_password/i18n/website_event_hidden_by_password.pot @@ -0,0 +1,146 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_event_hidden_by_password +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0-20260609\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-06-23 21:06+0000\n" +"PO-Revision-Date: 2026-06-23 21:06+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.event_private_gate +msgid "" +"" +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.events_list_private +msgid "" +"\n" +" Private" +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.event_private_gate +msgid "Unlock" +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,field_description:website_event_hidden_by_password.field_event_event__is_access_locked +msgid "Access Locked" +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,field_description:website_event_hidden_by_password.field_event_event__access_password +msgid "Access Password" +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.event_private_gate +msgid "Access password" +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,field_description:website_event_hidden_by_password.field_event_event__codename +msgid "Codename" +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,field_description:website_event_hidden_by_password.field_event_event__display_name +msgid "Display Name" +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model,name:website_event_hidden_by_password.model_event_event +msgid "Event" +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.view_event_form +msgid "Generate" +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,help:website_event_hidden_by_password.field_event_event__is_private +msgid "" +"Hide this event behind a password. It is redacted on the website listing and" +" its page asks for the access password before the content is shown." +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,field_description:website_event_hidden_by_password.field_event_event__id +msgid "ID" +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.event_private_gate +msgid "Incorrect password. Please try again." +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.event_private_gate +msgid "Password" +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,field_description:website_event_hidden_by_password.field_event_event__is_private +msgid "Private" +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.view_event_form +msgid "Private Access" +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.events_list_private +msgid "Private event" +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,help:website_event_hidden_by_password.field_event_event__codename +msgid "" +"Public reference label shown under 'Private' on the redacted card, so the " +"people you share the password with can tell which event to open. It is NOT " +"secret — pick a hint, not the real name (e.g. 'Aurora', 'The Greenhouse')." +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,help:website_event_hidden_by_password.field_event_event__access_password +msgid "" +"Shared password visitors must enter to view a private event. It is a shared " +"secret (like a webinar password), not a user account credential, so it is " +"stored and displayed in clear text and is only readable by event managers." +msgstr "" + +#. module: website_event_hidden_by_password +#: model:ir.model.fields,help:website_event_hidden_by_password.field_event_event__is_access_locked +msgid "" +"Technical: True when this event is private and the current user is not an " +"event manager, i.e. its details must be redacted on the website. Used by the" +" website templates." +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.event_private_gate +msgid "This event is private." +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.view_event_form +msgid "e.g. Aurora (public hint, not the real name)" +msgstr "" + +#. module: website_event_hidden_by_password +#: model_terms:ir.ui.view,arch_db:website_event_hidden_by_password.view_event_form +msgid "e.g. NIGHT-ONLY password" +msgstr "" diff --git a/website_event_hidden_by_password/models/__init__.py b/website_event_hidden_by_password/models/__init__.py new file mode 100644 index 000000000..d6f23f0e3 --- /dev/null +++ b/website_event_hidden_by_password/models/__init__.py @@ -0,0 +1,2 @@ +from . import event_event +from . import ir_http diff --git a/website_event_hidden_by_password/models/event_event.py b/website_event_hidden_by_password/models/event_event.py new file mode 100644 index 000000000..1f0e5fcef --- /dev/null +++ b/website_event_hidden_by_password/models/event_event.py @@ -0,0 +1,175 @@ +# Copyright 2026 Riccardo Fiore +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import hmac +import secrets +import string + +from odoo import api, fields, models +from odoo.fields import Domain + +# Password alphabet: upper-case letters + digits, minus the characters that +# are easy to confuse when read aloud or copied by hand (0/O, 1/I). +_PASSWORD_ALPHABET = "".join( + c for c in (string.ascii_uppercase + string.digits) if c not in "O0I1" +) +_PASSWORD_LENGTH = 8 + + +class EventEvent(models.Model): + _inherit = "event.event" + + is_private = fields.Boolean( + string="Private", + copy=False, + help="Hide this event behind a password. It is redacted on the " + "website listing and its page asks for the access password before " + "the content is shown.", + ) + is_access_locked = fields.Boolean( + string="Access Locked", + compute="_compute_is_access_locked", + compute_sudo=False, + help="Technical: True when this event is private and the current user " + "is not an event manager, i.e. its details must be redacted on the " + "website. Used by the website templates.", + ) + access_password = fields.Char( + copy=False, + groups="event.group_event_user", + help="Shared password visitors must enter to view a private event. " + "It is a shared secret (like a webinar password), not a user " + "account credential, so it is stored and displayed in clear text " + "and is only readable by event managers.", + ) + codename = fields.Char( + copy=False, + help="Public reference label shown under 'Private' on the redacted " + "card, so the people you share the password with can tell which " + "event to open. It is NOT secret: pick a hint, not the real name " + "(e.g. 'Aurora', 'The Greenhouse').", + ) + + @api.depends("is_private") + @api.depends_context("uid") + def _compute_is_access_locked(self): + is_manager = self.env.user.has_group("event.group_event_user") + for event in self: + event.is_access_locked = event.is_private and not is_manager + + @api.onchange("is_private") + def _onchange_is_private(self): + """Seed a password the first time an event is made private (UI hint).""" + for event in self: + if event.is_private and not event.access_password: + event.access_password = event._generate_access_password() + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("is_private") and not vals.get("access_password"): + vals["access_password"] = self._generate_access_password() + return super().create(vals_list) + + def write(self, vals): + res = super().write(vals) + # Invariant: a private event must always have an access password, + # otherwise nobody could open it. Re-seed any private record left + # without one — whether it was just turned private, had its password + # cleared, or was written through the API / an import. Enforcing it + # here (not via a view ``required``) is what keeps the password field + # from flashing red between the Private toggle and the onchange. + if "is_private" in vals or "access_password" in vals: + need_password = self.filtered( + lambda event: event.is_private and not event.access_password + ) + for event in need_password: + event.access_password = event._generate_access_password() + return res + + @api.model + def _generate_access_password(self): + """Return a fresh random access password.""" + return "".join( + secrets.choice(_PASSWORD_ALPHABET) for _ in range(_PASSWORD_LENGTH) + ) + + def action_generate_access_password(self): + """Backend button: (re)generate the access password for each event.""" + for event in self: + event.access_password = event._generate_access_password() + + def _is_access_password_valid(self, password): + """Constant-time check of a candidate password against the event's. + + Compares UTF-8 bytes (not ``consteq``/``hmac.compare_digest`` on str, + which raises on non-ASCII), so passwords with accents or symbols work. + Meant to be called with ``sudo`` so the public controller can read the + manager-only ``access_password`` field. + """ + self.ensure_one() + if not self.access_password or not password: + return False + return hmac.compare_digest( + self.access_password.encode("utf-8"), password.encode("utf-8") + ) + + # ------------------------------------------------------------------ + # Website search hardening + # + # A private event must never be discoverable through the website search + # by its real name, subtitle or venue, and must never leak its existence + # or metadata through the search facets. For visitors it is searchable + # ONLY by its (non-secret) codename, and then it surfaces as a redacted + # result shown in braces — "{codename}" — exactly like the listing card. + # Event managers keep the normal, name-based search so they can find and + # manage their events. + # ------------------------------------------------------------------ + @api.model + def _search_get_detail(self, website, order, options): + detail = super()._search_get_detail(website, order, options) + if not self.env.user.has_group("event.group_event_user"): + # The date / country facet badges are counted by name against + # these domains (see WebsiteEventController.events). Excluding + # private events here stops a visitor who types a private event's + # real name from learning, via the badges, that it exists at all + # or which country it is in. The main result set (base_domain) is + # left untouched so the redacted cards and codename search keep + # working. + not_private = [("is_private", "=", False)] + detail["no_date_domain"] = detail["no_date_domain"] + [not_private] + detail["no_country_domain"] = detail["no_country_domain"] + [not_private] + return detail + + def _search_build_domain(self, domain_list, search, fields, extra=None): + domain = super()._search_build_domain(domain_list, search, fields, extra) + if search and not self.env.user.has_group("event.group_event_user"): + # Visitors: private events match ONLY their codename (never the + # real name / subtitle / venue that `fields` + `extra` cover), and + # non-private events never match a codename. Empty searches skip + # this branch, so the plain listing still shows the redacted cards. + public = domain & Domain("is_private", "=", False) + by_codename = super()._search_build_domain( + domain_list, search, ["codename"] + ) & Domain("is_private", "=", True) + domain = public | by_codename + return domain + + def _search_render_results(self, fetch_fields, mapping, icon, limit): + results_data = super()._search_render_results( + fetch_fields, mapping, icon, limit + ) + if self.env.user.has_group("event.group_event_user"): + return results_data + for event, data in zip(self, results_data, strict=False): + if not event.is_private: + continue + # Redact the autocomplete row: a private event surfaces only via + # its codename (shown in braces) and links to the slug-free gate. + # Blank every other field so the real name, venue and subtitle + # never reach the dropdown. + data["name"] = "{%s}" % (event.codename or self.env._("Private")) + data["website_url"] = f"/event/{event.id}/private" + for leaked in ("address_name", "subtitle"): + if leaked in data: + data[leaked] = False + return results_data diff --git a/website_event_hidden_by_password/models/ir_http.py b/website_event_hidden_by_password/models/ir_http.py new file mode 100644 index 000000000..20a1d322a --- /dev/null +++ b/website_event_hidden_by_password/models/ir_http.py @@ -0,0 +1,53 @@ +# Copyright 2026 Riccardo Fiore +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import werkzeug + +from odoo import models +from odoo.http import request + +from ..utils import event_visitor_unlocked + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + @classmethod + def _pre_dispatch(cls, rule, args): + """Single choke point gating every event route that resolves an + ``event`` argument, for visitors who have not unlocked it. + + This runs before the endpoint (and before ``super()._pre_dispatch``'s + SEO canonical 301, which would otherwise rewrite ``/event/`` to + ``/event/-`` and hand the name to anyone guessing the + integer id). A locked visitor is sent to the slug-free gate instead. + + It is intentionally agnostic to the route's flags so it covers, with + no add-on dependency, both the website pages (landing, register, + custom pages, and — when those add-ons are installed — track, agenda + and booth pages) AND the non-website iCal downloads + (``/event//ics`` and ``website_event_track``'s + ``/event//track//ics``), which would otherwise stream the + event/track name, dates and venue. Managers and already-unlocked + visitors fall through to the normal flow. + """ + event = args.get("event") + if ( + event is not None + and getattr(event, "_name", None) == "event.event" + and request.httprequest.method in ("GET", "HEAD") + ): + # Re-browse in the live request env. The record bound by the URL + # converter can carry a cursor that is already closed by the time + # we run (super()._pre_dispatch is what rebinds args to the live + # env, and it runs after us) — notably on read-write routes such + # as the iCal download. event.id is cached, so reading it is safe. + # sudo is fine: the privacy flag is not secret, and it dodges an + # AccessError on the rare unpublished-yet-private edge. + event_sudo = request.env["event.event"].sudo().browse(event.id) + if event_sudo.is_private and not event_visitor_unlocked( + request.env, request.session, event_sudo + ): + werkzeug.exceptions.abort( + request.redirect(f"/event/{event.id}/private") + ) + return super()._pre_dispatch(rule, args) diff --git a/website_event_hidden_by_password/pyproject.toml b/website_event_hidden_by_password/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/website_event_hidden_by_password/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/website_event_hidden_by_password/readme/CONFIGURE.md b/website_event_hidden_by_password/readme/CONFIGURE.md new file mode 100644 index 000000000..bebbe1d1d --- /dev/null +++ b/website_event_hidden_by_password/readme/CONFIGURE.md @@ -0,0 +1,14 @@ +To make an event private: + +1. Go to *Events* and open (or create) an event. +2. In the **Private Access** group, switch on **Private**. A random + password is proposed automatically. +3. Keep the proposed **Access Password**, type your own, or click + **Generate** for a new random one. +4. Optionally set a **Codename**, a public, non-secret hint (e.g. + *Aurora*) shown under "Private" on the redacted card, so the people you + invite can tell which event to open. +5. Save and publish the event. + +Share the password (and codename, if any) with the people who should be +able to open the event. diff --git a/website_event_hidden_by_password/readme/CONTRIBUTORS.md b/website_event_hidden_by_password/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..14d07c072 --- /dev/null +++ b/website_event_hidden_by_password/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Riccardo Fiore (Odrakir) \ diff --git a/website_event_hidden_by_password/readme/DESCRIPTION.md b/website_event_hidden_by_password/readme/DESCRIPTION.md new file mode 100644 index 000000000..774ba26e6 --- /dev/null +++ b/website_event_hidden_by_password/readme/DESCRIPTION.md @@ -0,0 +1,48 @@ +This module lets each website event be made **private**, hidden behind a +shared access password. + +A private event: + +- is **redacted** on the website events listing: its card shows no name, + date, location or image, only a lock, "Private" and an optional public + **codename** (the slug-free gate link keeps the name out of the URL too); +- is **not searchable by its real name, subtitle or venue**, and never + leaks through the search facet counts. A visitor can only find it by + typing its **codename**, and then it surfaces as the same redacted card, + shown as `{codename}` (both on the events page and in the search + autocomplete); +- shows an **info-free password wall** when opened: the event page (and its + sub-pages) only render once the visitor enters the correct password; +- keeps its name out of every direct entry point. Guessing the integer id + (`/event/42`), the registration page or the iCal download (`/ics`) sends + the visitor to the gate instead of leaking the name, and the event is + left out of `sitemap.xml`; +- keeps its password on the event record itself, next to the *Private* + toggle, with a **Generate** button for a random one. + +The password is a *shared secret* (like a webinar password), not a user +account credential: it is stored and shown in clear text and is only +readable by event managers. Managers always bypass the wall so they can +preview and edit the page. + +## Scope and limitations + +This module is designed to close **every entry point in Odoo core** through +which a private event could otherwise be discovered or have its details +exposed: the events listing, the search, the search autocomplete and the +filter counts, the canonical-URL redirect, direct page access, the iCal +exports and `sitemap.xml`. The access guard is applied at the routing +layer, so it also covers the standard event add-ons (tracks, agenda, booth) +without this module depending on them. + +It cannot, however, protect entry points it has no knowledge of. If you +install additional modules, or otherwise make events reachable through a +custom route, report, API, feed, export or third-party integration, +securing those new entry points is **your responsibility**, typically in a +small custom bridge module (this module deliberately avoids hard +dependencies on optional add-ons). + +If you discover an entry point in **Odoo core** that still exposes a +private event's name or details, please open an issue on the project's +GitHub tracker and tag the maintainer (`@odrakirmusic`) so it can be closed +in this module. diff --git a/website_event_hidden_by_password/readme/USAGE.md b/website_event_hidden_by_password/readme/USAGE.md new file mode 100644 index 000000000..88d96e250 --- /dev/null +++ b/website_event_hidden_by_password/readme/USAGE.md @@ -0,0 +1,10 @@ +On the website: + +- The events listing shows private events redacted: no name, date, location + or image, only a lock and "Private". +- Opening a private event displays an info-free password wall instead of the + event page. +- Once a visitor enters the correct password, the event page and its + sub-pages are shown for the rest of the browsing session. +- Event managers (group *Event / User*) skip the wall, so they can preview + and edit private events as usual. diff --git a/website_event_hidden_by_password/static/description/index.html b/website_event_hidden_by_password/static/description/index.html new file mode 100644 index 000000000..8014cb1d3 --- /dev/null +++ b/website_event_hidden_by_password/static/description/index.html @@ -0,0 +1,505 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Website Event Hidden by Password

+ +

Beta License: AGPL-3 OCA/event Translate me on Weblate Try me on Runboat

+

This module lets each website event be made private, hidden behind a +shared access password.

+

A private event:

+
    +
  • is redacted on the website events listing: its card shows no name, +date, location or image, only a lock, “Private” and an optional public +codename (the slug-free gate link keeps the name out of the URL +too);
  • +
  • is not searchable by its real name, subtitle or venue, and never +leaks through the search facet counts. A visitor can only find it by +typing its codename, and then it surfaces as the same redacted +card, shown as {codename} (both on the events page and in the +search autocomplete);
  • +
  • shows an info-free password wall when opened: the event page (and +its sub-pages) only render once the visitor enters the correct +password;
  • +
  • keeps its name out of every direct entry point. Guessing the integer +id (/event/42), the registration page or the iCal download +(/ics) sends the visitor to the gate instead of leaking the name, +and the event is left out of sitemap.xml;
  • +
  • keeps its password on the event record itself, next to the Private +toggle, with a Generate button for a random one.
  • +
+

The password is a shared secret (like a webinar password), not a user +account credential: it is stored and shown in clear text and is only +readable by event managers. Managers always bypass the wall so they can +preview and edit the page.

+
+

Scope and limitations

+

This module is designed to close every entry point in Odoo core +through which a private event could otherwise be discovered or have its +details exposed: the events listing, the search, the search autocomplete +and the filter counts, the canonical-URL redirect, direct page access, +the iCal exports and sitemap.xml. The access guard is applied at the +routing layer, so it also covers the standard event add-ons (tracks, +agenda, booth) without this module depending on them.

+

It cannot, however, protect entry points it has no knowledge of. If you +install additional modules, or otherwise make events reachable through a +custom route, report, API, feed, export or third-party integration, +securing those new entry points is your responsibility, typically in +a small custom bridge module (this module deliberately avoids hard +dependencies on optional add-ons).

+

If you discover an entry point in Odoo core that still exposes a +private event’s name or details, please open an issue on the project’s +GitHub tracker and tag the maintainer (@odrakirmusic) so it can be +closed in this module.

+

Table of contents

+ +
+

Configuration

+

To make an event private:

+
    +
  1. Go to Events and open (or create) an event.
  2. +
  3. In the Private Access group, switch on Private. A random +password is proposed automatically.
  4. +
  5. Keep the proposed Access Password, type your own, or click +Generate for a new random one.
  6. +
  7. Optionally set a Codename, a public, non-secret hint (e.g. +Aurora) shown under “Private” on the redacted card, so the people +you invite can tell which event to open.
  8. +
  9. Save and publish the event.
  10. +
+

Share the password (and codename, if any) with the people who should be +able to open the event.

+
+
+

Usage

+

On the website:

+
    +
  • The events listing shows private events redacted: no name, date, +location or image, only a lock and “Private”.
  • +
  • Opening a private event displays an info-free password wall instead of +the event page.
  • +
  • Once a visitor enters the correct password, the event page and its +sub-pages are shown for the rest of the browsing session.
  • +
  • Event managers (group Event / User) skip the wall, so they can +preview and edit private events as usual.
  • +
+
+
+

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.

+
+ +
+
+

Authors

+
    +
  • Riccardo Fiore
  • +
+
+
+

Contributors

+ +
+
+

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:

+

odrakirmusic

+

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

+

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

+
+
+
+ + diff --git a/website_event_hidden_by_password/static/src/scss/website_event_hidden_by_password.scss b/website_event_hidden_by_password/static/src/scss/website_event_hidden_by_password.scss new file mode 100644 index 000000000..5d91770a7 --- /dev/null +++ b/website_event_hidden_by_password/static/src/scss/website_event_hidden_by_password.scss @@ -0,0 +1,87 @@ +/* ============================================================ + Website Event Private - baseline styling. + ------------------------------------------------------------ + Theme-agnostic look for the redacted listing card and the + password wall. No design tokens: works on any website. A + downstream theme can override these hooks. + ============================================================ */ + +/* ---- redacted event card (no details, frosted) ---- */ +.o_wevent_event_redacted { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.6rem; + min-height: 16rem; + text-align: center; + color: #d7d7da; + background: rgba(22, 24, 28, 0.55); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.5rem; + backdrop-filter: blur(8px) saturate(1.1); + -webkit-backdrop-filter: blur(8px) saturate(1.1); + overflow: hidden; + + .o_wevent_event_redacted_icon { + font-size: 1.7rem; + opacity: 0.85; + } + .o_wevent_event_redacted_label { + font-size: 0.82rem; + letter-spacing: 0.22em; + text-transform: uppercase; + } + /* codename — the public reference so visitors tell events apart */ + .o_wevent_event_redacted_codename { + margin-top: 0.1rem; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0.02em; + color: #fff; + } +} + +/* ---- password wall ---- */ +.o_wevent_event_private_gate { + min-height: 60vh; + display: flex; + align-items: center; + + .o_wevent_private_panel { + max-width: 30rem; + margin: 0 auto; + padding: 2.5rem 2rem; + border-radius: 0.75rem; + color: #ececec; + background: rgba(22, 24, 28, 0.6); + border: 1px solid rgba(255, 255, 255, 0.12); + backdrop-filter: blur(10px) saturate(1.1); + -webkit-backdrop-filter: blur(10px) saturate(1.1); + } + .o_wevent_private_lock { + color: #c9c9cc; + } + .o_wevent_private_msg { + letter-spacing: 0.03em; + margin-bottom: 1rem; + } + + .o_wevent_private_form { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.8rem; + margin-top: 1rem; + } + .o_wevent_private_input { + max-width: 18rem; + text-align: center; + } + /* centred pill */ + .o_wevent_private_btn { + border-radius: 50rem; + padding: 0.6rem 2.2rem; + } +} diff --git a/website_event_hidden_by_password/tests/__init__.py b/website_event_hidden_by_password/tests/__init__.py new file mode 100644 index 000000000..bf84e5b89 --- /dev/null +++ b/website_event_hidden_by_password/tests/__init__.py @@ -0,0 +1 @@ +from . import test_event_hidden_by_password diff --git a/website_event_hidden_by_password/tests/test_event_hidden_by_password.py b/website_event_hidden_by_password/tests/test_event_hidden_by_password.py new file mode 100644 index 000000000..0d90bb17d --- /dev/null +++ b/website_event_hidden_by_password/tests/test_event_hidden_by_password.py @@ -0,0 +1,223 @@ +# Copyright 2026 Riccardo Fiore +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import re + +from odoo.tests import HttpCase, TransactionCase, tagged + + +class TestEventPrivateModel(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event = cls.env["event.event"].create( + { + "name": "Secret DJ Night", + "date_begin": "2099-01-01 20:00:00", + "date_end": "2099-01-02 02:00:00", + } + ) + + def test_defaults_public(self): + self.assertFalse(self.event.is_private) + self.assertFalse(self.event.access_password) + self.assertFalse(self.event.is_access_locked) + + def test_generate_button_rotates_password(self): + self.event.write({"is_private": True, "access_password": "INITIAL1"}) + self.event.action_generate_access_password() + first = self.event.access_password + self.assertTrue(first) + self.assertNotEqual(first, "INITIAL1") + self.event.action_generate_access_password() + self.assertNotEqual(self.event.access_password, first) + + def test_password_check(self): + self.event.write({"is_private": True, "access_password": "OPENSESAME"}) + self.assertTrue(self.event._is_access_password_valid("OPENSESAME")) + self.assertFalse(self.event._is_access_password_valid("wrong")) + self.assertFalse(self.event._is_access_password_valid("")) + self.assertFalse(self.event._is_access_password_valid(None)) + + def test_password_check_non_ascii(self): + # Passwords may contain accents/symbols; the compare must not crash. + self.event.write({"is_private": True, "access_password": "café ©"}) + self.assertTrue(self.event._is_access_password_valid("café ©")) + self.assertFalse(self.event._is_access_password_valid("cafe")) + + def test_private_autogenerates_password(self): + # Turning an event private without a password (create or write) seeds + # one automatically, so it can never be locked with no key. + created = self.env["event.event"].create( + { + "name": "Locked, auto key", + "date_begin": "2099-01-01 20:00:00", + "date_end": "2099-01-02 02:00:00", + "is_private": True, + } + ) + self.assertTrue(created.access_password) + + self.event.write({"is_private": True}) + self.assertTrue(self.event.access_password) + + def test_private_repopulates_cleared_password(self): + # Clearing the password on a still-private event must not leave it + # unopenable: the invariant re-seeds one (this is why the form can drop + # the ``required`` modifier without risking a passwordless private + # event). + self.event.write({"is_private": True}) + self.event.write({"access_password": False}) + self.assertTrue(self.event.access_password) + + def test_is_access_locked_redacts_for_non_managers(self): + self.event.write({"website_published": True, "is_private": True}) + # Admin is an event manager -> not redacted. + self.assertFalse(self.event.is_access_locked) + # Public visitor -> redacted. + public = self.env.ref("base.public_user") + self.assertTrue(self.event.with_user(public).is_access_locked) + + def test_sitemap_omits_private_event(self): + from ..controllers.main import sitemap_event_private + + public_event = self.env["event.event"].create( + { + "name": "Open To Everyone", + "date_begin": "2099-01-01 20:00:00", + "date_end": "2099-01-02 02:00:00", + "website_published": True, + } + ) + self.event.write({"website_published": True, "is_private": True}) + slug = self.env["ir.http"]._slug + locs = [entry["loc"] for entry in sitemap_event_private(self.env, None, None)] + # The public event's named URL is in the sitemap... + self.assertIn(f"/event/{slug(public_event)}", locs) + # ...but the private event's (name-bearing) URL never is. + self.assertNotIn(f"/event/{slug(self.event)}", locs) + + +@tagged("post_install", "-at_install") +class TestEventPrivateFlow(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.password = "GLASSHOUSE" + cls.event = cls.env["event.event"].create( + { + "name": "Members Only Set", + "date_begin": "2099-01-01 20:00:00", + "date_end": "2099-01-02 02:00:00", + "website_published": True, + "is_private": True, + "access_password": cls.password, + "codename": "AURORA", + } + ) + cls.gate_url = f"/event/{cls.event.id}/private" + + def test_listing_redacts_private_event(self): + res = self.url_open("/event") + self.assertEqual(res.status_code, 200) + # The redacted card links to the slug-free gate... + self.assertIn(f"/event/{self.event.id}/private", res.text) + # ...and leaks none of the event's details (name)... + self.assertNotIn(self.event.name, res.text) + # ...but does show the public codename, so visitors tell events apart. + self.assertIn("AURORA", res.text) + + def test_gate_is_info_free(self): + res = self.url_open(self.gate_url) + self.assertEqual(res.status_code, 200) + self.assertIn("o_wevent_event_private_gate", res.text) + # No name, no event body. + self.assertNotIn(self.event.name, res.text) + self.assertNotIn("o_wevent_event_main_col", res.text) + + def test_register_page_shows_wall(self): + res = self.url_open(f"/event/{self.event.id}/register") + self.assertEqual(res.status_code, 200) + self.assertIn("o_wevent_event_private_gate", res.text) + self.assertNotIn("o_wevent_event_main_col", res.text) + + def test_wrong_then_right_password(self): + gate = self.url_open(self.gate_url) + token = re.search(r'name="csrf_token"\s+value="([^"]+)"', gate.text).group(1) + + wrong = self.url_open( + f"/event/{self.event.id}/unlock", + data={ + "csrf_token": token, + "password": "nope", + "redirect": f"/event/{self.event.id}", + }, + ) + self.assertIn("o_wevent_event_private_gate", wrong.text) + + right = self.url_open( + f"/event/{self.event.id}/unlock", + data={ + "csrf_token": token, + "password": self.password, + "redirect": f"/event/{self.event.id}", + }, + ) + # Unlocked: the event page renders, no more wall. + self.assertEqual(right.status_code, 200) + self.assertIn("o_wevent_event_main_col", right.text) + self.assertNotIn("o_wevent_event_private_gate", right.text) + + # -- Search loophole (issue #108) -------------------------------------- + def test_search_by_real_name_hides_private_event(self): + # Typing the real name must not surface the event: no redacted card, + # no link to it, no codename. (The page echoes the *typed* term back, + # which happens to be the name — that is the visitor's own input, not + # a leak — so we assert on the event's own markup instead.) + res = self.url_open("/event?search=Members+Only+Set&noFuzzy=1") + self.assertEqual(res.status_code, 200) + self.assertNotIn(f"/event/{self.event.id}/private", res.text) + self.assertNotIn("o_wevent_event_redacted", res.text) + self.assertNotIn("AURORA", res.text) + + def test_search_by_subtitle_hides_private_event(self): + self.event.write({"subtitle": "Backroom warehouse rave"}) + res = self.url_open("/event?search=warehouse&noFuzzy=1") + self.assertEqual(res.status_code, 200) + self.assertNotIn(self.event.name, res.text) + self.assertNotIn(f"/event/{self.event.id}/private", res.text) + + def test_search_by_codename_reveals_redacted_card(self): + # The codename is the only key into the event, and it shows up in + # braces on a redacted card linking to the slug-free gate. + res = self.url_open("/event?search=AURORA&noFuzzy=1") + self.assertEqual(res.status_code, 200) + self.assertIn(f"/event/{self.event.id}/private", res.text) + self.assertIn("{AURORA}", res.text) + self.assertNotIn(self.event.name, res.text) + + # -- Direct-URL / endpoint leaks --------------------------------------- + def test_direct_url_does_not_leak_name(self): + # Guessing the integer id must not 301 to /event/-; + # the visitor is sent to the slug-free gate instead. + for path in ("/event/%s", "/event/%s/register"): + res = self.url_open(path % self.event.id, allow_redirects=False) + self.assertIn(res.status_code, (301, 302, 303)) + location = res.headers.get("Location", "") + self.assertTrue(location.endswith(f"/event/{self.event.id}/private")) + self.assertNotIn("members-only-set", location.lower()) + + def test_ics_download_is_gated(self): + # The .ics holds name/date/venue/description. This is a non-website + # route, so it exercises the same generic ir.http guard that also + # covers website_event_track's /event//track//ics: a locked + # visitor is bounced to the slug-free gate, never the file. + res = self.url_open(f"/event/{self.event.id}/ics", allow_redirects=False) + self.assertIn(res.status_code, (301, 302, 303)) + self.assertTrue( + res.headers.get("Location", "").endswith(f"/event/{self.event.id}/private") + ) + + def test_ics_download_open_for_manager(self): + self.authenticate("admin", "admin") + res = self.url_open(f"/event/{self.event.id}/ics") + self.assertEqual(res.status_code, 200) diff --git a/website_event_hidden_by_password/utils.py b/website_event_hidden_by_password/utils.py new file mode 100644 index 000000000..3ff7396db --- /dev/null +++ b/website_event_hidden_by_password/utils.py @@ -0,0 +1,31 @@ +# Copyright 2026 Riccardo Fiore +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +"""Shared helpers for the private-event access gate. + +Kept here (rather than on the controller) so both the controller and the +``ir.http`` routing override can ask the same question — "may this visitor +see this private event?" — without one importing the other. +""" + +# Session list of event ids the current visitor has unlocked this session. +SESSION_KEY = "event_private_unlocked" + + +def is_event_manager(env): + """Event managers always bypass the gate (preview / edit the page).""" + return env.user.has_group("event.group_event_user") + + +def event_visitor_unlocked(env, session, event): + """Whether the current visitor may see this (possibly private) event. + + :param env: the (real, non-sudo) environment, used for the manager check. + :param session: the HTTP session holding the unlocked-event id list. + :param event: the ``event.event`` record (may be sudo'd; only ``id`` and + ``is_private`` are read). + """ + if not event.is_private: + return True + if is_event_manager(env): + return True + return event.id in (session.get(SESSION_KEY) or []) diff --git a/website_event_hidden_by_password/views/event_event_views.xml b/website_event_hidden_by_password/views/event_event_views.xml new file mode 100644 index 000000000..52d7fa317 --- /dev/null +++ b/website_event_hidden_by_password/views/event_event_views.xml @@ -0,0 +1,45 @@ + + + + + event.event.form.private + event.event + + + + + + + + + + + diff --git a/website_event_hidden_by_password/views/website_event_templates.xml b/website_event_hidden_by_password/views/website_event_templates.xml new file mode 100644 index 000000000..0bfdd5b90 --- /dev/null +++ b/website_event_hidden_by_password/views/website_event_templates.xml @@ -0,0 +1,114 @@ + + + + + + + + +