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,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)