Skip to content

Commit 5c49968

Browse files
committed
[IMP] session_db: Modify the way to parse data from session data
- Add a way to detect the binary values to parse as strings when saving the session to the database. - To identify the data that was converted from binary to str, add a prefix base64:: to the string. - Then, when the session needs to be read, detect the values modified, and parse again to base64. - Verify if some value of the session is a monetary value and parse it to a float type. - Keep string type in the value of the 'debug' website parameter.
1 parent 9aa256d commit 5c49968

File tree

2 files changed

+175
-1
lines changed

2 files changed

+175
-1
lines changed

session_db/pg_session_store.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
# @author Nicolas Seinlet
33
# Copyright (c) ACSONE SA 2022
44
# @author Stéphane Bidoul
5+
import base64
56
import json
67
import logging
78
import os
9+
import re
810

911
import psycopg2
1012

@@ -66,6 +68,7 @@ def __init__(self, uri, session_class=None):
6668
self._cr = None
6769
self._open_connection()
6870
self._setup_db()
71+
self.prefix_binary = "base64::"
6972

7073
def __del__(self):
7174
self._close_connection()
@@ -108,7 +111,8 @@ def _setup_db(self):
108111
@with_lock
109112
@with_cursor
110113
def save(self, session):
111-
payload = json.dumps(dict(session))
114+
json_session = self.session_to_str(dict(session))
115+
payload = json.dumps(json_session)
112116
self._cr.execute(
113117
"""
114118
INSERT INTO http_sessions(sid, write_date, payload)
@@ -131,6 +135,7 @@ def get(self, sid):
131135
self._cr.execute("SELECT payload FROM http_sessions WHERE sid=%s", (sid,))
132136
try:
133137
data = json.loads(self._cr.fetchone()[0])
138+
data = self.str_to_session(data)
134139
except Exception:
135140
return self.new()
136141

@@ -149,6 +154,73 @@ def vacuum(self, max_lifetime=http.SESSION_LIFETIME):
149154
(f"{max_lifetime} seconds",),
150155
)
151156

157+
def _traverse_and_convert(self, data_node, conversion_func):
158+
"""Helper method that preserves keys while converting values."""
159+
if isinstance(data_node, dict):
160+
res = {}
161+
for key, value in data_node.items():
162+
# This is necessary because Odoo's core (ir_qweb) needs the 'debug' value as
163+
# a string.
164+
# The value for this key can be: "1", "assets", "True", "False", etc.
165+
# Ref: https://github.com/Vauxoo/odoo/blob/d4d64d613800b8dc44c3262e13a2a81dbf3c742c/
166+
# odoo/addons/base/models/ir_qweb.py#L912
167+
# A test on an Odoo instance without the 'session_db' module confirmed
168+
# that 'request.session.debug' value is always a string (str) type.
169+
if key != "debug":
170+
key = self._traverse_and_convert(key, conversion_func)
171+
value = self._traverse_and_convert(value, conversion_func)
172+
res.update({key: value})
173+
return res
174+
if isinstance(data_node, list):
175+
return [
176+
self._traverse_and_convert(item, conversion_func) for item in data_node
177+
]
178+
return conversion_func(data_node)
179+
180+
def session_to_str(self, data):
181+
"""Converts binary values to prefixed strings."""
182+
183+
def convert(value):
184+
if isinstance(value, bytes):
185+
base64_string = base64.b64encode(value).decode("utf-8")
186+
return self.prefix_binary + base64_string
187+
return value
188+
189+
return self._traverse_and_convert(data, convert)
190+
191+
def str_to_session(self, data):
192+
"""Converts binary str to binary value again.
193+
Converts int/float str values convert to their respective types.
194+
"""
195+
196+
def convert(value):
197+
if not isinstance(value, str):
198+
return value # Only process strings
199+
# 1. Check for binary
200+
if value.startswith(self.prefix_binary):
201+
base64_string = value[len(self.prefix_binary) :]
202+
try:
203+
return base64.b64decode(base64_string)
204+
except (ValueError, TypeError):
205+
pass
206+
numeric_parsers = [
207+
# 2. Check for float (positive or negative)
208+
# This regex requires a decimal point.
209+
(r"^-?\d+\.\d+$", float),
210+
# 3. Check for integer (positive or negative)
211+
# This regex matches only digits (with optional sign).
212+
(r"^-?\d+$", int),
213+
]
214+
for pattern, parser in numeric_parsers:
215+
if re.match(pattern, value):
216+
try:
217+
return parser(value)
218+
except (ValueError, TypeError):
219+
pass
220+
return value
221+
222+
return self._traverse_and_convert(data, convert)
223+
152224

153225
_original_session_store = http.root.__class__.session_store
154226

session_db/tests/test_pg_session_store.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
from unittest import mock
23

34
import psycopg2
@@ -92,3 +93,104 @@ def test_make_postgres_uri(self):
9293
assert "postgres://test:PASSWORD@localhost:5432/test" == _make_postgres_uri(
9394
**connection_info
9495
)
96+
97+
def test_binary_serialization_roundtrip(self):
98+
"""Tests that binary data (bytes) converts to a string with a prefix
99+
when saving, and converts back to bytes when reading.
100+
"""
101+
original_data = {
102+
"normal_text": "test",
103+
"binary_data": b"Test binary",
104+
}
105+
106+
# 1. Simulate save (session_to_str)
107+
serialized = self.session_store.session_to_str(original_data)
108+
# Verify that the prefix was added and encoded in base64
109+
expected_b64 = base64.b64encode(b"Test binary").decode("utf-8")
110+
self.assertEqual(
111+
serialized["binary_data"],
112+
f"base64::{expected_b64}",
113+
"Binary data should be serialized with the base64:: prefix",
114+
)
115+
self.assertEqual(serialized["normal_text"], "test")
116+
117+
# 2. Simulate read (str_to_session)
118+
deserialized = self.session_store.str_to_session(serialized)
119+
# Verify that we recover the 'bytes' type
120+
self.assertEqual(deserialized["binary_data"], b"Test binary")
121+
self.assertIsInstance(deserialized["binary_data"], bytes)
122+
123+
def test_numeric_conversion(self):
124+
"""Tests that strings looking like numbers convert to int/float."""
125+
data_from_json = {
126+
"integer_str": "42",
127+
"float_str": "3.1416",
128+
"negative_int": "-10",
129+
"negative_float": "-0.01",
130+
"plain_text": "123-abc", # Should not convert
131+
}
132+
result = self.session_store.str_to_session(data_from_json)
133+
# Integer validations
134+
self.assertEqual(result["integer_str"], 42)
135+
self.assertIsInstance(result["integer_str"], int)
136+
self.assertEqual(result["negative_int"], -10)
137+
# Float validations
138+
self.assertEqual(result["float_str"], 3.1416)
139+
self.assertIsInstance(result["float_str"], float)
140+
self.assertEqual(result["negative_float"], -0.01)
141+
# Text validations that must not change
142+
self.assertEqual(result["plain_text"], "123-abc")
143+
self.assertIsInstance(result["plain_text"], str)
144+
145+
def test_debug_param_exception(self):
146+
"""Verifies that the "debug" key ALWAYS remains a string,
147+
even if it looks like an integer ("1").
148+
"""
149+
data = {
150+
"debug": "1", # Special case: must remain string
151+
"assets_debug": "1", # Normal case: must be int
152+
"debug_nested": {"debug": "0"}, # Recursive test
153+
}
154+
result = self.session_store.str_to_session(data)
155+
156+
# "debug" must be string "1", NOT integer 1
157+
self.assertEqual(result["debug"], "1")
158+
self.assertIsInstance(result["debug"], str)
159+
# Other keys do convert
160+
self.assertEqual(result["assets_debug"], 1)
161+
self.assertIsInstance(result["assets_debug"], int)
162+
# Verify exception applies recursively if the key is "debug"
163+
self.assertEqual(result["debug_nested"]["debug"], "0")
164+
self.assertIsInstance(result["debug_nested"]["debug"], str)
165+
166+
def test_recursive_traversal(self):
167+
"""Tests that conversion works in nested structures (lists and dicts)."""
168+
data = {"list_of_data": [b"binary_in_list", "100", {"deep_key": "50.5"}]}
169+
# 1. Serialize (Bytes -> Str)
170+
serialized = self.session_store.session_to_str(data)
171+
self.assertTrue(serialized["list_of_data"][0].startswith("base64::"))
172+
# 2. Deserialize (Str -> Bytes/Int/Float)
173+
# Simulate that JSON already loaded the string "100" and "50.5"
174+
# Note: session_to_str does not convert ints to strings, json.dumps does that later.
175+
# Here we test str_to_session with data appearing to come from JSON.
176+
input_for_read = {
177+
"list_of_data": [
178+
serialized["list_of_data"][0], # The base64 string
179+
"100",
180+
{"deep_key": "50.5"},
181+
]
182+
}
183+
result = self.session_store.str_to_session(input_for_read)
184+
185+
self.assertEqual(result["list_of_data"][0], b"binary_in_list")
186+
self.assertEqual(result["list_of_data"][1], 100)
187+
self.assertEqual(result["list_of_data"][2]["deep_key"], 50.5)
188+
189+
def test_invalid_base64_fallback(self):
190+
"""If a string has the base64:: prefix but the content is invalid,
191+
it must return the original value without crashing.
192+
"""
193+
invalid_data = {"bad_binary": "base64::TESTS"}
194+
result = self.session_store.str_to_session(invalid_data)
195+
# Should return the original string intact
196+
self.assertEqual(result["bad_binary"], "base64::TESTS")

0 commit comments

Comments
 (0)