From b94ee06d3ab029754c0b285418eaf02ae20e62ee Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 17 Aug 2026 23:06:30 -0400 Subject: [PATCH] fix: anonymous access to auth='user' pages crashed with 500 instead of redirecting to login Reported: clicking "Post a Listing" on /classifieds while logged out threw a raw 500 instead of prompting login. Root cause is upstream, in this Odoo 19 build's own http.py: when auth='user' raises SessionExpiredException for an anonymous visitor, Request._serve_db's `finally: self.env = None` clears the request env before the exception reaches the website error handler, which then tries to build the login redirect via self.env['ir.http']._redirect(...) and crashes with TypeError: 'NoneType' object is not subscriptable. This isn't specific to any one route - it reproduces on every auth='user' + website=True page hit anonymously, including stock Odoo's own /my (traced this back to the true cause rather than continuing to treat it as an unrelated environment quirk, since it now has a real reported symptom). Since core can't be patched here, worked around it at the route level across all 9 affected pages (classifieds new/my/renew, membership my/renew/card, benefits my, school attendance, portal my/school, event checkin): switched from auth='user' to auth='public' and added an explicit `if request.env.user._is_public(): return request.redirect(...)` check at the top of each handler, before Odoo's own auth layer ever gets a chance to raise. The jsonrpc AJAX endpoints (attendance save, checkin scan/dashboard) were left on auth='user' since they return a JSON error rather than attempting an HTML redirect, so they don't hit this path. Verified against a live Odoo 19 + Postgres 16 container: reproduced the original crash pre-fix, then confirmed all 9 previously-broken routes now 303-redirect to /web/login?redirect= when hit anonymously, that the login page carries the redirect target, that logged-in access is unaffected (200), and that the separate "logged in but lacking a required group" case (event check-in without Registration Desk) still degrades gracefully to a clean 403 rather than a crash. Full regression: 48/48 tests pass across the six touched modules. Co-Authored-By: Claude Sonnet 5 --- addons/community_benefits/controllers/main.py | 14 ++++++++- .../community_classifieds/controllers/main.py | 31 +++++++++++++++++-- .../controllers/portal.py | 24 ++++++++++++-- addons/community_portal/controllers/portal.py | 4 ++- .../controllers/attendance.py | 14 ++++++++- .../event_qr_ticketing/controllers/checkin.py | 14 ++++++++- 6 files changed, 91 insertions(+), 10 deletions(-) diff --git a/addons/community_benefits/controllers/main.py b/addons/community_benefits/controllers/main.py index e32ac9c..3f4e371 100644 --- a/addons/community_benefits/controllers/main.py +++ b/addons/community_benefits/controllers/main.py @@ -2,6 +2,15 @@ from odoo import http from odoo.http import request +def _redirect_to_login_if_public(path): + """Manual login-required redirect - see community_classifieds for why this + is needed instead of auth='user' (a bug in this Odoo version's own + SessionExpiredException -> login-redirect handling).""" + if request.env.user._is_public(): + return request.redirect(f'/web/login?redirect={path}') + return None + + class BenefitsController(http.Controller): @http.route(['/benefits'], type='http', auth='public', website=True, sitemap=True) @@ -9,8 +18,11 @@ class BenefitsController(http.Controller): centres = request.env['community.benefit.partner'].sudo().search([('active', '=', True)]) return request.render('community_benefits.benefits_directory_page', {'centres': centres}) - @http.route(['/my/benefits'], type='http', auth='user', website=True) + @http.route(['/my/benefits'], type='http', auth='public', website=True) def my_benefits(self, **kwargs): + redirect = _redirect_to_login_if_public('/my/benefits') + if redirect: + return redirect partner = request.env.user.partner_id benefits = request.env['community.benefit'].sudo().search( request.env['community.benefit']._entitled_domain_for_partner(partner) diff --git a/addons/community_classifieds/controllers/main.py b/addons/community_classifieds/controllers/main.py index f800beb..c94f73c 100644 --- a/addons/community_classifieds/controllers/main.py +++ b/addons/community_classifieds/controllers/main.py @@ -19,6 +19,22 @@ def _has_active_membership(partner): return partner.membership_state in ('active', 'renewal_due') +def _redirect_to_login_if_public(path): + """Manual login-required redirect. + + Routes here use auth='public' (not 'user') and check this explicitly, + because Odoo's own auth='user' + website=True error handling has a bug + in this version: SessionExpiredException triggers a login redirect via + self.env['ir.http']._redirect(...), but self.env has already been reset + to None by the finally block in Request._serve_db by the time the error + handler runs, causing a 500 instead of a redirect. Checking auth + ourselves and issuing a plain redirect avoids that code path entirely. + """ + if request.env.user._is_public(): + return request.redirect(f'/web/login?redirect={path}') + return None + + class ClassifiedsController(http.Controller): @http.route(['/classifieds'], type='http', auth='public', website=True, sitemap=True) @@ -40,8 +56,11 @@ class ClassifiedsController(http.Controller): listing._increment_view_count() return request.render('community_classifieds.classifieds_detail_page', {'listing': listing}) - @http.route(['/classifieds/new'], type='http', auth='user', website=True) + @http.route(['/classifieds/new'], type='http', auth='public', website=True) def classifieds_new(self, **kwargs): + redirect = _redirect_to_login_if_public('/classifieds/new') + if redirect: + return redirect partner = request.env.user.partner_id if _is_module_installed(request.env, 'community_membership') and not _has_active_membership(partner): return request.render('community_classifieds.classifieds_membership_required', {}) @@ -67,14 +86,20 @@ class ClassifiedsController(http.Controller): return request.render('community_classifieds.classifieds_new_page', {}) - @http.route(['/classifieds/my'], type='http', auth='user', website=True) + @http.route(['/classifieds/my'], type='http', auth='public', website=True) def classifieds_my(self, **kwargs): + redirect = _redirect_to_login_if_public('/classifieds/my') + if redirect: + return redirect partner = request.env.user.partner_id listings = request.env['community.classified'].sudo().search([('poster_partner_id', '=', partner.id)]) return request.render('community_classifieds.classifieds_my_page', {'listings': listings}) - @http.route(['/classifieds//renew'], type='http', auth='user', website=True) + @http.route(['/classifieds//renew'], type='http', auth='public', website=True) def classifieds_renew(self, classified_id, **kwargs): + redirect = _redirect_to_login_if_public(f'/classifieds/{classified_id}/renew') + if redirect: + return redirect partner = request.env.user.partner_id listing = request.env['community.classified'].sudo().search([ ('id', '=', classified_id), ('poster_partner_id', '=', partner.id), diff --git a/addons/community_membership/controllers/portal.py b/addons/community_membership/controllers/portal.py index 26895c0..1559993 100644 --- a/addons/community_membership/controllers/portal.py +++ b/addons/community_membership/controllers/portal.py @@ -3,6 +3,15 @@ from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.http import request +def _redirect_to_login_if_public(path): + """Manual login-required redirect - see community_classifieds for why this + is needed instead of auth='user' (a bug in this Odoo version's own + SessionExpiredException -> login-redirect handling).""" + if request.env.user._is_public(): + return request.redirect(f'/web/login?redirect={path}') + return None + + class MembershipPortal(CustomerPortal): def _prepare_home_portal_values(self, counters): @@ -12,16 +21,22 @@ class MembershipPortal(CustomerPortal): values['membership_count'] = 1 if partner.membership_state != 'none' else 0 return values - @http.route(['/my/membership'], type='http', auth='user', website=True) + @http.route(['/my/membership'], type='http', auth='public', website=True) def portal_my_membership(self, **kwargs): + redirect = _redirect_to_login_if_public('/my/membership') + if redirect: + return redirect partner = request.env.user.partner_id return request.render('community_membership.portal_my_membership', { 'partner': partner, 'page_name': 'membership', }) - @http.route(['/my/membership/renew'], type='http', auth='user', website=True) + @http.route(['/my/membership/renew'], type='http', auth='public', website=True) def portal_membership_renew(self, **kwargs): + redirect = _redirect_to_login_if_public('/my/membership/renew') + if redirect: + return redirect partner = request.env.user.partner_id invoice = request.env['account.move'].sudo().search([ ('partner_id', '=', partner.id), @@ -34,8 +49,11 @@ class MembershipPortal(CustomerPortal): return request.redirect('/my/membership') return request.redirect(f'/my/invoices/{invoice.id}') - @http.route(['/my/membership/card'], type='http', auth='user', website=True) + @http.route(['/my/membership/card'], type='http', auth='public', website=True) def portal_membership_card(self, **kwargs): + redirect = _redirect_to_login_if_public('/my/membership/card') + if redirect: + return redirect partner = request.env.user.partner_id pdf_content, _report_type = request.env['ir.actions.report'].sudo()._render_qweb_pdf( 'community_membership.action_report_membership_card', res_ids=partner.ids, diff --git a/addons/community_portal/controllers/portal.py b/addons/community_portal/controllers/portal.py index 621700f..23bb14f 100644 --- a/addons/community_portal/controllers/portal.py +++ b/addons/community_portal/controllers/portal.py @@ -48,8 +48,10 @@ class CommunityPortalDashboard(CustomerPortal): return values - @http.route(['/my/school'], type='http', auth='user', website=True) + @http.route(['/my/school'], type='http', auth='public', website=True) def portal_my_school(self, **kwargs): + if request.env.user._is_public(): + return request.redirect('/web/login?redirect=/my/school') partner = request.env.user.partner_id students = request.env['community.school.student'].sudo().search([ ('parent_partner_id', '=', partner.id), diff --git a/addons/community_school/controllers/attendance.py b/addons/community_school/controllers/attendance.py index 8605540..3d891c5 100644 --- a/addons/community_school/controllers/attendance.py +++ b/addons/community_school/controllers/attendance.py @@ -3,14 +3,26 @@ from odoo.exceptions import AccessDenied from odoo.http import request +def _redirect_to_login_if_public(path): + """Manual login-required redirect - see community_classifieds for why this + is needed instead of auth='user' (a bug in this Odoo version's own + SessionExpiredException -> login-redirect handling).""" + if request.env.user._is_public(): + return request.redirect(f'/web/login?redirect={path}') + return None + + class SchoolAttendanceController(http.Controller): def _get_teacher_classes(self): partner = request.env.user.partner_id return request.env['community.school.class'].sudo().search([('teacher_id', '=', partner.id)]) - @http.route(['/school/attendance'], type='http', auth='user', website=True) + @http.route(['/school/attendance'], type='http', auth='public', website=True) def attendance_home(self, class_id=None, date=None, **kwargs): + redirect = _redirect_to_login_if_public('/school/attendance') + if redirect: + return redirect classes = self._get_teacher_classes() if not classes: return request.render('community_school.portal_no_classes', {}) diff --git a/addons/event_qr_ticketing/controllers/checkin.py b/addons/event_qr_ticketing/controllers/checkin.py index 4cef49f..148610e 100644 --- a/addons/event_qr_ticketing/controllers/checkin.py +++ b/addons/event_qr_ticketing/controllers/checkin.py @@ -8,10 +8,22 @@ def _require_registration_desk(): raise AccessDenied() +def _redirect_to_login_if_public(path): + """Manual login-required redirect - see community_classifieds for why this + is needed instead of auth='user' (a bug in this Odoo version's own + SessionExpiredException -> login-redirect handling).""" + if request.env.user._is_public(): + return request.redirect(f'/web/login?redirect={path}') + return None + + class EventCheckinController(http.Controller): - @http.route(['/event/checkin', '/event/checkin/'], type='http', auth='user', website=True) + @http.route(['/event/checkin', '/event/checkin/'], type='http', auth='public', website=True) def checkin_page(self, event_id=None, **kwargs): + redirect = _redirect_to_login_if_public('/event/checkin') + if redirect: + return redirect _require_registration_desk() events = request.env['event.event'].search([('date_end', '>=', fields.Datetime.now())]) event = request.env['event.event'].browse(event_id) if event_id else events[:1]