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.
- cherrypy_foundation/__init__.py +0 -0
- cherrypy_foundation/components/ColorModes.jinja +70 -0
- cherrypy_foundation/components/Datatable.css +47 -0
- cherrypy_foundation/components/Datatable.jinja +63 -0
- cherrypy_foundation/components/Datatable.js +358 -0
- cherrypy_foundation/components/Field.css +10 -0
- cherrypy_foundation/components/Field.jinja +66 -0
- cherrypy_foundation/components/Field.js +56 -0
- cherrypy_foundation/components/Fields.jinja +4 -0
- cherrypy_foundation/components/Flash.jinja +13 -0
- cherrypy_foundation/components/Icon.jinja +3 -0
- cherrypy_foundation/components/LocaleSelection.jinja +13 -0
- cherrypy_foundation/components/LocaleSelection.js +26 -0
- cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
- cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
- cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
- cherrypy_foundation/components/Typeahead.css +55 -0
- cherrypy_foundation/components/Typeahead.jinja +106 -0
- cherrypy_foundation/components/Typeahead.js +8 -0
- cherrypy_foundation/components/__init__.py +51 -0
- cherrypy_foundation/components/tests/__init__.py +0 -0
- cherrypy_foundation/components/tests/test_static.py +90 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
- cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
- cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
- cherrypy_foundation/components/vendor/multi/README.md +109 -0
- cherrypy_foundation/components/vendor/multi/multi.css +95 -0
- cherrypy_foundation/components/vendor/multi/multi.js +328 -0
- cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
- cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
- cherrypy_foundation/error_page.py +94 -0
- cherrypy_foundation/flash.py +50 -0
- cherrypy_foundation/form.py +119 -0
- cherrypy_foundation/logging.py +103 -0
- cherrypy_foundation/passwd.py +65 -0
- cherrypy_foundation/plugins/__init__.py +0 -0
- cherrypy_foundation/plugins/db.py +286 -0
- cherrypy_foundation/plugins/ldap.py +257 -0
- cherrypy_foundation/plugins/restapi.py +74 -0
- cherrypy_foundation/plugins/scheduler.py +287 -0
- cherrypy_foundation/plugins/smtp.py +223 -0
- cherrypy_foundation/plugins/tests/__init__.py +0 -0
- cherrypy_foundation/plugins/tests/test_db.py +118 -0
- cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
- cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
- cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
- cherrypy_foundation/sessions.py +93 -0
- cherrypy_foundation/tests/__init__.py +72 -0
- cherrypy_foundation/tests/templates/test_flash.html +9 -0
- cherrypy_foundation/tests/templates/test_form.html +16 -0
- cherrypy_foundation/tests/templates/test_url.html +15 -0
- cherrypy_foundation/tests/test_error_page.py +78 -0
- cherrypy_foundation/tests/test_flash.py +61 -0
- cherrypy_foundation/tests/test_form.py +148 -0
- cherrypy_foundation/tests/test_logging.py +78 -0
- cherrypy_foundation/tests/test_passwd.py +51 -0
- cherrypy_foundation/tests/test_sessions.py +89 -0
- cherrypy_foundation/tests/test_url.py +161 -0
- cherrypy_foundation/tools/__init__.py +0 -0
- cherrypy_foundation/tools/auth.py +263 -0
- cherrypy_foundation/tools/auth_mfa.py +249 -0
- cherrypy_foundation/tools/i18n.py +529 -0
- cherrypy_foundation/tools/jinja2.py +158 -0
- cherrypy_foundation/tools/ratelimit.py +265 -0
- cherrypy_foundation/tools/secure_headers.py +119 -0
- cherrypy_foundation/tools/sessions_timeout.py +167 -0
- cherrypy_foundation/tools/tests/__init__.py +0 -0
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
- cherrypy_foundation/tools/tests/test_auth.py +110 -0
- cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
- cherrypy_foundation/tools/tests/test_i18n.py +247 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
- cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
- cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
- cherrypy_foundation/url.py +66 -0
- cherrypy_foundation/widgets.py +48 -0
- cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
- cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
- cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
- cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
- cherrypy_foundation-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Cherrypy-foundation
|
|
2
|
+
# Copyright (C) 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 hashlib
|
|
18
|
+
import os
|
|
19
|
+
from base64 import b64decode, b64encode
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
import argon2
|
|
23
|
+
|
|
24
|
+
_argon = argon2.PasswordHasher()
|
|
25
|
+
|
|
26
|
+
def hash_password(password):
|
|
27
|
+
assert password and isinstance(password, str)
|
|
28
|
+
return _argon.hash(password)
|
|
29
|
+
|
|
30
|
+
except ImportError:
|
|
31
|
+
_argon = None
|
|
32
|
+
|
|
33
|
+
def hash_password(password):
|
|
34
|
+
assert password and isinstance(password, str)
|
|
35
|
+
password = password.encode(encoding='utf8')
|
|
36
|
+
salt = os.urandom(4)
|
|
37
|
+
h = hashlib.sha1(password)
|
|
38
|
+
h.update(salt)
|
|
39
|
+
return "{SSHA}" + b64encode(h.digest() + salt).decode('latin1')
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_password(password, challenge):
|
|
43
|
+
"""
|
|
44
|
+
Check if the password matches the challenge.
|
|
45
|
+
The challenge is an encrypted password.
|
|
46
|
+
"""
|
|
47
|
+
if not password or not challenge:
|
|
48
|
+
return False
|
|
49
|
+
assert isinstance(password, str)
|
|
50
|
+
assert isinstance(challenge, str)
|
|
51
|
+
if _argon and challenge.startswith('$argon2'):
|
|
52
|
+
try:
|
|
53
|
+
return _argon.verify(challenge, password)
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
elif challenge.startswith('{SSHA}'):
|
|
57
|
+
digest_salt = b64decode(challenge[6:])
|
|
58
|
+
digest = digest_salt[:20]
|
|
59
|
+
sha = hashlib.sha1(password.encode(encoding='utf8'))
|
|
60
|
+
sha.update(digest_salt[20:])
|
|
61
|
+
return digest == sha.digest()
|
|
62
|
+
else:
|
|
63
|
+
# Fallback to previous SHA
|
|
64
|
+
sha = hashlib.sha1(password.encode('utf8'))
|
|
65
|
+
return challenge == sha.hexdigest()
|
|
File without changes
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# SQLAlchemy plugins 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 logging
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
import cherrypy
|
|
20
|
+
from cherrypy.process.plugins import SimplePlugin
|
|
21
|
+
from sqlalchemy import create_engine, event, inspect
|
|
22
|
+
from sqlalchemy.engine import Engine
|
|
23
|
+
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
24
|
+
from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@event.listens_for(Engine, 'connect')
|
|
28
|
+
def _set_sqlite_journal_mode_wal(connection, connection_record):
|
|
29
|
+
"""
|
|
30
|
+
Enable WAL journaling for concurrent read and write operation.
|
|
31
|
+
"""
|
|
32
|
+
if 'sqlite3' in str(connection.__class__):
|
|
33
|
+
cursor = connection.cursor()
|
|
34
|
+
cursor.execute('PRAGMA journal_mode=WAL;')
|
|
35
|
+
cursor.execute("PRAGMA foreign_keys=ON;")
|
|
36
|
+
cursor.close()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@event.listens_for(Engine, "handle_error")
|
|
40
|
+
def enhance_database_errors(exception_context):
|
|
41
|
+
"""
|
|
42
|
+
Enhance SQLAlchemy error by adding more information.
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(exception_context.sqlalchemy_exception, IntegrityError):
|
|
45
|
+
constraint = _find_constraint(exception_context.sqlalchemy_exception)
|
|
46
|
+
exception_context.sqlalchemy_exception.constraint = constraint
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _find_constraint(exception):
|
|
50
|
+
"""
|
|
51
|
+
Search reference of constraint within the error message. Return None if not found.
|
|
52
|
+
"""
|
|
53
|
+
# Extract the constraint name for Postgresql and SQLite
|
|
54
|
+
# Postgresql: duplicate key value violates unique constraint "subnet_name_key"\nDETAIL: Key (name)=() already exists.\n
|
|
55
|
+
# Postgresql: violates check constraint "dnsrecord_value_domain_name"
|
|
56
|
+
# Postgresql: foreign key constraint "dnsrecord_dnszone_subnet_fk"
|
|
57
|
+
# SQLite: UNIQUE constrain: subnet.name
|
|
58
|
+
# SQLite: UNIQUE constraint failed: index 'dnszone_name_index'
|
|
59
|
+
# SQLite: CHECK constraint failed: dnsrecord_value_domain_name
|
|
60
|
+
error = exception._message()
|
|
61
|
+
constraint_match = (
|
|
62
|
+
re.search(r'unique constraint "([^"]+)"', error)
|
|
63
|
+
or re.search(r'check constraint "([^"]+)"', error)
|
|
64
|
+
or re.search(r"UNIQUE constraint failed: index '([^']+)'", error)
|
|
65
|
+
or re.search(r"UNIQUE constraint failed: (.+)$", error)
|
|
66
|
+
or re.search(r"CHECK constraint failed: (.+)", error)
|
|
67
|
+
or re.search(r'foreign key constraint "([^"]+)"', error)
|
|
68
|
+
)
|
|
69
|
+
if not constraint_match:
|
|
70
|
+
return None
|
|
71
|
+
name = constraint_match[1]
|
|
72
|
+
|
|
73
|
+
# Use a lookup cache to simplify the search of index and constraints.
|
|
74
|
+
if not getattr(_find_constraint, '_cache', False):
|
|
75
|
+
cache = {}
|
|
76
|
+
metadata = cherrypy.db.get_base().metadata
|
|
77
|
+
for table in metadata.tables.values():
|
|
78
|
+
for item in table.constraints:
|
|
79
|
+
if item.name:
|
|
80
|
+
cache[item.name] = item
|
|
81
|
+
for item in table.indexes:
|
|
82
|
+
# Keep reference to unique index only.
|
|
83
|
+
if item.unique:
|
|
84
|
+
if item.name:
|
|
85
|
+
cache[item.name] = item
|
|
86
|
+
# SQLite return <table>.<column>
|
|
87
|
+
key = ', '.join([f'{table.name}.{c.name}' for c in item.columns])
|
|
88
|
+
cache[key] = item
|
|
89
|
+
_find_constraint._cache = cache
|
|
90
|
+
|
|
91
|
+
return _find_constraint._cache.get(name, None)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _get_model_changes(model):
|
|
95
|
+
"""
|
|
96
|
+
Return a dictionary containing changes made to the model since it was
|
|
97
|
+
fetched from the database.
|
|
98
|
+
|
|
99
|
+
The dictionary is of the form {'property_name': [old_value, new_value]}
|
|
100
|
+
"""
|
|
101
|
+
state = inspect(model)
|
|
102
|
+
changes = {}
|
|
103
|
+
for attr in state.attrs:
|
|
104
|
+
hist = attr.history
|
|
105
|
+
if not hist.has_changes():
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(attr.value, (list, tuple)) or len(hist.deleted) > 1 or len(hist.added) > 1:
|
|
108
|
+
# If array, store array
|
|
109
|
+
changes[attr.key] = [hist.deleted, hist.added]
|
|
110
|
+
else:
|
|
111
|
+
# If primitive, store primitive
|
|
112
|
+
changes[attr.key] = [
|
|
113
|
+
hist.deleted[0] if len(hist.deleted) >= 1 else None,
|
|
114
|
+
hist.added[0] if len(hist.added) >= 1 else None,
|
|
115
|
+
]
|
|
116
|
+
return changes
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Base:
|
|
120
|
+
'''
|
|
121
|
+
Extends declarative base to provide convenience methods to models similar to
|
|
122
|
+
functionality found in Elixir. Works in python3.
|
|
123
|
+
|
|
124
|
+
For example, given the model User:
|
|
125
|
+
# no need to write init methods for models, simply pass keyword arguments or
|
|
126
|
+
# override if needed.
|
|
127
|
+
User(name="daniel", email="daniel@dasa.cc").add()
|
|
128
|
+
User.query # returns session.query(User)
|
|
129
|
+
User.query.all() # instead of session.query(User).all()
|
|
130
|
+
changed = User.from_dict({}) # update record based on dict argument passed in and returns any keys changed
|
|
131
|
+
'''
|
|
132
|
+
|
|
133
|
+
def add(self):
|
|
134
|
+
"""
|
|
135
|
+
Add current object to session.
|
|
136
|
+
"""
|
|
137
|
+
self.__class__.session.add(self)
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def delete(self):
|
|
141
|
+
self.__class__.session.delete(self)
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def commit(self):
|
|
145
|
+
self.__class__.session.commit()
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
def flush(self):
|
|
149
|
+
self.__class__.session.flush()
|
|
150
|
+
return self
|
|
151
|
+
|
|
152
|
+
def expire(self):
|
|
153
|
+
self.__class__.session.expire(self)
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
def rollback(self):
|
|
157
|
+
self.__class__.session.rollback()
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class SQLA(SimplePlugin):
|
|
162
|
+
uri = None
|
|
163
|
+
debug = False
|
|
164
|
+
|
|
165
|
+
_base = None
|
|
166
|
+
_session = None
|
|
167
|
+
_engine = None
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def engine(self):
|
|
171
|
+
return self._engine
|
|
172
|
+
|
|
173
|
+
def start(self):
|
|
174
|
+
if self.uri is None:
|
|
175
|
+
return
|
|
176
|
+
# Adjust debug level.
|
|
177
|
+
if self.debug:
|
|
178
|
+
logging.getLogger('sqlalchemy.engine').setLevel(logging.DEBUG)
|
|
179
|
+
# Create connection to database
|
|
180
|
+
self._engine = create_engine(self.uri)
|
|
181
|
+
# Clean-up previous session.
|
|
182
|
+
self.clear_sessions()
|
|
183
|
+
# Associate our session to our engine
|
|
184
|
+
self.get_session().configure(bind=self._engine)
|
|
185
|
+
self.bus.log("database session plugin started")
|
|
186
|
+
|
|
187
|
+
# This is slightly lower priority to get database started first.
|
|
188
|
+
start.priority = 45
|
|
189
|
+
|
|
190
|
+
def stop(self):
|
|
191
|
+
if self._session:
|
|
192
|
+
self.clear_sessions()
|
|
193
|
+
if self._engine:
|
|
194
|
+
self._engine.dispose()
|
|
195
|
+
self.bus.log("database session plugin stopped")
|
|
196
|
+
|
|
197
|
+
def graceful(self):
|
|
198
|
+
"""Reload of subscribers."""
|
|
199
|
+
self.stop()
|
|
200
|
+
self.start()
|
|
201
|
+
|
|
202
|
+
def create_all(self):
|
|
203
|
+
try:
|
|
204
|
+
# Create tables
|
|
205
|
+
base = self.get_base()
|
|
206
|
+
conn = self.get_session().connection()
|
|
207
|
+
base.metadata.create_all(bind=conn)
|
|
208
|
+
self.get_session().commit()
|
|
209
|
+
finally:
|
|
210
|
+
# Release opened sessions.
|
|
211
|
+
self.clear_sessions()
|
|
212
|
+
|
|
213
|
+
def drop_all(self):
|
|
214
|
+
try:
|
|
215
|
+
# Drop all
|
|
216
|
+
base = self.get_base()
|
|
217
|
+
base.metadata.drop_all(bind=self._engine)
|
|
218
|
+
self.get_session().commit()
|
|
219
|
+
finally:
|
|
220
|
+
# Release opened sessions.
|
|
221
|
+
self.clear_sessions()
|
|
222
|
+
|
|
223
|
+
def get_base(self):
|
|
224
|
+
"""
|
|
225
|
+
Return a singleton instance of the Base classe for ORM.
|
|
226
|
+
"""
|
|
227
|
+
if self._base is None:
|
|
228
|
+
self._base = declarative_base(cls=Base)
|
|
229
|
+
# Provide a friendly ObjectName.query.
|
|
230
|
+
self._base.session = self.get_session()
|
|
231
|
+
self._base.query = self.get_session().query_property()
|
|
232
|
+
return self._base
|
|
233
|
+
|
|
234
|
+
def get_session(self):
|
|
235
|
+
"""
|
|
236
|
+
Return a singleton database session.
|
|
237
|
+
"""
|
|
238
|
+
if self._session is None:
|
|
239
|
+
self._session_factory = sessionmaker(autoflush=False, autocommit=False)
|
|
240
|
+
self._session = scoped_session(self._session_factory)
|
|
241
|
+
return self._session
|
|
242
|
+
|
|
243
|
+
def after_request(self):
|
|
244
|
+
self.clear_sessions()
|
|
245
|
+
|
|
246
|
+
def clear_sessions(self):
|
|
247
|
+
"""
|
|
248
|
+
Used to clean-up session and raise error if session are not clean.
|
|
249
|
+
"""
|
|
250
|
+
if self._session is None:
|
|
251
|
+
return
|
|
252
|
+
try:
|
|
253
|
+
# When terminating, raise an error if objects are not commit.
|
|
254
|
+
if self._session.dirty or self._session.new or self._session.deleted:
|
|
255
|
+
cherrypy.log(
|
|
256
|
+
'database session dirty; uncommitted objects detected — potential application bug',
|
|
257
|
+
context='DB',
|
|
258
|
+
severity=logging.ERROR,
|
|
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
|
+
)
|
|
276
|
+
raise SQLAlchemyError('session is dirty')
|
|
277
|
+
finally:
|
|
278
|
+
self._session.rollback()
|
|
279
|
+
self._session.expunge_all()
|
|
280
|
+
self._session.remove()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
cherrypy.db = SQLA(cherrypy.engine)
|
|
284
|
+
cherrypy.db.subscribe()
|
|
285
|
+
|
|
286
|
+
cherrypy.config.namespaces['db'] = lambda key, value: setattr(cherrypy.db, key, value)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# LDAP Plugins for cherrypy
|
|
2
|
+
# Copyright (C) 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 logging
|
|
18
|
+
|
|
19
|
+
import cherrypy
|
|
20
|
+
import ldap3
|
|
21
|
+
from cherrypy.process.plugins import SimplePlugin
|
|
22
|
+
from ldap3.core.exceptions import LDAPException, LDAPInvalidCredentialsResult
|
|
23
|
+
|
|
24
|
+
_safe = ldap3.utils.conv.escape_filter_chars
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def all_attribute(attributes, keys, default=None):
|
|
28
|
+
"""
|
|
29
|
+
Extract all values from LDAP attributes.
|
|
30
|
+
"""
|
|
31
|
+
# Skip loopkup if key is not defined.
|
|
32
|
+
if not keys:
|
|
33
|
+
return default
|
|
34
|
+
# Convert key to a list.
|
|
35
|
+
keys = keys if isinstance(keys, list) else [keys]
|
|
36
|
+
# Loop on each attribute name.
|
|
37
|
+
values = []
|
|
38
|
+
for attr in keys:
|
|
39
|
+
try:
|
|
40
|
+
value = attributes[attr]
|
|
41
|
+
if isinstance(value, list) and len(value) > 0:
|
|
42
|
+
values.append(value[0])
|
|
43
|
+
else:
|
|
44
|
+
values.append(value)
|
|
45
|
+
except KeyError:
|
|
46
|
+
pass
|
|
47
|
+
# Default to None.
|
|
48
|
+
return values if values else default
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def first_attribute(attributes, keys, default=None):
|
|
52
|
+
"""
|
|
53
|
+
Extract the first value from LDAP attributes.
|
|
54
|
+
"""
|
|
55
|
+
# Skip loopkup if key is not defined.
|
|
56
|
+
if not keys:
|
|
57
|
+
return default
|
|
58
|
+
# Convert key to a list.
|
|
59
|
+
keys = keys if isinstance(keys, list) else [keys]
|
|
60
|
+
# Loop on each attribute name to find a value.
|
|
61
|
+
for attr in keys:
|
|
62
|
+
try:
|
|
63
|
+
value = attributes[attr]
|
|
64
|
+
if isinstance(value, list):
|
|
65
|
+
if len(value) == 0:
|
|
66
|
+
continue
|
|
67
|
+
return value[0]
|
|
68
|
+
else:
|
|
69
|
+
return value
|
|
70
|
+
except KeyError:
|
|
71
|
+
pass
|
|
72
|
+
# Default to None.
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class LdapPlugin(SimplePlugin):
|
|
77
|
+
"""
|
|
78
|
+
Used this plugin to authenticate user against an LDAP server.
|
|
79
|
+
|
|
80
|
+
`authenticate(login, password)` return None if the credentials are not
|
|
81
|
+
valid. Otherwise it return a tuple or login and extra attributes.
|
|
82
|
+
The extra attribute may contains `_fullname` and `_email`.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
uri = None
|
|
86
|
+
base_dn = None
|
|
87
|
+
bind_dn = ''
|
|
88
|
+
bind_password = ''
|
|
89
|
+
scope = 'subtree'
|
|
90
|
+
tls = False
|
|
91
|
+
user_filter = '(objectClass=*)'
|
|
92
|
+
login_attribute = ['uid']
|
|
93
|
+
required_group = None
|
|
94
|
+
group_attribute = 'member'
|
|
95
|
+
group_attribute_is_dn = False
|
|
96
|
+
group_filter = '(objectClass=*)'
|
|
97
|
+
version = 3
|
|
98
|
+
network_timeout = 10
|
|
99
|
+
timeout = 10
|
|
100
|
+
fullname_attribute = None
|
|
101
|
+
firstname_attribute = None
|
|
102
|
+
lastname_attribute = None
|
|
103
|
+
email_attribute = None
|
|
104
|
+
pool_size = 10
|
|
105
|
+
|
|
106
|
+
def start(self):
|
|
107
|
+
# Don't configure this plugin if the ldap URI is not provided.
|
|
108
|
+
if not self.uri:
|
|
109
|
+
return
|
|
110
|
+
self.bus.log('Start LDAP connection')
|
|
111
|
+
# Set up the LDAP server object
|
|
112
|
+
server = ldap3.Server(self.uri, connect_timeout=self.network_timeout, mode=ldap3.IP_V4_PREFERRED)
|
|
113
|
+
# Create a pool of reusable connections
|
|
114
|
+
self._pool = ldap3.Connection(
|
|
115
|
+
server,
|
|
116
|
+
user=self.bind_dn,
|
|
117
|
+
password=self.bind_password,
|
|
118
|
+
auto_bind=ldap3.AUTO_BIND_TLS_BEFORE_BIND if self.tls else ldap3.AUTO_BIND_NO_TLS,
|
|
119
|
+
version=self.version,
|
|
120
|
+
raise_exceptions=True,
|
|
121
|
+
client_strategy=ldap3.REUSABLE,
|
|
122
|
+
pool_size=self.pool_size,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def stop(self):
|
|
126
|
+
self.bus.log('Stop LDAP connection')
|
|
127
|
+
# Release the connection pool.
|
|
128
|
+
if hasattr(self, '_pool'):
|
|
129
|
+
self._pool.unbind()
|
|
130
|
+
|
|
131
|
+
def graceful(self):
|
|
132
|
+
"""Reload of subscribers."""
|
|
133
|
+
self.stop()
|
|
134
|
+
self.start()
|
|
135
|
+
|
|
136
|
+
def authenticate(self, login, password):
|
|
137
|
+
"""
|
|
138
|
+
Check if the given credential as valid according to LDAP.
|
|
139
|
+
Return False if invalid.
|
|
140
|
+
Return None if the plugin is unavailable to validate credentials or if the plugin is disabled.
|
|
141
|
+
Return tuple (<login>, <attributes>) if the credentials are valid.
|
|
142
|
+
"""
|
|
143
|
+
assert isinstance(login, str)
|
|
144
|
+
assert isinstance(password, str)
|
|
145
|
+
|
|
146
|
+
if not hasattr(self, '_pool'):
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
# Connect to LDAP Server.
|
|
150
|
+
with self._pool as conn:
|
|
151
|
+
try:
|
|
152
|
+
# Search the LDAP server for user's DN.
|
|
153
|
+
safe_login = _safe(login)
|
|
154
|
+
attr_filter = ''.join([f'({_safe(attr)}={safe_login})' for attr in self.login_attribute])
|
|
155
|
+
search_filter = f"(&{self.user_filter}(|{attr_filter}))"
|
|
156
|
+
response = self._search(conn, search_filter)
|
|
157
|
+
if not response:
|
|
158
|
+
cherrypy.log(f"lookup failed login={login} reason=not_found", context='LDAP')
|
|
159
|
+
return False
|
|
160
|
+
cherrypy.log(f"lookup successful login={login}", context='LDAP')
|
|
161
|
+
user_dn = response[0]['dn']
|
|
162
|
+
|
|
163
|
+
# Use a separate connection to validate credentials
|
|
164
|
+
login_conn = ldap3.Connection(
|
|
165
|
+
self._pool.server,
|
|
166
|
+
user=user_dn,
|
|
167
|
+
password=password,
|
|
168
|
+
version=self.version,
|
|
169
|
+
raise_exceptions=True,
|
|
170
|
+
client_strategy=ldap3.ASYNC,
|
|
171
|
+
)
|
|
172
|
+
if not login_conn.bind():
|
|
173
|
+
cherrypy.log(
|
|
174
|
+
f'ldap authentication failed login={login} reason=wrong_password',
|
|
175
|
+
context='LDAP',
|
|
176
|
+
severity=logging.WARNING,
|
|
177
|
+
)
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
# Get user's login
|
|
181
|
+
attrs = response[0]['attributes']
|
|
182
|
+
new_login = first_attribute(attrs, self.login_attribute[0])
|
|
183
|
+
if not new_login:
|
|
184
|
+
cherrypy.log(
|
|
185
|
+
f"object missing login attribute user_dn={user_dn} attribute={self.login_attribute[0]}",
|
|
186
|
+
context='LDAP',
|
|
187
|
+
severity=logging.WARNING,
|
|
188
|
+
)
|
|
189
|
+
return False
|
|
190
|
+
|
|
191
|
+
# Verify if the user is member of the required group
|
|
192
|
+
if self.required_group:
|
|
193
|
+
if not isinstance(self.required_group, list):
|
|
194
|
+
self.required_group = [self.required_group]
|
|
195
|
+
user_value = user_dn if self.group_attribute_is_dn else new_login
|
|
196
|
+
group_filter = '(&(%s=%s)(|%s)%s)' % (
|
|
197
|
+
_safe(self.group_attribute),
|
|
198
|
+
_safe(user_value),
|
|
199
|
+
''.join(['(cn=%s)' % _safe(group) for group in self.required_group]),
|
|
200
|
+
self.group_filter,
|
|
201
|
+
)
|
|
202
|
+
# Search LDAP Server for matching groups.
|
|
203
|
+
cherrypy.log(
|
|
204
|
+
f"group check start login={user_value} required_groups={' '.join(self.required_group)}",
|
|
205
|
+
context='LDAP',
|
|
206
|
+
)
|
|
207
|
+
response = self._search(conn, group_filter, attributes=['cn'])
|
|
208
|
+
if not response:
|
|
209
|
+
cherrypy.log(
|
|
210
|
+
f"group check failed login={user_value} required_groups={' '.join(self.required_group)}",
|
|
211
|
+
context='LDAP',
|
|
212
|
+
)
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
# Extract common attribute from LDAP
|
|
216
|
+
attrs['dn'] = user_dn
|
|
217
|
+
attrs['email'] = first_attribute(attrs, self.email_attribute)
|
|
218
|
+
attrs['fullname'] = fullname = first_attribute(attrs, self.fullname_attribute)
|
|
219
|
+
if not fullname:
|
|
220
|
+
firstname = first_attribute(attrs, self.firstname_attribute, '')
|
|
221
|
+
lastname = first_attribute(attrs, self.lastname_attribute, '')
|
|
222
|
+
attrs['fullname'] = ' '.join([name for name in [firstname, lastname] if name])
|
|
223
|
+
return (new_login, attrs)
|
|
224
|
+
except LDAPInvalidCredentialsResult:
|
|
225
|
+
return False
|
|
226
|
+
except LDAPException:
|
|
227
|
+
cherrypy.log(f"unexpected error login={login}", context='LDAP', severity=logging.ERROR, traceback=True)
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def search(self, filter, attributes=ldap3.ALL_ATTRIBUTES, search_base=None, paged_size=None):
|
|
231
|
+
with self._pool as conn:
|
|
232
|
+
return self._search(
|
|
233
|
+
conn, filter=filter, attributes=attributes, search_base=search_base, paged_size=paged_size
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def _search(self, conn, filter, attributes=ldap3.ALL_ATTRIBUTES, search_base=None, paged_size=None):
|
|
237
|
+
search_scope = {'base': ldap3.BASE, 'onelevel': ldap3.LEVEL, 'subtree': ldap3.SUBTREE}.get(
|
|
238
|
+
self.scope, ldap3.SUBTREE
|
|
239
|
+
)
|
|
240
|
+
search_base = search_base or self.base_dn
|
|
241
|
+
cherrypy.log(f"search {self.uri}/{search_base}?{search_scope}?{filter}", context='LDAP')
|
|
242
|
+
msg_id = conn.search(
|
|
243
|
+
search_base=search_base,
|
|
244
|
+
search_filter=filter,
|
|
245
|
+
search_scope=search_scope,
|
|
246
|
+
time_limit=self.timeout,
|
|
247
|
+
attributes=attributes,
|
|
248
|
+
paged_size=paged_size,
|
|
249
|
+
)
|
|
250
|
+
response, _result_unused = conn.get_response(msg_id)
|
|
251
|
+
return response
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
cherrypy.ldap = LdapPlugin(cherrypy.engine)
|
|
255
|
+
cherrypy.ldap.subscribe()
|
|
256
|
+
|
|
257
|
+
cherrypy.config.namespaces['ldap'] = lambda key, value: setattr(cherrypy.ldap, key, value)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# RestAPI plugin for cherrypy
|
|
2
|
+
# Copyright (C) 2024-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 cherrypy
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Dispatcher(cherrypy.dispatch.Dispatcher):
|
|
21
|
+
"""
|
|
22
|
+
Dispatcher using HTTP method to find the proper function to be called.
|
|
23
|
+
|
|
24
|
+
e.g.:
|
|
25
|
+
GET /api/users -> list()
|
|
26
|
+
GET /api/users/3 -> get(3)
|
|
27
|
+
GET /api/users/my/path -> get('my/path')
|
|
28
|
+
DELETE /api/users/3 -> delete(3)
|
|
29
|
+
DELETE /api/users/my/path -> delete('my/path')
|
|
30
|
+
|
|
31
|
+
POST /api/users -> post(data)
|
|
32
|
+
POST /api/users/3 -> post(3, data)
|
|
33
|
+
PUT /api/users -> post(data)
|
|
34
|
+
PUT /api/users/3 -> post(3, data)
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
supported_method = ['get', 'delete', 'post', 'put']
|
|
39
|
+
|
|
40
|
+
def __call__(self, path):
|
|
41
|
+
request = cherrypy.serving.request
|
|
42
|
+
# To simplify implementation reuse default dispatcher function.
|
|
43
|
+
# But strip the last segment
|
|
44
|
+
resource, vpath = self.find_handler(path)
|
|
45
|
+
|
|
46
|
+
if not resource:
|
|
47
|
+
request.handler = cherrypy.NotFound()
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# Two scenario possible. Either we have found our GET handler.
|
|
51
|
+
# Or we need to look into the resource for it.
|
|
52
|
+
meth = request.method.lower()
|
|
53
|
+
if meth not in self.supported_method:
|
|
54
|
+
request.handler = cherrypy.HTTPError(405)
|
|
55
|
+
if meth == 'get' and not hasattr(resource, 'list') and not hasattr(resource, 'get'):
|
|
56
|
+
request.handler = cherrypy.dispatch.LateParamPageHandler(resource, *vpath)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
# Call "list()" instead of "get()" when path doesn't have an id or name.
|
|
60
|
+
if meth == 'get' and not vpath and hasattr(resource, 'list'):
|
|
61
|
+
meth = 'list'
|
|
62
|
+
# Find the subhandler
|
|
63
|
+
func = getattr(resource, meth, None)
|
|
64
|
+
if func:
|
|
65
|
+
# Grab any _cp_config on the subhandler.
|
|
66
|
+
if hasattr(func, '_cp_config'):
|
|
67
|
+
request.config.update(func._cp_config)
|
|
68
|
+
|
|
69
|
+
# Decode any leftover %2F in the virtual_path atoms.
|
|
70
|
+
if vpath:
|
|
71
|
+
vpath = ['/'.join([x.replace('%2F', '/') for x in vpath])]
|
|
72
|
+
request.handler = cherrypy.dispatch.LateParamPageHandler(func, *vpath)
|
|
73
|
+
else:
|
|
74
|
+
request.handler = cherrypy.HTTPError(405)
|