cherrypy-foundation 1.0.0a1__py3-none-any.whl → 1.0.0a3__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.
- cherrypy_foundation/components/Field.jinja +14 -6
- cherrypy_foundation/components/Typeahead.css +6 -1
- cherrypy_foundation/components/Typeahead.jinja +2 -2
- cherrypy_foundation/components/__init__.py +1 -1
- cherrypy_foundation/error_page.py +18 -15
- cherrypy_foundation/plugins/db.py +34 -8
- cherrypy_foundation/plugins/ldap.py +28 -22
- cherrypy_foundation/plugins/scheduler.py +197 -84
- cherrypy_foundation/plugins/smtp.py +71 -45
- cherrypy_foundation/plugins/tests/test_db.py +2 -2
- cherrypy_foundation/plugins/tests/test_scheduler.py +58 -50
- cherrypy_foundation/plugins/tests/test_smtp.py +9 -6
- cherrypy_foundation/tests/templates/test_form.html +10 -0
- cherrypy_foundation/tests/test_form.py +119 -0
- cherrypy_foundation/tools/auth.py +28 -11
- cherrypy_foundation/tools/auth_mfa.py +10 -13
- cherrypy_foundation/tools/errors.py +1 -1
- cherrypy_foundation/tools/i18n.py +15 -6
- cherrypy_foundation/tools/jinja2.py +15 -12
- cherrypy_foundation/tools/ratelimit.py +5 -9
- cherrypy_foundation/tools/secure_headers.py +0 -4
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +10 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2_i18n.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +8 -0
- cherrypy_foundation/tools/tests/test_auth.py +1 -1
- cherrypy_foundation/tools/tests/test_auth_mfa.py +367 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +123 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/METADATA +1 -1
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/RECORD +33 -25
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/WHEEL +0 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/licenses/LICENSE.md +0 -0
- {cherrypy_foundation-1.0.0a1.dist-info → cherrypy_foundation-1.0.0a3.dist-info}/top_level.txt +0 -0
|
@@ -29,16 +29,24 @@
|
|
|
29
29
|
{% set input_class, label_class, floating_supported, label_always_last = bootstrap_class_table.get(field.widget.__class__.__name__) or ('', 'form-label', False, False) %}
|
|
30
30
|
{% set label_last = (floating and floating_supported) or label_always_last %}
|
|
31
31
|
{% do attrs.add_class(input_class) %}
|
|
32
|
-
{# Render label an widget #}
|
|
33
32
|
{# Used for input-group #}
|
|
34
33
|
{% set prepend = attrs.get('prepend', None) %}
|
|
35
34
|
{% set append = attrs.get('append', None) %}
|
|
36
35
|
{% set input_group = prepend or append %}
|
|
37
|
-
{
|
|
38
|
-
{%
|
|
39
|
-
|
|
36
|
+
{# Build container & label specific attributes #}
|
|
37
|
+
{% set container_attrs = attrs.__class__({'class': 'mb-2 form-field'}) %}
|
|
38
|
+
{% set label_attrs = attrs.__class__({'class': label_class}) %}
|
|
39
|
+
{% for key, value in attrs.as_dict.items() %}
|
|
40
|
+
{% if key.startswith('container-') or key.startswith('container_') %}
|
|
41
|
+
{% do container_attrs.set(**{key[10:]: value}) %}
|
|
42
|
+
{% endif %}
|
|
43
|
+
{% if key.startswith('label-') or key.startswith('label_') %}
|
|
44
|
+
{% do label_attrs.set(**{key[6:]: value}) %}
|
|
45
|
+
{% endif %}
|
|
46
|
+
{% endfor %}
|
|
47
|
+
<div {{ container_attrs.as_dict | xmlattr }}>
|
|
40
48
|
{% if floating and floating_supported %}<div class="form-floating">{% endif %}
|
|
41
|
-
{% if not label_last %}{{ field.label(
|
|
49
|
+
{% if not label_last %}{{ field.label(**label_attrs.as_dict) }}{% endif %}
|
|
42
50
|
{% if input_group %}
|
|
43
51
|
<div class="input-group">
|
|
44
52
|
{% if prepend %}<span class="input-group-text">{{ prepend }}</span>{% endif %}
|
|
@@ -48,7 +56,7 @@
|
|
|
48
56
|
{% if append %}<span class="input-group-text">{{ append }}</span>{% endif %}
|
|
49
57
|
</div>
|
|
50
58
|
{% endif %}
|
|
51
|
-
{% if label_last %}{{ field.label(
|
|
59
|
+
{% if label_last %}{{ field.label(**label_attrs.as_dict) }}{% endif %}
|
|
52
60
|
{% for error in field.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
|
|
53
61
|
{% if field.description %}<div class="form-text small test-secondary">{{ field.description }}</div>{% endif %}
|
|
54
62
|
{% if floating and floating_supported %}</div>{% endif %}
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
top: 0.5rem;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/* Fix lense position */
|
|
14
|
+
.typeahead__button button {
|
|
15
|
+
height: 100%;
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
/* Fix color for DarkMode */
|
|
14
19
|
.typeahead__container button,
|
|
15
20
|
.typeahead__container button.disabled,
|
|
@@ -47,4 +52,4 @@
|
|
|
47
52
|
.typeahead__list .typeahead__item:not([disabled])>a:hover {
|
|
48
53
|
background-color: var(--bs-tertiary-bg);
|
|
49
54
|
color: var(--bs-body-color);
|
|
50
|
-
}
|
|
55
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{#def
|
|
2
|
-
accent=
|
|
2
|
+
accent=False,
|
|
3
3
|
async_result=False,
|
|
4
4
|
autofocus=False,
|
|
5
5
|
backdrop=False,
|
|
@@ -27,7 +27,7 @@ href=None,
|
|
|
27
27
|
loading_animation=True,
|
|
28
28
|
matcher=None,
|
|
29
29
|
max_item=8,
|
|
30
|
-
max_item_per_group=
|
|
30
|
+
max_item_per_group=None,
|
|
31
31
|
max_length=None,
|
|
32
32
|
min_length=2,
|
|
33
33
|
multiselect=None,
|
|
@@ -19,9 +19,6 @@ import logging
|
|
|
19
19
|
|
|
20
20
|
import cherrypy
|
|
21
21
|
|
|
22
|
-
# Define the logger
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
22
|
HTML_ERROR_TEMPLATE = '''<!DOCTYPE html>
|
|
26
23
|
<html>
|
|
27
24
|
<head>
|
|
@@ -53,39 +50,45 @@ HTML_ERROR_TEMPLATE = '''<!DOCTYPE html>
|
|
|
53
50
|
'''
|
|
54
51
|
|
|
55
52
|
|
|
56
|
-
def error_page(
|
|
53
|
+
def error_page(status='', message='', traceback='', version=''):
|
|
57
54
|
"""
|
|
58
55
|
Error page handler to handle Plain Text (text/plain), Json (application/json) or HTML (text/html) output.
|
|
59
56
|
|
|
60
57
|
If available, uses Jina2 environment to generate error page using `error_page.html`.
|
|
61
58
|
"""
|
|
62
59
|
# Log server error exception
|
|
63
|
-
if
|
|
64
|
-
|
|
65
|
-
'error page
|
|
60
|
+
if status.startswith('500'):
|
|
61
|
+
cherrypy.log(
|
|
62
|
+
f'error page status={status} message={message}\n{traceback}', context='ERROR-PAGE', severity=logging.WARNING
|
|
66
63
|
)
|
|
67
64
|
|
|
68
65
|
# Replace message by generic one for 404. Default implementation leak path info.
|
|
69
|
-
if
|
|
70
|
-
|
|
66
|
+
if status == '404 Not Found':
|
|
67
|
+
message = 'Nothing matches the given URI'
|
|
71
68
|
|
|
72
69
|
# Check expected response type.
|
|
73
70
|
mtype = cherrypy.serving.response.headers.get('Content-Type') or cherrypy.tools.accept.callable(
|
|
74
71
|
['text/html', 'text/plain', 'application/json']
|
|
75
72
|
)
|
|
76
73
|
if mtype == 'text/plain':
|
|
77
|
-
return
|
|
74
|
+
return message
|
|
78
75
|
elif mtype == 'application/json':
|
|
79
|
-
return json.dumps({'message':
|
|
76
|
+
return json.dumps({'message': message, 'status': status})
|
|
80
77
|
elif mtype == 'text/html':
|
|
78
|
+
context = {'status': status, 'message': message, 'traceback': traceback, 'version': version}
|
|
81
79
|
if hasattr(cherrypy.tools, 'jinja2'):
|
|
82
80
|
# Try to build a nice error page with Jinja2 env
|
|
83
81
|
try:
|
|
84
|
-
return cherrypy.tools.jinja2.render_request(template='error_page.html',
|
|
82
|
+
return cherrypy.tools.jinja2.render_request(template='error_page.html', context=context)
|
|
85
83
|
except Exception:
|
|
86
|
-
|
|
84
|
+
cherrypy.log(
|
|
85
|
+
'fail to render error page with jinja2',
|
|
86
|
+
context='ERROR-PAGE',
|
|
87
|
+
severity=logging.ERROR,
|
|
88
|
+
traceback=True,
|
|
89
|
+
)
|
|
87
90
|
# Fallback to built-int HTML error page
|
|
88
|
-
return HTML_ERROR_TEMPLATE %
|
|
91
|
+
return HTML_ERROR_TEMPLATE % context
|
|
89
92
|
|
|
90
93
|
# Fallback to raw error message.
|
|
91
|
-
return
|
|
94
|
+
return message
|
|
@@ -23,8 +23,6 @@ from sqlalchemy.engine import Engine
|
|
|
23
23
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
24
24
|
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
|
|
25
25
|
|
|
26
|
-
logger = logging.getLogger(__name__)
|
|
27
|
-
|
|
28
26
|
|
|
29
27
|
@event.listens_for(Engine, 'connect')
|
|
30
28
|
def _set_sqlite_journal_mode_wal(connection, connection_record):
|
|
@@ -168,6 +166,10 @@ class SQLA(SimplePlugin):
|
|
|
168
166
|
_session = None
|
|
169
167
|
_engine = None
|
|
170
168
|
|
|
169
|
+
@property
|
|
170
|
+
def engine(self):
|
|
171
|
+
return self._engine
|
|
172
|
+
|
|
171
173
|
def start(self):
|
|
172
174
|
if self.uri is None:
|
|
173
175
|
return
|
|
@@ -180,14 +182,22 @@ class SQLA(SimplePlugin):
|
|
|
180
182
|
self.clear_sessions()
|
|
181
183
|
# Associate our session to our engine
|
|
182
184
|
self.get_session().configure(bind=self._engine)
|
|
183
|
-
self.bus.log("
|
|
185
|
+
self.bus.log("database session plugin started")
|
|
186
|
+
|
|
187
|
+
# This is slightly lower priority to get database started first.
|
|
188
|
+
start.priority = 45
|
|
184
189
|
|
|
185
190
|
def stop(self):
|
|
186
191
|
if self._session:
|
|
187
192
|
self.clear_sessions()
|
|
188
193
|
if self._engine:
|
|
189
194
|
self._engine.dispose()
|
|
190
|
-
self.bus.log("
|
|
195
|
+
self.bus.log("database session plugin stopped")
|
|
196
|
+
|
|
197
|
+
def graceful(self):
|
|
198
|
+
"""Reload of subscribers."""
|
|
199
|
+
self.stop()
|
|
200
|
+
self.start()
|
|
191
201
|
|
|
192
202
|
def create_all(self):
|
|
193
203
|
try:
|
|
@@ -242,11 +252,27 @@ class SQLA(SimplePlugin):
|
|
|
242
252
|
try:
|
|
243
253
|
# When terminating, raise an error if objects are not commit.
|
|
244
254
|
if self._session.dirty or self._session.new or self._session.deleted:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
'
|
|
248
|
-
|
|
255
|
+
cherrypy.log(
|
|
256
|
+
'database session dirty; uncommitted objects detected — potential application bug',
|
|
257
|
+
context='DB',
|
|
258
|
+
severity=logging.ERROR,
|
|
249
259
|
)
|
|
260
|
+
if self._session.dirty:
|
|
261
|
+
changes = ', '.join([str(_get_model_changes(obj)) for obj in self._session.dirty])
|
|
262
|
+
cherrypy.log(
|
|
263
|
+
f'database session dirty_objects={self._session.dirty}', context='DB', severity=logging.ERROR
|
|
264
|
+
)
|
|
265
|
+
cherrypy.log(f'database session pending_changes={changes}', context='DB', severity=logging.ERROR)
|
|
266
|
+
if self._session.new:
|
|
267
|
+
cherrypy.log(
|
|
268
|
+
f'database session new_objects={self._session.new}', context='DB', severity=logging.ERROR
|
|
269
|
+
)
|
|
270
|
+
if self._session.deleted:
|
|
271
|
+
cherrypy.log(
|
|
272
|
+
f'database session deleted_objects={self._session.deleted}',
|
|
273
|
+
context='DB',
|
|
274
|
+
severity=logging.ERROR,
|
|
275
|
+
)
|
|
250
276
|
raise SQLAlchemyError('session is dirty')
|
|
251
277
|
finally:
|
|
252
278
|
self._session.rollback()
|
|
@@ -21,8 +21,6 @@ import ldap3
|
|
|
21
21
|
from cherrypy.process.plugins import SimplePlugin
|
|
22
22
|
from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult
|
|
23
23
|
|
|
24
|
-
logger = logging.getLogger(__name__)
|
|
25
|
-
|
|
26
24
|
_safe = ldap3.utils.conv.escape_filter_chars
|
|
27
25
|
|
|
28
26
|
|
|
@@ -126,6 +124,11 @@ class LdapPlugin(SimplePlugin):
|
|
|
126
124
|
if hasattr(self, '_pool'):
|
|
127
125
|
self._pool.unbind()
|
|
128
126
|
|
|
127
|
+
def graceful(self):
|
|
128
|
+
"""Reload of subscribers."""
|
|
129
|
+
self.stop()
|
|
130
|
+
self.start()
|
|
131
|
+
|
|
129
132
|
def authenticate(self, username, password):
|
|
130
133
|
"""
|
|
131
134
|
Check if the given credential as valid according to LDAP.
|
|
@@ -148,9 +151,9 @@ class LdapPlugin(SimplePlugin):
|
|
|
148
151
|
search_filter = f"(&{self.user_filter}(|{attr_filter}))"
|
|
149
152
|
response = self._search(conn, search_filter)
|
|
150
153
|
if not response:
|
|
151
|
-
|
|
154
|
+
cherrypy.log(f"lookup failed username={username} reason=not_found", context='LDAP')
|
|
152
155
|
return False
|
|
153
|
-
|
|
156
|
+
cherrypy.log(f"lookup successful username={username}", context='LDAP')
|
|
154
157
|
user_dn = response[0]['dn']
|
|
155
158
|
|
|
156
159
|
# Use a separate connection to validate credentials
|
|
@@ -163,17 +166,21 @@ class LdapPlugin(SimplePlugin):
|
|
|
163
166
|
client_strategy=ldap3.ASYNC,
|
|
164
167
|
)
|
|
165
168
|
if not login_conn.bind():
|
|
166
|
-
|
|
169
|
+
cherrypy.log(
|
|
170
|
+
f'ldap authentication failed username={username} reason=wrong_password',
|
|
171
|
+
context='LDAP',
|
|
172
|
+
severity=logging.WARNING,
|
|
173
|
+
)
|
|
167
174
|
return False
|
|
168
175
|
|
|
169
176
|
# Get username
|
|
170
177
|
attrs = response[0]['attributes']
|
|
171
178
|
new_username = first_attribute(attrs, self.username_attribute)
|
|
172
179
|
if not new_username:
|
|
173
|
-
|
|
174
|
-
"
|
|
175
|
-
|
|
176
|
-
|
|
180
|
+
cherrypy.log(
|
|
181
|
+
f"object missing username attribute user_dn={user_dn} attribute={self.username_attribute}",
|
|
182
|
+
context='LDAP',
|
|
183
|
+
severity=logging.WARNING,
|
|
177
184
|
)
|
|
178
185
|
return False
|
|
179
186
|
|
|
@@ -189,17 +196,15 @@ class LdapPlugin(SimplePlugin):
|
|
|
189
196
|
self.group_filter,
|
|
190
197
|
)
|
|
191
198
|
# Search LDAP Server for matching groups.
|
|
192
|
-
|
|
193
|
-
"check
|
|
194
|
-
|
|
195
|
-
' '.join(self.required_group),
|
|
199
|
+
cherrypy.log(
|
|
200
|
+
f"group check start username={user_value} required_groups={' '.join(self.required_group)}",
|
|
201
|
+
context='LDAP',
|
|
196
202
|
)
|
|
197
203
|
response = self._search(conn, group_filter, attributes=['cn'])
|
|
198
204
|
if not response:
|
|
199
|
-
|
|
200
|
-
"
|
|
201
|
-
|
|
202
|
-
' '.join(self.required_group),
|
|
205
|
+
cherrypy.log(
|
|
206
|
+
f"group check failed username={user_value} required_groups={' '.join(self.required_group)}",
|
|
207
|
+
context='LDAP',
|
|
203
208
|
)
|
|
204
209
|
return False
|
|
205
210
|
|
|
@@ -215,7 +220,9 @@ class LdapPlugin(SimplePlugin):
|
|
|
215
220
|
except LDAPInvalidCredentialsResult:
|
|
216
221
|
return False
|
|
217
222
|
except LDAPException:
|
|
218
|
-
|
|
223
|
+
cherrypy.log(
|
|
224
|
+
f"unexpected error username={username}", context='LDAP', severity=logging.ERROR, traceback=True
|
|
225
|
+
)
|
|
219
226
|
return None
|
|
220
227
|
|
|
221
228
|
def search(self, filter, attributes=ldap3.ALL_ATTRIBUTES, search_base=None, paged_size=None):
|
|
@@ -228,11 +235,10 @@ class LdapPlugin(SimplePlugin):
|
|
|
228
235
|
search_scope = {'base': ldap3.BASE, 'onelevel': ldap3.LEVEL, 'subtree': ldap3.SUBTREE}.get(
|
|
229
236
|
self.scope, ldap3.SUBTREE
|
|
230
237
|
)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
)
|
|
238
|
+
search_base = search_base or self.base_dn
|
|
239
|
+
cherrypy.log(f"search {self.uri}/{search_base}?{search_scope}?{filter}", context='LDAP')
|
|
234
240
|
msg_id = conn.search(
|
|
235
|
-
search_base=search_base
|
|
241
|
+
search_base=search_base,
|
|
236
242
|
search_filter=filter,
|
|
237
243
|
search_scope=search_scope,
|
|
238
244
|
time_limit=self.timeout,
|