krest-utils 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.
krest/__init__.py ADDED
File without changes
krest/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ from . import krest_qt
2
+
3
+
4
+ def main():
5
+ krest_qt.main()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
krest/krest_backend.py ADDED
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import os
4
+ import json
5
+ import shutil
6
+ # import time
7
+ import base64
8
+ from typing import Callable, List, Dict, Optional, Any, Self
9
+ from enum import Enum
10
+ from datetime import datetime
11
+ from importlib.metadata import version
12
+ import secrets
13
+ import httpx
14
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
15
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
16
+ from cryptography.hazmat.primitives import hashes
17
+ from krest.translations import t
18
+
19
+ print(t(t.REPLY_NAME, name=t(t.K_USER))) # testing i18n solution
20
+
21
+ def developer_log(message):
22
+ with open("/home/antototh/Downloads/krest_debug_log_developer.log", "a") as f:
23
+ f.write(message + "\n")
24
+
25
+ class CredentialType(Enum):
26
+ BASIC = ("Basic", "Basic Auth (" + t(t.BASICAUTH_DESC) + ")")
27
+ DIGEST = ("DigestAuth", "Digest Auth (" + t(t.DIGESTAUTH_DESC) + ")")
28
+ OAUTH2 = ("OAuth2", "OAuth 2.0 (" + t(t.OAUTH_DESC) + ")")
29
+ # HEADER_KEY = ("HeaderKey", "Header Key")
30
+
31
+ def __new__(cls: type["CredentialType"], value: str, display: str) -> "CredentialType":
32
+ obj = object.__new__(cls)
33
+ obj._value_ = value
34
+ obj.display = display
35
+ return obj
36
+
37
+
38
+ class Method(Enum):
39
+ GET = "GET"
40
+ POST = "POST"
41
+ PUT = "PUT"
42
+ DELETE = "DELETE"
43
+ HEAD = "HEAD"
44
+ OPTIONS = "OPTIONS"
45
+ PATCH = "PATCH"
46
+
47
+
48
+ def empty_endpoint() -> Dict[str, Any]:
49
+ return {
50
+ "id": None,
51
+ "name": "",
52
+ "desc": "",
53
+ "labels": [],
54
+ "credential_id": None,
55
+ "method": Method.GET.value,
56
+ "timeout": 150,
57
+ "url": "",
58
+ "headers": {"Accept": "text/html", "Accept-Charset": "utf-8", "Connection": "close", "Content-Type": "application/json"},
59
+ "body": None,
60
+ "params": {},
61
+ "follow_redirects": True,
62
+ "created_at": None,
63
+ "updated_at": None,
64
+ }
65
+
66
+ def empty_credential(credential_type: str) -> Dict[str, Any]:
67
+ empty_credential = {}
68
+
69
+ if credential_type == CredentialType.BASIC.value:
70
+ empty_credential = {
71
+ "id": None,
72
+ "name": "",
73
+ "desc": "",
74
+ "labels": [],
75
+ "credential_type": credential_type,
76
+ "username": "",
77
+ "password": "",
78
+ "created_at": None,
79
+ "updated_at": None,
80
+ }
81
+ elif credential_type == CredentialType.DIGEST.value:
82
+ empty_credential = {
83
+ "id": None,
84
+ "name": "",
85
+ "desc": "",
86
+ "labels": [],
87
+ "credential_type": credential_type,
88
+ "username": "",
89
+ "password": "",
90
+ "created_at": None,
91
+ "updated_at": None,
92
+ }
93
+ elif credential_type == CredentialType.OAUTH2.value:
94
+ empty_credential = {
95
+ "id": None,
96
+ "name": "",
97
+ "desc": "",
98
+ "labels": [],
99
+ "credential_type": credential_type,
100
+ "token_url": "",
101
+ "scope": None, # e.g. "urn:opc:idm:__myscopes__"
102
+ "client_id": "",
103
+ "client_secret": "",
104
+ "created_at": None,
105
+ "updated_at": None,
106
+ }
107
+
108
+ return empty_credential
109
+
110
+
111
+ MAGIC_BYTES = b"KREST01"
112
+ FILE_EXTENSION = "krest"
113
+ CREDENTIAL_TYPES = [credential.value for credential in CredentialType]
114
+ METHODS = [method.value for method in Method]
115
+ EMPTY_FILE_DATA = {
116
+ "application": "krest",
117
+ "version": version("krest"),
118
+ "settings": {"is_backup_enabled": True},
119
+ "max_endpoint_id": -1,
120
+ "max_credential_id": -1,
121
+ "max_call_history_id": -1,
122
+ "credentials": [],
123
+ "endpoints": [],
124
+ "call_history": [],
125
+ }
126
+
127
+
128
+ class KrestBackend:
129
+ """Backend for the Krest application, handling file operations, business functions."""
130
+
131
+ __slots__ = ("file_path", "file_password", "file_data", "dirty_bit")
132
+
133
+ @staticmethod
134
+ def derive_key(password: str, salt: bytes, length=32, iterations=600_000) -> bytes:
135
+ """Derive a key from a password and salt."""
136
+
137
+ kdf = PBKDF2HMAC(
138
+ algorithm=hashes.SHA256(),
139
+ length=length,
140
+ salt=salt,
141
+ iterations=iterations,
142
+ )
143
+ return kdf.derive(password.encode())
144
+
145
+ def __init__(self):
146
+ """Constructor to initialize the backend."""
147
+ self.file_path: str|None = None
148
+ self.file_password: str|None = None
149
+ self.dirty_bit: int|None = None
150
+ self.file_data: Dict[str, Any]|None = EMPTY_FILE_DATA.copy()
151
+
152
+ def delete_endpoint(self, id: int) -> None:
153
+ """Delete an endpoint from the file data."""
154
+
155
+ to_remove = next((d for d in self.file_data["endpoints"] if d["id"] == id), None)
156
+
157
+ if to_remove:
158
+ self.file_data["endpoints"].remove(to_remove)
159
+ self.dirty_bit = True
160
+
161
+ def save_endpoint(self, endpoint: Dict[str, Any]) -> int|None:
162
+ """Add an endpoint to the file data."""
163
+
164
+ self.dirty_bit = True
165
+
166
+ if endpoint.get("id") is None:
167
+ next_id = self.file_data["max_endpoint_id"] + 1
168
+ new_endpoint = endpoint.copy()
169
+ new_endpoint["created_at"] = datetime.now().isoformat()
170
+ new_endpoint["id"] = next_id
171
+ self.file_data["endpoints"].append(new_endpoint)
172
+ self.file_data["max_endpoint_id"] = next_id
173
+ return next_id
174
+ else:
175
+ for i, act_endpoint in enumerate(self.file_data["endpoints"]):
176
+ if act_endpoint.get("id") == endpoint.get("id"):
177
+ endpoint["updated_at"] = datetime.now().isoformat()
178
+ self.file_data["endpoints"][i].update(endpoint)
179
+ break
180
+ return endpoint.get("id")
181
+
182
+ def save_credential(self, credential: Dict[str, Any]) -> int|None:
183
+ """Add a credential to the file data."""
184
+
185
+ self.dirty_bit = True
186
+
187
+ if credential.get("id") is None:
188
+ next_id = self.file_data["max_credential_id"] + 1
189
+ new_credential = credential.copy()
190
+ new_credential["created_at"] = datetime.now().isoformat()
191
+ new_credential["id"] = next_id
192
+ self.file_data["credentials"].append(new_credential)
193
+ self.file_data["max_credential_id"] = next_id
194
+ return next_id
195
+ else:
196
+ for i, act_credential in enumerate(self.file_data["credentials"]):
197
+ if act_credential.get("id") == credential.get("id"):
198
+ credential["updated_at"] = datetime.now().isoformat()
199
+ self.file_data["credentials"][i].update(credential)
200
+ break
201
+ return credential.get("id")
202
+
203
+ def get_endpoint(self, id: int) -> Dict[str, Any]|None:
204
+ """Get an endpoint by its ID."""
205
+ result = [endpoint for endpoint in self.file_data["endpoints"] if endpoint.get("id") == id]
206
+ return result[0] if result else None
207
+
208
+ def get_credential(self, id: int) -> Dict[str, Any]|None:
209
+ """Get a credential by its ID."""
210
+ result = [cred for cred in self.file_data["credentials"] if cred["id"] == id]
211
+ return result[0] if result else None
212
+
213
+ def select_file(self, file_path: str|None = None, file_password: str|None = None):
214
+ """Set the file path and password for the backend."""
215
+ if file_path is None:
216
+ raise ValueError(t(t.ERR_MISSING_FILEPATH))
217
+ self.file_path = file_path
218
+ self.file_password = file_password
219
+
220
+ def create_file(self, file_path: str|None = None, file_password: str|None = None):
221
+ """Create an Empty File; """
222
+ self.file_data = EMPTY_FILE_DATA.copy()
223
+ self.select_file(file_path, file_password)
224
+ self.save_file()
225
+
226
+ def save_file(self):
227
+ """Save the file data to the specified file path with encryption."""
228
+ # Check if file_path and file_password are set
229
+ # Check if file_data is not empty
230
+ # Create directory if it does not exist
231
+ # Check if backup file sign is enabled and create a backup if the file exists
232
+
233
+ if not self.file_path:
234
+ raise ValueError(t(t.ERR_MISSING_FILEPATH))
235
+ if not self.file_data:
236
+ raise ValueError(t(t.ERR_NODATA))
237
+
238
+ os.makedirs(os.path.dirname(self.file_path), exist_ok=True)
239
+
240
+ salt = secrets.token_bytes(16)
241
+ key = self.derive_key(self.file_password, salt)
242
+ aesgcm = AESGCM(key)
243
+ iv = secrets.token_bytes(12)
244
+ json_bytes = json.dumps(self.file_data).encode()
245
+ ciphertext = aesgcm.encrypt(iv, json_bytes, None)
246
+ encrypted_data = MAGIC_BYTES + salt + iv + ciphertext
247
+
248
+ with open(self.file_path + '.tmp', "wb") as f:
249
+ f.write(encrypted_data)
250
+
251
+ if self.file_data["settings"]["is_backup_enabled"] and os.path.exists(self.file_path):
252
+ backup_file_path = self.file_path + "." + str(datetime.now().strftime('%Y%m%dT%H%M%S')) + ".bak"
253
+ shutil.copy2(self.file_path, backup_file_path)
254
+
255
+ shutil.move(self.file_path + '.tmp', self.file_path)
256
+
257
+ self.dirty_bit = None
258
+
259
+ def load_file(self):
260
+ """Load the file data from the specified file path with decryption."""
261
+
262
+ with open(self.file_path, "rb") as f:
263
+ encrypted_data = f.read()
264
+ magic_bytes, salt, iv, ciphertext = encrypted_data[:7], encrypted_data[7:23], encrypted_data[23:35], encrypted_data[35:]
265
+ if magic_bytes != MAGIC_BYTES:
266
+ # developer_log(f"Expected magic bytes: {MAGIC_BYTES}, found: {magic_bytes}")
267
+ raise ValueError(t(t.ERR_MAGICBYTES))
268
+ key = self.derive_key(self.file_password, salt)
269
+ aesgcm = AESGCM(key)
270
+ try:
271
+ json_bytes = aesgcm.decrypt(iv, ciphertext, None)
272
+ except Exception:
273
+ raise ValueError(t(t.ERR_INVALID_PASSWD))
274
+ self.file_data = json.loads(json_bytes)
275
+
276
+ self.dirty_bit = None
277
+
278
+ def log_response(self, call_type: str, endpoint_id: int, response: httpx.Response, endpoint: dict|None = None, credential: dict|None = None):
279
+ next_id = self.file_data["max_call_history_id"] + 1
280
+
281
+ # try:
282
+ # content = response.json()
283
+ # except ValueError:
284
+ # content = response.text
285
+
286
+ cookie_list = [vars(c) for c in response.cookies.jar]
287
+
288
+ self.file_data["call_history"].append({
289
+ "id": next_id,
290
+ "call_type": call_type,
291
+ "endpoint_id": endpoint_id,
292
+ "run_date": datetime.now().isoformat(),
293
+ "endpoint": (endpoint or {}).copy(),
294
+ "credential": (credential or {}).copy(),
295
+ "elapsed_time": response.elapsed.total_seconds() if response.elapsed else 0,
296
+
297
+ # "request_url": str(response.request.url),
298
+ # "request_headers": dict(response.request.headers), - endpoint should include
299
+ # "request_method": response.request.method, - endpoint should include
300
+ # "request_body": response.request.body if response.request.body else "", - endpoint should include
301
+
302
+ #"response_url": str(response.url),
303
+ "response_status_code": response.status_code,
304
+ "response_reason_phrase": response.reason_phrase,
305
+
306
+ "response_content_length": len(response.content) if response.content else 0,
307
+ "response_encoding": response.encoding,
308
+ "response_charset_encoding": response.charset_encoding,
309
+ # "response_ssl_version": response.raw.version if hasattr(response.raw, "version") else "",
310
+ "response_headers": dict(response.headers),
311
+ "cookies": cookie_list,
312
+ "response_body": response.text,
313
+ "response_content_base64": base64.b64encode(response.content).decode("ascii"), # Original byte Data: base64.b64decode(response_content_base64)
314
+ # "response_json": response.json(),
315
+ "response_http_version": response.http_version,
316
+ "response_links": response.links.copy(),
317
+ "response_has_redirect_location": response.has_redirect_location,
318
+ })
319
+ self.file_data["max_call_history_id"] = next_id
320
+ return next_id
321
+
322
+
323
+ def run_endpoint(self, endpoint_id):
324
+
325
+ endpoint = self.get_endpoint(endpoint_id)
326
+ if endpoint == None:
327
+ raise ValueError(t(t.ERR_INVALID_ENDPOINT_ID, endpoint_id=endpoint_id))
328
+
329
+ def log_event(name):
330
+ return lambda *args, **kwargs: None #developer_log(f"{time.time():.2f} - {name}")
331
+ hooks = {
332
+ 'request': [log_event('Request Started')],
333
+ 'response': [log_event('Response Received')]
334
+ }
335
+ transport = httpx.HTTPTransport(local_address="0.0.0.0") # Force IPv4 transport
336
+
337
+ auth = None
338
+ request_headers = endpoint.get("headers", {})
339
+ request_data = endpoint.get("body")
340
+ credential_id = endpoint.get("credential_id", None)
341
+ credential = self.get_credential(credential_id)
342
+ credential_type = None
343
+ if credential:
344
+ credential_type = credential.get("credential_type", None)
345
+
346
+ if credential_type == CredentialType.BASIC.value:
347
+ auth = httpx.BasicAuth(credential.get("username", ""), credential.get("password", ""))
348
+
349
+ if credential_type == CredentialType.DIGEST.value:
350
+ auth = httpx.DigestAuth(username=credential.get("username", ""), password=credential.get("password", ""))
351
+
352
+ # if credential_type == CredentialType.HEADER_KEY.value:
353
+ # auth = None
354
+ # headers[credential.get("username")] = credential.get("password")
355
+
356
+ if credential_type == CredentialType.OAUTH2.value:
357
+
358
+ token_body = "grant_type=client_credentials"
359
+ if credential.get("scope", None):
360
+ token_body += "&scope=" + credential["scope"]
361
+
362
+ token_response = None
363
+ with httpx.Client(event_hooks=hooks, transport=transport) as client:
364
+ token_response = client.request(
365
+ method="POST",
366
+ url=credential.get("token_url"),
367
+ auth=httpx.BasicAuth(credential.get("client_id"), credential.get("client_secret")),
368
+ timeout=endpoint.get("timeout"),
369
+ headers={"Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded"},
370
+ data=token_body,
371
+ follow_redirects=endpoint.get("follow_redirects")
372
+ )
373
+
374
+ self.log_response(t(t.TOKEN_REQUEST), endpoint_id, token_response, endpoint, credential)
375
+ token_response.raise_for_status()
376
+
377
+ token_json = token_response.json()
378
+ access_token = token_json["access_token"]
379
+
380
+ if access_token is None or access_token=="":
381
+ # developer_log(token_response.text)
382
+ raise ValueError(t(t.ERR_ACCESS_TOKEN))
383
+
384
+ auth = None
385
+ request_headers["Authorization"] = f"Bearer {access_token}"
386
+
387
+ response = None
388
+ with httpx.Client(event_hooks=hooks, transport=transport) as client:
389
+ response = client.request(
390
+ method=endpoint.get("method"),
391
+ url=endpoint.get("url"),
392
+ auth=auth,
393
+ timeout=endpoint.get("timeout"),
394
+ headers=request_headers,
395
+ data=request_data,
396
+ params=endpoint.get("params"),
397
+ follow_redirects=endpoint.get("follow_redirects")
398
+ )
399
+
400
+ self.log_response(t(t.SERVICE_REQUEST), endpoint_id, response, endpoint, credential)
401
+ self.dirty_bit = True
402
+ response.raise_for_status()
403
+
404
+ return response
krest/krest_logging.py ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+
3
+ pass
4
+
5
+ # import os
6
+ # import sys
7
+ # import logging
8
+ #
9
+ # def user_log_dir(appname: str | None = None) -> str:
10
+ # # better solution: https://pypi.org/project/platformdirs/
11
+ # # from platformdirs import user_log_dir
12
+ #
13
+ # if sys.platform == "win32":
14
+ # # Windows
15
+ # path = os.environ.get("LOCALAPPDATA")
16
+ # if not path:
17
+ # path = os.path.expanduser("~\\AppData\\Local")
18
+ # if appname:
19
+ # path = os.path.join(path, appname, "Logs")
20
+ # else:
21
+ # path = os.path.join(path, "Logs")
22
+ #
23
+ # elif sys.platform == "darwin":
24
+ # # macOS
25
+ # path = os.path.expanduser("~/Library/Logs")
26
+ # if appname:
27
+ # path = os.path.join(path, appname)
28
+ #
29
+ # elif (os.getenv("ANDROID_DATA") == "/data" and os.getenv("ANDROID_ROOT") == "/system"):
30
+ # # Android
31
+ # base = None
32
+ # try:
33
+ # from android import mActivity # type: ignore
34
+ # context = mActivity.getApplicationContext()
35
+ # base = context.getCacheDir().getAbsolutePath()
36
+ # except Exception:
37
+ # try:
38
+ # from jnius import autoclass # type: ignore
39
+ # context = autoclass("android.content.Context")
40
+ # base = context.getCacheDir().getAbsolutePath()
41
+ # except Exception:
42
+ # import re
43
+ # pattern = re.compile(r"/data/(data|user/\d+)/(.+)/files")
44
+ # for p in sys.path:
45
+ # match = pattern.match(p)
46
+ # if match:
47
+ # base = p.split("/files")[0] + "/cache"
48
+ # if base is None:
49
+ # base = "/data/data/unknown/cache"
50
+ #
51
+ # if appname:
52
+ # path = os.path.join(base, appname, "log")
53
+ # else:
54
+ # path = os.path.join(base, "log")
55
+ # else:
56
+ # # Linux/Unix
57
+ # state_home = os.environ.get("XDG_STATE_HOME")
58
+ # if not state_home or not state_home.strip():
59
+ # state_home = os.path.expanduser("~/.local/state")
60
+ #
61
+ # if appname:
62
+ # path = os.path.join(state_home, appname, "log")
63
+ # else:
64
+ # path = os.path.join(state_home, "log")
65
+ #
66
+ # return path
67
+ #
68
+ # def setup_logging():
69
+ # log_dir = user_log_dir("Krest")
70
+ # os.makedirs(log_dir, exist_ok=True)
71
+ # log_file = os.path.join(log_dir, 'krest.log')
72
+ # print("[I] Log File:", log_file)
73
+ # logging.basicConfig(filename=log_file, level=logging.DEBUG, force=True)
74
+ #
75
+ # setup_logging()
76
+ #
krest/krest_qt.py ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # import os
4
+ # import gc
5
+ import locale
6
+
7
+ # import sys
8
+ # import signal
9
+
10
+ import logging
11
+
12
+ # from importlib.resources import files
13
+
14
+ # from PySide6.QtGui import QGuiApplication
15
+ # from PySide6.QtCore import QUrl
16
+ # from PySide6.QtQml import QQmlApplicationEngine
17
+
18
+ from krest.krest_backend import setup_logging, KrestBackend
19
+ # from krest.translations import t
20
+
21
+
22
+ lang, encoding = locale.getdefaultlocale()
23
+
24
+ def qt():
25
+ # TODO: QT/Kirigami UI
26
+
27
+ return "This feature is yet not implemented"
28
+
29
+ logging.info("connct backend...")
30
+ backend = KrestBackend()
31
+ print("File version: " + backend.file_data["version"])
32
+
33
+ app = QGuiApplication(sys.argv)
34
+ engine = QQmlApplicationEngine()
35
+
36
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
37
+
38
+ if not os.environ.get("QT_QUICK_CONTROLS_STYLE"):
39
+ os.environ["QT_QUICK_CONTROLS_STYLE"] = "org.kde.desktop"
40
+
41
+ base_path = files('krest').joinpath('qml', 'Main.qml')
42
+ url = QUrl(f"{base_path}")
43
+
44
+ engine.load(url)
45
+
46
+ if len(engine.rootObjects()) == 0:
47
+ quit()
48
+
49
+ app.exec()
50
+
51
+ def main():
52
+ setup_logging()
53
+ logging.info("Start main()...")
54
+
55
+ logging.info("start QT...")
56
+ qt()
57
+
58
+ logging.info("End main().")
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()