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.
- cal_docs_server/.gitignore +1 -0
- cal_docs_server/__init__.py +1 -0
- cal_docs_server/__main__.py +64 -0
- cal_docs_server/_version.py +4 -0
- cal_docs_server/api.py +643 -0
- cal_docs_server/async_file.py +112 -0
- cal_docs_server/auth.py +253 -0
- cal_docs_server/cache_render_index.py +106 -0
- cal_docs_server/index_docs.py +449 -0
- cal_docs_server/main.py +191 -0
- cal_docs_server/render_index.py +174 -0
- cal_docs_server/render_md.py +90 -0
- cal_docs_server/resources/__init__.py +1 -0
- cal_docs_server/resources/help.md +171 -0
- cal_docs_server/resources/index_template.html +612 -0
- cal_docs_server/resources/md_template.html +244 -0
- cal_docs_server/resources/openapi.yaml +281 -0
- cal_docs_server/version.py +38 -0
- cal_docs_server/web_server.py +217 -0
- cal_docs_server-3.0.0b1.dist-info/METADATA +12 -0
- cal_docs_server-3.0.0b1.dist-info/RECORD +24 -0
- cal_docs_server-3.0.0b1.dist-info/WHEEL +4 -0
- cal_docs_server-3.0.0b1.dist-info/entry_points.txt +2 -0
- cal_docs_server-3.0.0b1.dist-info/licenses/LICENSE +21 -0
|
@@ -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)
|
cal_docs_server/auth.py
ADDED
|
@@ -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)
|