cal-docs-server 3.0.0b1__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.
@@ -0,0 +1,112 @@
1
+ # ----------------------------------------------------------------------------------------
2
+ # async_file
3
+ # ----------
4
+ #
5
+ # Provides async file functions
6
+ #
7
+ # License
8
+ # -------
9
+ # MIT License - Copyright 2025-2026 Cyber Assessment Labs
10
+ #
11
+ # Authors
12
+ # -------
13
+ # bena
14
+ #
15
+ # Version History
16
+ # ---------------
17
+ # Mar 2024 - Created
18
+ # Dec 2025 - New version 2
19
+ # ----------------------------------------------------------------------------------------
20
+
21
+ # ----------------------------------------------------------------------------------------
22
+ # Imports
23
+ # ----------------------------------------------------------------------------------------
24
+
25
+ import asyncio
26
+ import contextlib
27
+ import io
28
+ from typing import TYPE_CHECKING
29
+ from typing import Literal
30
+ from typing import cast
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import AsyncGenerator
34
+
35
+ # ----------------------------------------------------------------------------------------
36
+ # Functions
37
+ # ----------------------------------------------------------------------------------------
38
+
39
+
40
+ # ----------------------------------------------------------------------------------------
41
+ @contextlib.asynccontextmanager
42
+ async def open_binary(
43
+ file_path: str, mode: BinaryFileMode = "rb"
44
+ ) -> AsyncGenerator[BinaryFile]:
45
+ loop = asyncio.get_running_loop()
46
+ file = await loop.run_in_executor(None, open, file_path, mode)
47
+ assert isinstance(file, io.BufferedIOBase)
48
+
49
+ # file = await _open_binary(file_path, mode)
50
+ file_class = BinaryFile(file)
51
+ try:
52
+ yield file_class
53
+ finally:
54
+ await file_class.close()
55
+
56
+
57
+ # ----------------------------------------------------------------------------------------
58
+ @contextlib.asynccontextmanager
59
+ async def open_text(
60
+ file_path: str, mode: TextFileMode = "rt"
61
+ ) -> AsyncGenerator[TextFile]:
62
+ loop = asyncio.get_running_loop()
63
+ file = await loop.run_in_executor(None, open, file_path, mode)
64
+ file_class = TextFile(cast("io.TextIOWrapper", file))
65
+ try:
66
+ yield file_class
67
+ finally:
68
+ await file_class.close()
69
+
70
+
71
+ # ----------------------------------------------------------------------------------------
72
+ # Classes
73
+ # ----------------------------------------------------------------------------------------
74
+
75
+ BinaryFileMode = Literal["rb", "wb", "w+b"]
76
+ TextFileMode = Literal["r", "rt", "w", "wt", "w+", "w+t"]
77
+
78
+
79
+ # ----------------------------------------------------------------------------------------
80
+ class BinaryFile:
81
+ def __init__(self, file: io.BufferedIOBase):
82
+ self.__file = file
83
+
84
+ async def read(self, size: int = -1) -> bytes:
85
+ loop = asyncio.get_running_loop()
86
+ return await loop.run_in_executor(None, self.__file.read, size)
87
+
88
+ async def write(self, data: bytes) -> None:
89
+ loop = asyncio.get_running_loop()
90
+ await loop.run_in_executor(None, self.__file.write, data)
91
+
92
+ async def close(self) -> None:
93
+ loop = asyncio.get_running_loop()
94
+ await loop.run_in_executor(None, self.__file.close)
95
+
96
+
97
+ # ----------------------------------------------------------------------------------------
98
+ class TextFile:
99
+ def __init__(self, file: io.TextIOWrapper):
100
+ self.__file = file
101
+
102
+ async def read(self, size: int = -1) -> str:
103
+ loop = asyncio.get_running_loop()
104
+ return await loop.run_in_executor(None, self.__file.read, size)
105
+
106
+ async def write(self, data: str) -> None:
107
+ loop = asyncio.get_running_loop()
108
+ await loop.run_in_executor(None, self.__file.write, data)
109
+
110
+ async def close(self) -> None:
111
+ loop = asyncio.get_running_loop()
112
+ await loop.run_in_executor(None, self.__file.close)
@@ -0,0 +1,253 @@
1
+ # ----------------------------------------------------------------------------------------
2
+ # auth
3
+ # ----
4
+ #
5
+ # Token-based authentication for API endpoints
6
+ #
7
+ # License
8
+ # -------
9
+ # MIT License - Copyright 2025-2026 Cyber Assessment Labs
10
+ #
11
+ # Authors
12
+ # -------
13
+ # bena (via Claude)
14
+ #
15
+ # Version History
16
+ # ---------------
17
+ # Feb 2026 - Created
18
+ # ----------------------------------------------------------------------------------------
19
+
20
+ # ----------------------------------------------------------------------------------------
21
+ # Imports
22
+ # ----------------------------------------------------------------------------------------
23
+
24
+ import json
25
+ import logging
26
+ import time
27
+ from typing import Any
28
+ from typing import TypedDict
29
+ from aiohttp import web
30
+
31
+ # ----------------------------------------------------------------------------------------
32
+ # Types
33
+ # ----------------------------------------------------------------------------------------
34
+
35
+
36
+ class TokenInfo(TypedDict):
37
+ """Information about a token's permissions."""
38
+
39
+ name: str # Human-readable token name
40
+ projects: list[str] | None # None = all projects allowed
41
+ allow_overwrite: bool
42
+ existing_projects_only: bool
43
+
44
+
45
+ TokensDict = dict[str, TokenInfo]
46
+
47
+ # ----------------------------------------------------------------------------------------
48
+ # Token Cache
49
+ # ----------------------------------------------------------------------------------------
50
+
51
+ _tokens_cache: TokensDict = {}
52
+ _tokens_cache_time: float = 0.0
53
+ _TOKENS_CACHE_TTL: float = 5.0 # Reload tokens at most every 5 seconds
54
+
55
+ # ----------------------------------------------------------------------------------------
56
+ # Functions
57
+ # ----------------------------------------------------------------------------------------
58
+
59
+
60
+ # ----------------------------------------------------------------------------------------
61
+ def _json_dumps(data: Any) -> str:
62
+ """JSON serializer with consistent formatting and trailing newline."""
63
+ return json.dumps(data, indent=2) + "\n"
64
+
65
+
66
+ # ----------------------------------------------------------------------------------------
67
+ def load_tokens(file_path: str | None) -> TokensDict:
68
+ """
69
+ Loads authentication tokens from a JSON file.
70
+ Returns empty dict if file_path is None or on any error (logged).
71
+
72
+ Format:
73
+ {
74
+ "tokens": {
75
+ "admin": {
76
+ "token": "abc123xyz",
77
+ "allow_overwrite": true
78
+ },
79
+ "ci-pipeline": {
80
+ "token": "def456uvw",
81
+ "projects": ["my-project"]
82
+ }
83
+ }
84
+ }
85
+
86
+ Key is a human-readable name. Fields:
87
+ - token (required): the actual secret token value
88
+ - projects (optional): list of allowed projects. Omit for all projects
89
+ - allow_overwrite (optional): defaults to false. Set true to allow overwrites
90
+ - existing_projects_only (optional): defaults to false. Set true to only allow
91
+ uploading new versions to existing projects
92
+ """
93
+
94
+ if file_path is None:
95
+ return {}
96
+
97
+ try:
98
+ with open(file_path) as f:
99
+ data: dict[str, dict[str, dict[str, Any]]] = json.load(f)
100
+ except FileNotFoundError:
101
+ logging.error("Tokens file not found: %s", file_path)
102
+ return {}
103
+ except json.JSONDecodeError as e:
104
+ logging.error("Invalid JSON in tokens file %s: %s", file_path, e)
105
+ return {}
106
+ except PermissionError:
107
+ logging.error("Permission denied reading tokens file: %s", file_path)
108
+ return {}
109
+ except OSError as e:
110
+ logging.error("Error reading tokens file %s: %s", file_path, e)
111
+ return {}
112
+
113
+ tokens: TokensDict = {}
114
+ for name, entry in data.get("tokens", {}).items():
115
+ token_str: str = entry.get("token", "")
116
+ if token_str:
117
+ tokens[token_str] = {
118
+ "name": name,
119
+ "projects": entry.get("projects"), # None = all projects
120
+ "allow_overwrite": entry.get("allow_overwrite", False),
121
+ "existing_projects_only": entry.get("existing_projects_only", False),
122
+ }
123
+
124
+ return tokens
125
+
126
+
127
+ # ----------------------------------------------------------------------------------------
128
+ def get_tokens(request: web.Request) -> TokensDict:
129
+ """
130
+ Gets the current tokens, reloading from file if cache has expired.
131
+ Reloads at most every 5 seconds to avoid excessive file reads.
132
+ """
133
+
134
+ global _tokens_cache, _tokens_cache_time
135
+
136
+ tokens_file: str | None = request.app.get("tokens_file")
137
+ if tokens_file is None:
138
+ return {}
139
+
140
+ now = time.time()
141
+ if now - _tokens_cache_time >= _TOKENS_CACHE_TTL:
142
+ _tokens_cache = load_tokens(tokens_file)
143
+ _tokens_cache_time = now
144
+ logging.debug("Reloaded tokens from %s", tokens_file)
145
+
146
+ return _tokens_cache
147
+
148
+
149
+ # ----------------------------------------------------------------------------------------
150
+ def get_token_info(request: web.Request) -> TokenInfo | None:
151
+ """
152
+ Gets the TokenInfo for the request's token, or None if invalid/missing.
153
+ """
154
+
155
+ tokens = get_tokens(request)
156
+
157
+ if not tokens:
158
+ return None
159
+
160
+ token = request.headers.get("X-Token", "")
161
+ if token and token in tokens:
162
+ return tokens[token]
163
+
164
+ return None
165
+
166
+
167
+ # ----------------------------------------------------------------------------------------
168
+ def check_auth(request: web.Request) -> bool:
169
+ """
170
+ Validates the request has a valid authentication token.
171
+ Checks X-Token header for the token value.
172
+ Returns True if valid, False otherwise.
173
+ """
174
+
175
+ return get_token_info(request) is not None
176
+
177
+
178
+ # ----------------------------------------------------------------------------------------
179
+ def check_project_allowed(request: web.Request, project: str) -> bool:
180
+ """
181
+ Checks if the token is allowed to upload to the specified project.
182
+ Returns False if token is invalid or project not allowed.
183
+ """
184
+
185
+ token_info = get_token_info(request)
186
+ if token_info is None:
187
+ return False
188
+
189
+ allowed_projects = token_info["projects"]
190
+ if allowed_projects is None:
191
+ return True # No restriction
192
+
193
+ return project in allowed_projects
194
+
195
+
196
+ # ----------------------------------------------------------------------------------------
197
+ def check_overwrite_allowed(request: web.Request) -> bool:
198
+ """
199
+ Checks if the token is allowed to overwrite existing directories.
200
+ Returns False if token is invalid or overwrite not allowed.
201
+ """
202
+
203
+ token_info = get_token_info(request)
204
+ if token_info is None:
205
+ return False
206
+
207
+ return token_info["allow_overwrite"]
208
+
209
+
210
+ # ----------------------------------------------------------------------------------------
211
+ def check_existing_projects_only(request: web.Request) -> bool:
212
+ """
213
+ Checks if the token is restricted to existing projects only.
214
+ Returns True if token requires existing projects, False otherwise.
215
+ """
216
+
217
+ token_info = get_token_info(request)
218
+ if token_info is None:
219
+ return False
220
+
221
+ return token_info["existing_projects_only"]
222
+
223
+
224
+ # ----------------------------------------------------------------------------------------
225
+ def get_token_name(request: web.Request) -> str | None:
226
+ """
227
+ Gets the human-readable name of the token used in the request.
228
+ Returns None if no valid token.
229
+ """
230
+
231
+ token_info = get_token_info(request)
232
+ if token_info is None:
233
+ return None
234
+
235
+ return token_info["name"]
236
+
237
+
238
+ # ----------------------------------------------------------------------------------------
239
+ def require_auth(request: web.Request) -> web.Response | None:
240
+ """
241
+ Helper that returns 401 response if auth fails, None if auth succeeds.
242
+ Usage:
243
+ if error := require_auth(request):
244
+ return error
245
+ """
246
+
247
+ if not check_auth(request):
248
+ return web.json_response(
249
+ dumps=_json_dumps,
250
+ data={"error": "Unauthorized", "message": "Valid API token required"},
251
+ status=401,
252
+ )
253
+ return None
@@ -0,0 +1,106 @@
1
+ # ----------------------------------------------------------------------------------------
2
+ # cache_render_index
3
+ # ------------------
4
+ #
5
+ # This caches the index page so it doesn't render it on every request, only if enough
6
+ # time has passed between previous render.
7
+ #
8
+ # License
9
+ # -------
10
+ # MIT License - Copyright 2025-2026 Cyber Assessment Labs
11
+ #
12
+ # Authors
13
+ # -------
14
+ # bena
15
+ #
16
+ # Version History
17
+ # ---------------
18
+ # Mar 2024 - Created
19
+ # Dec 2025 - New version 2
20
+ # ----------------------------------------------------------------------------------------
21
+
22
+ # ----------------------------------------------------------------------------------------
23
+ # Imports
24
+ # ----------------------------------------------------------------------------------------
25
+
26
+ import asyncio
27
+ import time
28
+ from . import index_docs
29
+ from . import render_index
30
+
31
+ # ----------------------------------------------------------------------------------------
32
+ # Constants
33
+ # ----------------------------------------------------------------------------------------
34
+
35
+ CACHE_TIME_SECONDS = 20
36
+
37
+ # ----------------------------------------------------------------------------------------
38
+ # Globals
39
+ # ----------------------------------------------------------------------------------------
40
+
41
+ g_last_rendered_html: str = ""
42
+ g_last_rendered_time: float = 0
43
+ g_currently_rendering: bool = False
44
+ g_version_redirects: dict[str, str] = {}
45
+
46
+ # ----------------------------------------------------------------------------------------
47
+ # Functions
48
+ # ----------------------------------------------------------------------------------------
49
+
50
+
51
+ # ----------------------------------------------------------------------------------------
52
+ async def cache_render_index(
53
+ root_directory: str, server_name: str = "Documents Server"
54
+ ) -> tuple[str, dict[str, str]]:
55
+ """
56
+ Builds index from `root_directory` and renders it into HTML. This will cache the
57
+ result for a certain amount of time and re use it if another request comes in
58
+ within the time.
59
+ Returns a tuple of (html, version_redirects dict)
60
+ """
61
+
62
+ global g_last_rendered_time
63
+ global g_last_rendered_html
64
+ global g_currently_rendering
65
+ global g_version_redirects
66
+
67
+ if g_currently_rendering:
68
+ # If already rendering, then just wait for this one to finish and then return
69
+ # it rather than start another one.
70
+ await _wait_for_render()
71
+
72
+ elif time.time() - g_last_rendered_time > CACHE_TIME_SECONDS:
73
+ # Been too long so render a new one.
74
+ g_currently_rendering = True
75
+
76
+ # Build the index
77
+ index_list = await index_docs.make_index(root_directory=root_directory)
78
+
79
+ # Get version redirects
80
+ g_version_redirects = index_docs.get_version_redirects(index_list)
81
+
82
+ # Render HTML
83
+ html = await render_index.render_index_from_list(
84
+ index_list, server_name=server_name
85
+ )
86
+ g_last_rendered_html = html
87
+ g_currently_rendering = False
88
+ g_last_rendered_time = time.time()
89
+
90
+ else:
91
+ # Otherwise just use the current cache
92
+ pass
93
+
94
+ return g_last_rendered_html, g_version_redirects
95
+
96
+
97
+ # ----------------------------------------------------------------------------------------
98
+ async def _wait_for_render() -> None:
99
+ """
100
+ Waits until `g_currently_rendering` is False
101
+ """
102
+
103
+ global g_currently_rendering
104
+
105
+ while g_currently_rendering:
106
+ await asyncio.sleep(0.05)