cherrypy-foundation 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. cherrypy_foundation/__init__.py +0 -0
  2. cherrypy_foundation/components/ColorModes.jinja +70 -0
  3. cherrypy_foundation/components/Datatable.css +47 -0
  4. cherrypy_foundation/components/Datatable.jinja +63 -0
  5. cherrypy_foundation/components/Datatable.js +358 -0
  6. cherrypy_foundation/components/Field.css +10 -0
  7. cherrypy_foundation/components/Field.jinja +66 -0
  8. cherrypy_foundation/components/Field.js +56 -0
  9. cherrypy_foundation/components/Fields.jinja +4 -0
  10. cherrypy_foundation/components/Flash.jinja +13 -0
  11. cherrypy_foundation/components/Icon.jinja +3 -0
  12. cherrypy_foundation/components/LocaleSelection.jinja +13 -0
  13. cherrypy_foundation/components/LocaleSelection.js +26 -0
  14. cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
  15. cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
  16. cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
  17. cherrypy_foundation/components/Typeahead.css +55 -0
  18. cherrypy_foundation/components/Typeahead.jinja +106 -0
  19. cherrypy_foundation/components/Typeahead.js +8 -0
  20. cherrypy_foundation/components/__init__.py +51 -0
  21. cherrypy_foundation/components/tests/__init__.py +0 -0
  22. cherrypy_foundation/components/tests/test_static.py +90 -0
  23. cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
  24. cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
  25. cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  26. cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  27. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
  28. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
  29. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
  30. cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
  31. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
  32. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
  33. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
  34. cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
  35. cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
  36. cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
  37. cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
  38. cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
  39. cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
  40. cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
  41. cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
  42. cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
  43. cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
  44. cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
  45. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
  46. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
  47. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
  48. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
  49. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
  50. cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
  51. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
  52. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
  53. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
  54. cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
  55. cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
  56. cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
  57. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
  58. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
  59. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
  60. cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
  61. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
  62. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
  63. cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
  64. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
  65. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
  66. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
  67. cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
  68. cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
  69. cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
  70. cherrypy_foundation/components/vendor/multi/README.md +109 -0
  71. cherrypy_foundation/components/vendor/multi/multi.css +95 -0
  72. cherrypy_foundation/components/vendor/multi/multi.js +328 -0
  73. cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
  74. cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
  75. cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
  76. cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
  77. cherrypy_foundation/error_page.py +94 -0
  78. cherrypy_foundation/flash.py +50 -0
  79. cherrypy_foundation/form.py +119 -0
  80. cherrypy_foundation/logging.py +103 -0
  81. cherrypy_foundation/passwd.py +65 -0
  82. cherrypy_foundation/plugins/__init__.py +0 -0
  83. cherrypy_foundation/plugins/db.py +286 -0
  84. cherrypy_foundation/plugins/ldap.py +257 -0
  85. cherrypy_foundation/plugins/restapi.py +74 -0
  86. cherrypy_foundation/plugins/scheduler.py +287 -0
  87. cherrypy_foundation/plugins/smtp.py +223 -0
  88. cherrypy_foundation/plugins/tests/__init__.py +0 -0
  89. cherrypy_foundation/plugins/tests/test_db.py +118 -0
  90. cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
  91. cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
  92. cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
  93. cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
  94. cherrypy_foundation/sessions.py +93 -0
  95. cherrypy_foundation/tests/__init__.py +72 -0
  96. cherrypy_foundation/tests/templates/test_flash.html +9 -0
  97. cherrypy_foundation/tests/templates/test_form.html +16 -0
  98. cherrypy_foundation/tests/templates/test_url.html +15 -0
  99. cherrypy_foundation/tests/test_error_page.py +78 -0
  100. cherrypy_foundation/tests/test_flash.py +61 -0
  101. cherrypy_foundation/tests/test_form.py +148 -0
  102. cherrypy_foundation/tests/test_logging.py +78 -0
  103. cherrypy_foundation/tests/test_passwd.py +51 -0
  104. cherrypy_foundation/tests/test_sessions.py +89 -0
  105. cherrypy_foundation/tests/test_url.py +161 -0
  106. cherrypy_foundation/tools/__init__.py +0 -0
  107. cherrypy_foundation/tools/auth.py +263 -0
  108. cherrypy_foundation/tools/auth_mfa.py +249 -0
  109. cherrypy_foundation/tools/i18n.py +529 -0
  110. cherrypy_foundation/tools/jinja2.py +158 -0
  111. cherrypy_foundation/tools/ratelimit.py +265 -0
  112. cherrypy_foundation/tools/secure_headers.py +119 -0
  113. cherrypy_foundation/tools/sessions_timeout.py +167 -0
  114. cherrypy_foundation/tools/tests/__init__.py +0 -0
  115. cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
  116. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
  117. cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
  118. cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
  119. cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
  120. cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
  121. cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
  122. cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
  123. cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
  124. cherrypy_foundation/tools/tests/test_auth.py +110 -0
  125. cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
  126. cherrypy_foundation/tools/tests/test_i18n.py +247 -0
  127. cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
  128. cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
  129. cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
  130. cherrypy_foundation/url.py +66 -0
  131. cherrypy_foundation/widgets.py +48 -0
  132. cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
  133. cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
  134. cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
  135. cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
  136. cherrypy_foundation-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,265 @@
1
+ # Ratelimit tools for cherrypy
2
+ # Copyright (C) 2022-2026 IKUS Software
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ import hashlib
17
+ import os
18
+ import tempfile
19
+ import threading
20
+ import time
21
+ from collections import namedtuple
22
+
23
+ import cherrypy
24
+
25
+ from cherrypy_foundation.sessions import session_lock
26
+
27
+ Tracker = namedtuple('Tracker', ['token', 'hits', 'timeout'])
28
+
29
+
30
+ class _DataStore:
31
+ """
32
+ Base class for rate limit data store
33
+ """
34
+
35
+ def __init__(self, **kwargs):
36
+ self._locks = {}
37
+
38
+ def get_and_increment(self, token, delay, hit=1):
39
+ lock = self._locks.setdefault(token, threading.RLock())
40
+ with lock:
41
+ tracker = self._load(token)
42
+ if tracker is None or tracker.timeout < time.time():
43
+ tracker = Tracker(token=token, hits=0, timeout=int(time.time() + delay))
44
+ tracker = tracker._replace(hits=tracker.hits + hit)
45
+ self._save(tracker)
46
+ return tracker.hits, tracker.timeout
47
+
48
+ def _save(self, tracker):
49
+ raise NotImplementedError
50
+
51
+ def _load(self, token):
52
+ raise NotImplementedError
53
+
54
+
55
+ class RamRateLimit(_DataStore):
56
+ """
57
+ Store rate limit information in memory.
58
+ """
59
+
60
+ def __init__(self, **kwargs):
61
+ super().__init__(**kwargs)
62
+ self._data = {}
63
+
64
+ def _load(self, token):
65
+ return self._data.get(token)
66
+
67
+ def _save(self, tracker):
68
+ self._data[tracker.token] = tracker
69
+
70
+ def reset(self):
71
+ self._data = {}
72
+
73
+
74
+ class FileRateLimit(_DataStore):
75
+ PREFIX = 'ratelimit-'
76
+
77
+ def __init__(self, storage_path, **kwargs):
78
+ super().__init__(**kwargs)
79
+ assert storage_path, 'FileRateLimit requires storage_path'
80
+ self.storage_path = os.path.abspath(storage_path)
81
+ os.makedirs(self.storage_path, exist_ok=True)
82
+
83
+ def _path(self, token):
84
+ # Hash the token to avoid max path limit and invalid chars.
85
+ digest = hashlib.sha256(token.encode('utf-8')).hexdigest()
86
+ return os.path.join(self.storage_path, self.PREFIX + digest + '.txt')
87
+
88
+ def _load(self, token):
89
+ path = self._path(token)
90
+ try:
91
+ with open(path, 'r', encoding='utf-8') as f:
92
+ line = f.readline().strip()
93
+ except FileNotFoundError:
94
+ return None
95
+ except OSError:
96
+ return None
97
+
98
+ if not line:
99
+ return None
100
+
101
+ parts = line.split()
102
+ if len(parts) != 2:
103
+ return None
104
+
105
+ try:
106
+ hits = int(parts[0])
107
+ timeout = int(parts[1])
108
+ if hits < 0 or timeout < 0:
109
+ return None
110
+ except ValueError:
111
+ return None
112
+
113
+ return Tracker(token=token, hits=hits, timeout=timeout)
114
+
115
+ def _save(self, tracker):
116
+ path = self._path(tracker.token)
117
+ # Atomic write: temp file in same directory
118
+ fd, tmp = tempfile.mkstemp(dir=self.storage_path)
119
+ try:
120
+ with os.fdopen(fd, 'w', encoding='utf-8') as f:
121
+ f.write(f"{tracker.hits} {int(tracker.timeout)}\n")
122
+ f.flush()
123
+ os.fsync(f.fileno())
124
+ os.replace(tmp, path)
125
+ # On POSIX, chmod on first write
126
+ try:
127
+ os.chmod(path, 0o600)
128
+ except OSError:
129
+ pass
130
+ finally:
131
+ try:
132
+ if os.path.exists(tmp):
133
+ os.remove(tmp)
134
+ except Exception:
135
+ pass
136
+
137
+ def reset(self):
138
+ for item in os.listdir(self.storage_path):
139
+ if not item.startswith(self.PREFIX):
140
+ continue
141
+ fn = os.path.join(self.storage_path, item)
142
+ if not os.path.isfile(fn):
143
+ continue
144
+ try:
145
+ os.remove(fn)
146
+ except OSError:
147
+ pass
148
+
149
+
150
+ class Ratelimit(cherrypy.Tool):
151
+ CONTEXT = 'TOOLS.RATELIMIT'
152
+
153
+ _datastores = {}
154
+
155
+ def __init__(self, priority=60):
156
+ super().__init__('before_handler', self.check_ratelimit, 'ratelimit', priority)
157
+
158
+ def _get_datastore(self, storage_class, **kwargs):
159
+ """
160
+ Create new datastore or return existing datastore.
161
+ """
162
+ # Default to RAM if storage class is not defined.
163
+ if storage_class is None:
164
+ kwargs = {}
165
+ storage_class = RamRateLimit
166
+ # Lookup for matching storage.
167
+ key = (storage_class, str(kwargs))
168
+ datastore = self._datastores.get(key)
169
+ if datastore is None:
170
+ # Create new storage if not found.
171
+ self._datastores[key] = datastore = storage_class(**kwargs)
172
+ return datastore
173
+
174
+ def check_ratelimit(
175
+ self,
176
+ delay=3600,
177
+ limit=25,
178
+ return_status=429,
179
+ logout=False,
180
+ scope=None,
181
+ methods=None,
182
+ debug=False,
183
+ hit=1,
184
+ storage_class=None,
185
+ **storage_kwargs,
186
+ ):
187
+ """
188
+ Verify the ratelimit. By default return a 429 HTTP error code (Too Many Request). After 25 request within the same hour.
189
+
190
+ Arguments:
191
+ delay: Time window for analysis in seconds
192
+ limit: Number of request allowed for an entry point
193
+ return_status: HTTP Error code to return.
194
+ logout: True to logout user when limit is reached
195
+ scope: if specify, define the scope of rate limit. Default to path_info.
196
+ methods: if specify, only the methods in the list will be rate limited.
197
+ """
198
+ if delay <= 0:
199
+ raise ValueError('Invalid delay: %s' % delay)
200
+ if limit <= 0:
201
+ return
202
+
203
+ # Check if this 'method' should be rate limited
204
+ request = cherrypy.request
205
+ if methods is not None and request.method not in methods:
206
+ if debug:
207
+ cherrypy.log(f'skip rate limit for HTTP method {request.method}', context=self.CONTEXT)
208
+ return
209
+
210
+ # Create storage using storage class
211
+ datastore = self._get_datastore(storage_class, **storage_kwargs)
212
+
213
+ # Identifier: prefer authenticated user; else client IP
214
+ identifier = getattr(cherrypy.serving.request, 'login', None) or cherrypy.request.remote.ip
215
+
216
+ # Include method unless methods is explicitly a single method
217
+ method_part = request.method
218
+ if methods and len(methods) == 1:
219
+ method_part = next(iter(methods))
220
+
221
+ # Scope: allow explicit; else normalized path (e.g., strip trailing slash)
222
+ path = request.path_info.rstrip('/') or '/'
223
+ scope_value = scope or path
224
+
225
+ token = f"{identifier}|{method_part}|{scope_value}"
226
+
227
+ # Get hits count using datastore.
228
+ hits, timeout = datastore.get_and_increment(token, delay, hit)
229
+ if debug:
230
+ cherrypy.log(f'check and increase limit token={token} limit={limit} hits={hits}', context=self.CONTEXT)
231
+
232
+ # Verify user has not exceeded rate limit
233
+ remaining = max(0, limit - hits)
234
+
235
+ # Define headers
236
+ cherrypy.response.headers['X-RateLimit-Limit'] = str(limit)
237
+ cherrypy.response.headers['X-RateLimit-Remaining'] = str(remaining)
238
+ cherrypy.response.headers['X-RateLimit-Reset'] = str(timeout)
239
+
240
+ if limit < hits: # block only after 'limit' successful requests
241
+ cherrypy.log(f'block access to path_info={request.path_info}', context=self.CONTEXT)
242
+ if logout:
243
+ if hasattr(cherrypy.serving, 'session'):
244
+ with session_lock() as session:
245
+ session.clear()
246
+ raise cherrypy.HTTPRedirect("/")
247
+ raise cherrypy.HTTPError(return_status)
248
+
249
+ def increase_hit(self, hit=1):
250
+ """
251
+ May be called directly by handlers to add a hit for the given request.
252
+ """
253
+ conf = cherrypy.tools.ratelimit._merged_args()
254
+ conf['hit'] = hit
255
+ cherrypy.tools.ratelimit.callable(**conf)
256
+
257
+ def reset(self):
258
+ """
259
+ Used to reset the ratelimit. (for unit test)
260
+ """
261
+ for datastore in self._datastores.values():
262
+ datastore.reset()
263
+
264
+
265
+ cherrypy.tools.ratelimit = Ratelimit()
@@ -0,0 +1,119 @@
1
+ # Secure Headers tool for cherrypy
2
+ # Copyright (C) 2012-2026 IKUS Software
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import http.cookies
18
+
19
+ import cherrypy
20
+
21
+ DEFAULT_CSP = {
22
+ 'default-src': 'self',
23
+ 'style-src': ('self', 'unsafe-inline'),
24
+ 'script-src': ('self', 'unsafe-inline'),
25
+ }
26
+
27
+ #
28
+ # Patch Morsel prior to 3.8
29
+ # Allow SameSite attribute to be define on the cookie.
30
+ #
31
+ if not http.cookies.Morsel().isReservedKey("samesite"):
32
+ http.cookies.Morsel._reserved['samesite'] = 'SameSite'
33
+
34
+
35
+ def _build_csp(csp):
36
+ if isinstance(csp, dict):
37
+ return "; ".join(
38
+ f"{directive} {' '.join(sources) if isinstance(sources, (list, tuple)) else str(sources)}"
39
+ for directive, sources in csp.items()
40
+ )
41
+ return str(csp)
42
+
43
+
44
+ def set_headers(
45
+ xfo='DENY',
46
+ no_cache=True,
47
+ referrer='same-origin',
48
+ nosniff=True,
49
+ xxp='1; mode=block',
50
+ csp=DEFAULT_CSP,
51
+ ):
52
+ """
53
+ This tool provide CSRF mitigation.
54
+
55
+ * Define X-Frame-Options = DENY
56
+ * Define Cookies SameSite=Lax
57
+ * Define Cookies Secure when https is detected
58
+ * Validate `Origin` and `Referer` on POST, PUT, PATCH, DELETE
59
+ * Define Cache-Control by default
60
+ * Define Referrer-Policy to 'same-origin'
61
+
62
+ Ref.:
63
+ https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
64
+ https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html
65
+ """
66
+ request = cherrypy.request
67
+ response = cherrypy.serving.response
68
+
69
+ # Check if Origin matches our target.
70
+ if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
71
+ origin = request.headers.get('Origin', None)
72
+ if origin and origin != request.base:
73
+ raise cherrypy.HTTPError(403, 'Unexpected Origin header')
74
+
75
+ # Check if https is enabled
76
+ https = request.base.startswith('https')
77
+
78
+ # Define X-Frame-Options to avoid Clickjacking
79
+ if xfo:
80
+ response.headers['X-Frame-Options'] = xfo
81
+
82
+ # Enforce security on cookies
83
+ cookie = response.cookie.get('session_id', None)
84
+ if cookie:
85
+ # Awaiting bug fix in cherrypy
86
+ # https://github.com/cherrypy/cherrypy/issues/1767
87
+ # Force SameSite to Lax
88
+ cookie['samesite'] = 'Lax'
89
+ if https:
90
+ cookie['secure'] = 1
91
+
92
+ # Add Cache-Control to avoid storing sensible information in Browser cache.
93
+ if no_cache:
94
+ response.headers['Cache-control'] = 'no-cache, no-store, must-revalidate, max-age=0'
95
+ response.headers['Pragma'] = 'no-cache'
96
+ response.headers['Expires'] = '0'
97
+
98
+ # Add Referrer-Policy
99
+ if referrer:
100
+ response.headers['Referrer-Policy'] = referrer
101
+
102
+ # Add X-Content-Type-Options to avoid browser to "sniff" to content-type
103
+ if nosniff:
104
+ response.headers['X-Content-Type-Options'] = 'nosniff'
105
+
106
+ # Add X-XSS-Protection to enabled XSS protection
107
+ if xxp:
108
+ response.headers['X-XSS-Protection'] = xxp
109
+
110
+ # Add Content-Security-Policy
111
+ if csp:
112
+ response.headers['Content-Security-Policy'] = _build_csp(csp)
113
+
114
+ # Add Strict-Transport-Security to force https use.
115
+ if https:
116
+ response.headers['Strict-Transport-Security'] = "max-age=31536000; includeSubDomains"
117
+
118
+
119
+ cherrypy.tools.secure_headers = cherrypy.Tool('before_request_body', set_headers, priority=71)
@@ -0,0 +1,167 @@
1
+ # Session timeout tool for cherrypy
2
+ # Copyright (C) 2012-2026 IKUS Software
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import datetime
18
+ import math
19
+ import time
20
+
21
+ import cherrypy
22
+ from cherrypy.lib import httputil
23
+
24
+ from cherrypy_foundation.sessions import session_lock
25
+
26
+ SESSION_PERSISTENT = '_session_persistent'
27
+ SESSION_START_TIME = '_session_start_time'
28
+
29
+ SESSION_DEFAULT_ABSOLUTE_TIMEOUT = 43200 # 30 days
30
+ SESSION_DEFAULT_PERSISTENT_TIMEOUT = 10080 # 7 days
31
+ SESSION_DEFAULT_TIMEOUT = 60 # 60 minutes (from cherrypy default)
32
+
33
+
34
+ class SessionsTimeout(cherrypy.Tool):
35
+ """
36
+ Fine-grained control over session timeouts.
37
+
38
+ Config keys:
39
+ - tools.sessions.timeout (int, minutes) -> base idle timeout (CherryPy built-in, used for non-persistent)
40
+ - tools.sessions_timeout.absolute_timeout (int, minutes) -> hard cap for all sessions (default=43200 = 30d)
41
+ - tools.sessions_timeout.persistent_timeout (int, minutes) -> sliding window for persistent sessions (default=10080 = 7d)
42
+ - tools.sessions.name (str) -> session cookie name (default: 'session_id')
43
+ """
44
+
45
+ def __init__(self, priority: int = 75):
46
+ super().__init__(point='before_handler', callable=self.run, priority=priority)
47
+
48
+ @staticmethod
49
+ def _cookie_name() -> str:
50
+ return cherrypy.request.config.get('tools.sessions.name', 'session_id')
51
+
52
+ @staticmethod
53
+ def _get_config_timeouts() -> tuple[int, int, int]:
54
+ cfg = cherrypy.request.config
55
+ idle_timeout = int(cfg.get('tools.sessions.timeout', SESSION_DEFAULT_TIMEOUT))
56
+ persistent_timeout = int(
57
+ cfg.get('tools.sessions_timeout.persistent_timeout', SESSION_DEFAULT_PERSISTENT_TIMEOUT)
58
+ )
59
+ absolute_timeout = int(cfg.get('tools.sessions_timeout.absolute_timeout', SESSION_DEFAULT_ABSOLUTE_TIMEOUT))
60
+ return idle_timeout, persistent_timeout, absolute_timeout
61
+
62
+ def _set_cookie_max_age(self, session, max_age: int) -> None:
63
+ """Adjust only Max-Age and Expires for the session cookie, keep other flags intact."""
64
+ cookie_name = self._cookie_name()
65
+ cookie = cherrypy.serving.response.cookie
66
+ # Ensure the cookie key exists (CherryPy sets it when session id is issued/regenerated).
67
+ if cookie_name not in cookie:
68
+ cookie[cookie_name] = session.id
69
+ cookie[cookie_name]['max-age'] = str(max(0, max_age))
70
+ cookie[cookie_name]['expires'] = httputil.HTTPDate(time.time() + max(0, max_age))
71
+
72
+ @staticmethod
73
+ def _ceil_minutes(seconds: int) -> int:
74
+ if seconds <= 0:
75
+ return 0
76
+ return int(math.ceil(seconds / 60.0))
77
+
78
+ def _ensure_start_time(self, session) -> None:
79
+ """Ensure the session has a stable start time anchor for absolute timeout."""
80
+ if SESSION_START_TIME not in session or not isinstance(session.get(SESSION_START_TIME), datetime.datetime):
81
+ session[SESSION_START_TIME] = session.now()
82
+
83
+ def _expire_and_restart(self, session, make_persistent: bool | None = None) -> None:
84
+ """Expire current session and start a fresh one. Optionally set persistent flag."""
85
+ session.clear()
86
+ session.regenerate()
87
+ session[SESSION_START_TIME] = session.now()
88
+ if make_persistent is not None:
89
+ session[SESSION_PERSISTENT] = bool(make_persistent)
90
+
91
+ def _update_session_timeout(self, session) -> None:
92
+ self._ensure_start_time(session)
93
+
94
+ now: datetime.datetime = session.now()
95
+ start: datetime.datetime = session[SESSION_START_TIME]
96
+
97
+ idle_timeout_min, persistent_timeout_min, absolute_timeout_min = self._get_config_timeouts()
98
+
99
+ is_persistent = bool(session.get(SESSION_PERSISTENT, False))
100
+
101
+ # Compute absolute remaining (hard cap from start, never slides)
102
+ abs_expiration = start + datetime.timedelta(minutes=absolute_timeout_min)
103
+ abs_remaining_sec = int((abs_expiration - now).total_seconds())
104
+
105
+ if is_persistent:
106
+ # Sliding window for persistent sessions
107
+ sliding_exp = now + datetime.timedelta(minutes=persistent_timeout_min)
108
+ else:
109
+ # Sliding window for non-persistent sessions (idle timeout)
110
+ sliding_exp = now + datetime.timedelta(minutes=idle_timeout_min)
111
+
112
+ sliding_remaining_sec = int((sliding_exp - now).total_seconds())
113
+
114
+ # Effective remaining is the minimum of the sliding window and absolute cap
115
+ remaining_sec = min(abs_remaining_sec, sliding_remaining_sec)
116
+ remaining_min = self._ceil_minutes(remaining_sec)
117
+
118
+ if remaining_min <= 0:
119
+ # Expired due to either sliding window or absolute cap
120
+ # Start a fresh session; do NOT automatically re-mark persistent here.
121
+ # Authentication/Remember me logic elsewhere can call set_persistent(True) again after login.
122
+ self._expire_and_restart(session)
123
+ # After regeneration, set a sensible session.timeout baseline:
124
+ session.timeout = idle_timeout_min
125
+ return
126
+
127
+ # Apply per-request remaining minutes to CherryPy's session timeout
128
+ session.timeout = remaining_min
129
+
130
+ if is_persistent:
131
+ # Keep cookie lifetime aligned with remaining time so it can survive browser restarts.
132
+ self._set_cookie_max_age(session, remaining_sec)
133
+ else:
134
+ # For non-persistent, let CherryPy manage the session cookie (session or transient cookie).
135
+ # If you want strict enforcement client-side too, uncomment the next line:
136
+ # self._set_cookie_max_age(remaining_sec)
137
+ pass
138
+
139
+ def run(self, persistent_timeout: int = 10080, absolute_timeout: int = 43200) -> None:
140
+ # Skip if sessions are not enabled
141
+ if not hasattr(cherrypy.serving, 'session'):
142
+ return
143
+
144
+ # Ensure configured values are honored (method signature kept for CherryPy Tool interface)
145
+ with session_lock() as session:
146
+ self._update_session_timeout(session)
147
+
148
+ def set_persistent(self, value: bool = True) -> None:
149
+ """Mark current session as persistent (or not) and reset the start window."""
150
+ with session_lock() as session:
151
+ session[SESSION_PERSISTENT] = bool(value)
152
+ # Reset absolute window anchor when the persistence policy changes
153
+ session[SESSION_START_TIME] = session.now()
154
+ self._update_session_timeout(session)
155
+
156
+ def is_persistent(self) -> bool:
157
+ """Return True if the session is marked persistent."""
158
+ with session_lock() as session:
159
+ return bool(session.get(SESSION_PERSISTENT, False))
160
+
161
+ def get_session_start_time(self) -> datetime.datetime | None:
162
+ """Return the session start time if any."""
163
+ with session_lock() as session:
164
+ return session.get(SESSION_START_TIME, None)
165
+
166
+
167
+ cherrypy.tools.sessions_timeout = SessionsTimeout()
File without changes
@@ -0,0 +1,2 @@
1
+ {# def label, link #}
2
+ <a {{ attrs.render(class='btn btn-primary', href=link) }}>{{ label }}</a>
@@ -0,0 +1,15 @@
1
+ msgid ""
2
+ msgstr ""
3
+ "Project-Id-Version: \n"
4
+ "POT-Creation-Date: \n"
5
+ "PO-Revision-Date: \n"
6
+ "Last-Translator: \n"
7
+ "Language-Team: \n"
8
+ "Language: de\n"
9
+ "MIME-Version: 1.0\n"
10
+ "Content-Type: text/plain; charset=UTF-8\n"
11
+ "Content-Transfer-Encoding: 8bit\n"
12
+ "X-Generator: Poedit 3.6\n"
13
+
14
+ msgid "Some text to translate"
15
+ msgstr "Einige zu übersetzende Texte"
@@ -0,0 +1,15 @@
1
+ msgid ""
2
+ msgstr ""
3
+ "Project-Id-Version: \n"
4
+ "POT-Creation-Date: \n"
5
+ "PO-Revision-Date: \n"
6
+ "Last-Translator: \n"
7
+ "Language-Team: \n"
8
+ "Language: fr\n"
9
+ "MIME-Version: 1.0\n"
10
+ "Content-Type: text/plain; charset=UTF-8\n"
11
+ "Content-Transfer-Encoding: 8bit\n"
12
+ "X-Generator: Poedit 3.6\n"
13
+
14
+ msgid "Some text to translate"
15
+ msgstr "Du texte à traduire"
@@ -0,0 +1,2 @@
1
+ msgid "Some text to translate"
2
+ msgstr ""
@@ -0,0 +1,11 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>test-jinja2</title>
5
+ </head>
6
+ <body>
7
+ var1: {{ var1 }}<br/>
8
+ var2: {{ var2 }}<br/>
9
+ const1: {{ const1 }}<br/>
10
+ </body>
11
+ </html>
@@ -0,0 +1,9 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>test-jinjax</title>
5
+ </head>
6
+ <body>
7
+ <Button link="http://example.com" label="foo" />
8
+ </body>
9
+ </html>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="{{ get_translation().locale }}">
3
+ <head>
4
+ <title>test-jinja2</title>
5
+ {{ catalog.render_assets() }}
6
+ </head>
7
+ <body>
8
+ <!-- Selector -->
9
+ <div class="dropdown">
10
+ <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
11
+ Dropdown button
12
+ </button>
13
+ <ul class="dropdown-menu">
14
+ <LocaleSelection />
15
+ </ul>
16
+ </div>
17
+ {{ get_language_name(get_translation().locale) }}<br/>
18
+ {% trans %}Some text to translate{% endtrans %}<br/>
19
+ {{ my_datetime | format_datetime(format='full') }}<br/>
20
+ {{ my_date | format_date(format='full') }}
21
+ </body>
22
+ </html>