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
cal_docs_server/api.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
# ----------------------------------------------------------------------------------------
|
|
2
|
+
# api
|
|
3
|
+
# ---
|
|
4
|
+
#
|
|
5
|
+
# REST API endpoints for cal-docs-server
|
|
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 importlib.resources
|
|
25
|
+
import io
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import urllib.parse
|
|
30
|
+
import zipfile
|
|
31
|
+
from datetime import datetime
|
|
32
|
+
from functools import partial
|
|
33
|
+
from typing import Any
|
|
34
|
+
import yaml
|
|
35
|
+
from aiohttp import web
|
|
36
|
+
from aiohttp.multipart import BodyPartReader
|
|
37
|
+
from . import async_file
|
|
38
|
+
from . import auth
|
|
39
|
+
from . import index_docs
|
|
40
|
+
from .version import VERSION_STR
|
|
41
|
+
|
|
42
|
+
# ----------------------------------------------------------------------------------------
|
|
43
|
+
# Constants
|
|
44
|
+
# ----------------------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
PRODUCT_NAME = "cal-docs-server"
|
|
47
|
+
API_VERSION = "1.0"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _json_dumps(data: Any) -> str:
|
|
51
|
+
"""JSON serializer with consistent formatting and trailing newline."""
|
|
52
|
+
return json.dumps(data, indent=2) + "\n"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_docs_filename(filename: str) -> tuple[str, str] | None:
|
|
56
|
+
"""
|
|
57
|
+
Parses a documentation zip filename to extract project name and version.
|
|
58
|
+
|
|
59
|
+
Handles formats like:
|
|
60
|
+
- cal-gitlab-mirror-0.3.1-docs.zip -> ("cal-gitlab-mirror", "0.3.1")
|
|
61
|
+
- caltech-6.5.0b5-documentation.zip -> ("caltech", "6.5.0b5")
|
|
62
|
+
- git-rewrite-0.8.1-docs.zip -> ("git-rewrite", "0.8.1")
|
|
63
|
+
- my-project-1.2.3.zip -> ("my-project", "1.2.3")
|
|
64
|
+
|
|
65
|
+
Returns (project, version) tuple or None if parsing fails.
|
|
66
|
+
"""
|
|
67
|
+
# Must end with .zip
|
|
68
|
+
if not filename.lower().endswith(".zip"):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# Strip .zip
|
|
72
|
+
name = filename[:-4]
|
|
73
|
+
|
|
74
|
+
# Strip common suffixes
|
|
75
|
+
for suffix in ["-docs", "-documentation", "-doc"]:
|
|
76
|
+
if name.lower().endswith(suffix):
|
|
77
|
+
name = name[: -len(suffix)]
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
# Find the version: look for last hyphen followed by a digit
|
|
81
|
+
# Version starts with a digit and can contain digits, dots, letters (like b1, rc1)
|
|
82
|
+
match = re.search(r"-(\d[^-]*)$", name)
|
|
83
|
+
if not match:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
version = match.group(1)
|
|
87
|
+
project = name[: match.start()]
|
|
88
|
+
|
|
89
|
+
# Validate we got something reasonable
|
|
90
|
+
if not project or not version:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
return (project, version)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_upload_filename(request: web.Request) -> str | None:
|
|
97
|
+
"""
|
|
98
|
+
Best-effort filename extraction for application/zip uploads.
|
|
99
|
+
|
|
100
|
+
For multipart uploads, aiohttp provides `field.filename` and this helper is not used.
|
|
101
|
+
For raw uploads, accept either:
|
|
102
|
+
- X-Filename: my-project-1.2.3-docs.zip
|
|
103
|
+
- Content-Disposition: attachment; filename="my-project-1.2.3-docs.zip"
|
|
104
|
+
- Content-Disposition: attachment; filename*=UTF-8''my-project-1.2.3-docs.zip
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
header_filename = request.headers.get("X-Filename", "").strip()
|
|
108
|
+
if header_filename:
|
|
109
|
+
return os.path.basename(header_filename.strip('"'))
|
|
110
|
+
|
|
111
|
+
content_disposition = request.headers.get("Content-Disposition", "")
|
|
112
|
+
if not content_disposition:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
match = re.search(
|
|
116
|
+
r"filename\*=UTF-8''([^;]+)", content_disposition, flags=re.IGNORECASE
|
|
117
|
+
)
|
|
118
|
+
if match:
|
|
119
|
+
decoded = urllib.parse.unquote(match.group(1)).strip().strip('"')
|
|
120
|
+
return os.path.basename(decoded)
|
|
121
|
+
|
|
122
|
+
match = re.search(
|
|
123
|
+
r'filename="?([^";]+)"?', content_disposition, flags=re.IGNORECASE
|
|
124
|
+
)
|
|
125
|
+
if match:
|
|
126
|
+
return os.path.basename(match.group(1).strip())
|
|
127
|
+
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ----------------------------------------------------------------------------------------
|
|
132
|
+
# Functions
|
|
133
|
+
# ----------------------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ----------------------------------------------------------------------------------------
|
|
137
|
+
def setup_api_routes(app: web.Application, root_directory: str) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Registers all API routes on the application.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
app.router.add_get("/api/help", _handle_help)
|
|
143
|
+
app.router.add_get("/api/version", _handle_version)
|
|
144
|
+
app.router.add_get("/api/spec", _handle_spec)
|
|
145
|
+
app.router.add_get(
|
|
146
|
+
"/api/projects", partial(_handle_projects, root_directory=root_directory)
|
|
147
|
+
)
|
|
148
|
+
app.router.add_get(
|
|
149
|
+
"/api/download/{project}/{version}",
|
|
150
|
+
partial(_handle_download, root_directory=root_directory),
|
|
151
|
+
)
|
|
152
|
+
app.router.add_post(
|
|
153
|
+
"/api/upload", partial(_handle_upload, root_directory=root_directory)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ----------------------------------------------------------------------------------------
|
|
158
|
+
# Private Functions
|
|
159
|
+
# ----------------------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
API_HELP_TEMPLATE = """\
|
|
162
|
+
CAL Documentation Server API
|
|
163
|
+
=============================
|
|
164
|
+
Version: {version}
|
|
165
|
+
|
|
166
|
+
ENDPOINTS
|
|
167
|
+
---------
|
|
168
|
+
|
|
169
|
+
GET /api/help
|
|
170
|
+
This help text.
|
|
171
|
+
|
|
172
|
+
GET /api/version
|
|
173
|
+
Returns server version as JSON.
|
|
174
|
+
Example: curl {base}/api/version
|
|
175
|
+
|
|
176
|
+
GET /api/spec
|
|
177
|
+
Returns OpenAPI 3.0 specification as JSON.
|
|
178
|
+
Example: curl {base}/api/spec
|
|
179
|
+
|
|
180
|
+
GET /api/projects
|
|
181
|
+
Lists all documentation projects and their versions.
|
|
182
|
+
Optional: ?search=term to filter by project name.
|
|
183
|
+
Example: curl {base}/api/projects
|
|
184
|
+
Example: curl "{base}/api/projects?search=myproject"
|
|
185
|
+
|
|
186
|
+
GET /api/download/{{project}}/{{version}}
|
|
187
|
+
Downloads a project version as a .zip file.
|
|
188
|
+
Use "latest" as version for the most recent release.
|
|
189
|
+
Example: curl -o docs.zip {base}/api/download/myproject/1.0.0
|
|
190
|
+
Example: curl -o docs.zip {base}/api/download/myproject/latest
|
|
191
|
+
|
|
192
|
+
POST /api/upload
|
|
193
|
+
Uploads documentation as a .zip file. Requires authentication.
|
|
194
|
+
Filename must be: {{project}}-{{version}}.zip or {{project}}-{{version}}-docs.zip
|
|
195
|
+
Example: curl -X POST -H "X-Token: YOUR_TOKEN" \\
|
|
196
|
+
-F "file=@myproject-1.0.0-docs.zip" \\
|
|
197
|
+
{base}/api/upload
|
|
198
|
+
|
|
199
|
+
AUTHENTICATION
|
|
200
|
+
--------------
|
|
201
|
+
|
|
202
|
+
The upload endpoint requires a token via the X-Token header:
|
|
203
|
+
|
|
204
|
+
curl -H "X-Token: your-token-here" ...
|
|
205
|
+
|
|
206
|
+
Tokens are configured server-side. Contact your administrator for access.
|
|
207
|
+
|
|
208
|
+
CLIENT TOOL
|
|
209
|
+
-----------
|
|
210
|
+
|
|
211
|
+
For easier command-line access, install cal-docs-client:
|
|
212
|
+
|
|
213
|
+
pip install cal-docs-client
|
|
214
|
+
cal-docs-client --help
|
|
215
|
+
|
|
216
|
+
For more information, see the OpenAPI spec at /api/spec
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ----------------------------------------------------------------------------------------
|
|
221
|
+
async def _handle_help(request: web.Request) -> web.Response:
|
|
222
|
+
"""
|
|
223
|
+
GET /api/help
|
|
224
|
+
Returns human-readable API documentation.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
base_url = request.app.get("base_url", "") or "http://example.com"
|
|
228
|
+
help_text = API_HELP_TEMPLATE.format(base=base_url, version=VERSION_STR)
|
|
229
|
+
return web.Response(text=help_text, content_type="text/plain")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ----------------------------------------------------------------------------------------
|
|
233
|
+
async def _handle_version(_request: web.Request) -> web.Response:
|
|
234
|
+
"""
|
|
235
|
+
GET /api/version
|
|
236
|
+
Returns product name and version as JSON.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
return web.json_response(
|
|
240
|
+
dumps=_json_dumps,
|
|
241
|
+
data={
|
|
242
|
+
"product": PRODUCT_NAME,
|
|
243
|
+
"version": VERSION_STR,
|
|
244
|
+
"apiVersion": API_VERSION,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ----------------------------------------------------------------------------------------
|
|
250
|
+
async def _handle_spec(_request: web.Request) -> web.Response:
|
|
251
|
+
"""
|
|
252
|
+
GET /api/spec
|
|
253
|
+
Returns the OpenAPI 3.0 specification.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
file_folder = importlib.resources.files("cal_docs_server.resources")
|
|
258
|
+
file_path = os.path.join(str(file_folder), "openapi.yaml")
|
|
259
|
+
|
|
260
|
+
async with async_file.open_text(file_path, "r") as f:
|
|
261
|
+
spec_content = await f.read()
|
|
262
|
+
|
|
263
|
+
# Parse YAML and return as JSON for easier consumption
|
|
264
|
+
spec_dict = yaml.safe_load(spec_content)
|
|
265
|
+
return web.json_response(dumps=_json_dumps, data=spec_dict)
|
|
266
|
+
except Exception as e:
|
|
267
|
+
return web.json_response(
|
|
268
|
+
dumps=_json_dumps,
|
|
269
|
+
data={
|
|
270
|
+
"error": "Internal Server Error",
|
|
271
|
+
"message": f"Failed to load spec: {e}",
|
|
272
|
+
},
|
|
273
|
+
status=500,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ----------------------------------------------------------------------------------------
|
|
278
|
+
async def _handle_projects(request: web.Request, root_directory: str) -> web.Response:
|
|
279
|
+
"""
|
|
280
|
+
GET /api/projects
|
|
281
|
+
GET /api/projects?search=term
|
|
282
|
+
|
|
283
|
+
Returns list of all documentation projects and their versions.
|
|
284
|
+
Optional search parameter filters by project name (case-insensitive).
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
index_list = await index_docs.make_index(root_directory)
|
|
288
|
+
|
|
289
|
+
# Apply search filter if provided
|
|
290
|
+
search_term = request.query.get("search", "").lower().strip()
|
|
291
|
+
if search_term:
|
|
292
|
+
index_list = [
|
|
293
|
+
item
|
|
294
|
+
for item in index_list
|
|
295
|
+
if search_term in item["name"].lower()
|
|
296
|
+
or search_term in item["directory_name"].lower()
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
# Prepend base_url to version URLs if configured
|
|
300
|
+
base_url = request.app.get("base_url", "")
|
|
301
|
+
if base_url:
|
|
302
|
+
for item in index_list:
|
|
303
|
+
for version in item["versions"]:
|
|
304
|
+
version["url"] = base_url + version["url"]
|
|
305
|
+
version["download_url"] = base_url + version["download_url"]
|
|
306
|
+
|
|
307
|
+
return web.json_response(
|
|
308
|
+
dumps=_json_dumps, data={"projects": index_list, "count": len(index_list)}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ----------------------------------------------------------------------------------------
|
|
313
|
+
async def _handle_download(request: web.Request, root_directory: str) -> web.Response:
|
|
314
|
+
"""
|
|
315
|
+
GET /api/download/{project}/{version}
|
|
316
|
+
|
|
317
|
+
Downloads a project version as a .zip file.
|
|
318
|
+
The {version} can be a specific version like "1.2.3" or "latest" for the latest version.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
project = request.match_info.get("project", "")
|
|
322
|
+
version = request.match_info.get("version", "")
|
|
323
|
+
|
|
324
|
+
if not project or not version:
|
|
325
|
+
return web.json_response(
|
|
326
|
+
dumps=_json_dumps,
|
|
327
|
+
data={
|
|
328
|
+
"error": "Bad Request",
|
|
329
|
+
"message": "Project and version are required",
|
|
330
|
+
},
|
|
331
|
+
status=400,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Validate no path traversal
|
|
335
|
+
if ".." in project or ".." in version:
|
|
336
|
+
return web.json_response(
|
|
337
|
+
dumps=_json_dumps,
|
|
338
|
+
data={"error": "Bad Request", "message": "Invalid project or version"},
|
|
339
|
+
status=400,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Determine the directory to zip
|
|
343
|
+
if version.lower() == "latest":
|
|
344
|
+
# Find the latest version for this project
|
|
345
|
+
index_list = await index_docs.make_index(root_directory)
|
|
346
|
+
project_item = next(
|
|
347
|
+
(item for item in index_list if item["directory_name"] == project),
|
|
348
|
+
None,
|
|
349
|
+
)
|
|
350
|
+
if not project_item:
|
|
351
|
+
return web.json_response(
|
|
352
|
+
dumps=_json_dumps,
|
|
353
|
+
data={
|
|
354
|
+
"error": "Not Found",
|
|
355
|
+
"message": f"Project '{project}' not found",
|
|
356
|
+
},
|
|
357
|
+
status=404,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
version_dir = project_item.get("latest_version_dir")
|
|
361
|
+
if not version_dir:
|
|
362
|
+
# No versioned directories, use base directory
|
|
363
|
+
version_dir = project
|
|
364
|
+
else:
|
|
365
|
+
# Specific version requested
|
|
366
|
+
version_dir = f"{project}-{version}"
|
|
367
|
+
|
|
368
|
+
dir_path = os.path.join(root_directory, version_dir)
|
|
369
|
+
|
|
370
|
+
if not os.path.isdir(dir_path):
|
|
371
|
+
return web.json_response(
|
|
372
|
+
dumps=_json_dumps,
|
|
373
|
+
data={
|
|
374
|
+
"error": "Not Found",
|
|
375
|
+
"message": f"Version '{version}' not found for project '{project}'",
|
|
376
|
+
},
|
|
377
|
+
status=404,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Create zip in memory
|
|
381
|
+
zip_buffer = io.BytesIO()
|
|
382
|
+
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
|
383
|
+
for root, dirs, files in os.walk(dir_path):
|
|
384
|
+
# Skip hidden directories
|
|
385
|
+
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
386
|
+
|
|
387
|
+
for file in files:
|
|
388
|
+
if file.startswith("."):
|
|
389
|
+
continue
|
|
390
|
+
file_path = os.path.join(root, file)
|
|
391
|
+
arcname = os.path.relpath(file_path, dir_path)
|
|
392
|
+
zip_file.write(file_path, arcname)
|
|
393
|
+
|
|
394
|
+
zip_buffer.seek(0)
|
|
395
|
+
zip_filename = f"{version_dir}.zip"
|
|
396
|
+
|
|
397
|
+
return web.Response(
|
|
398
|
+
body=zip_buffer.getvalue(),
|
|
399
|
+
content_type="application/zip",
|
|
400
|
+
headers={"Content-Disposition": f'attachment; filename="{zip_filename}"'},
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ----------------------------------------------------------------------------------------
|
|
405
|
+
async def _handle_upload(request: web.Request, root_directory: str) -> web.Response:
|
|
406
|
+
"""
|
|
407
|
+
POST /api/upload
|
|
408
|
+
|
|
409
|
+
Uploads a .zip file containing documentation.
|
|
410
|
+
Requires authentication via X-Token header.
|
|
411
|
+
|
|
412
|
+
The zip file should be sent as multipart/form-data with field name "file".
|
|
413
|
+
Alternative: raw zip bytes in request body with Content-Type: application/zip.
|
|
414
|
+
For raw uploads, include a filename via X-Filename or Content-Disposition.
|
|
415
|
+
|
|
416
|
+
curl example:
|
|
417
|
+
curl -X POST -H "X-Token: TOKEN" -F "file=@myproject-1.0.0-docs.zip" http://localhost/api/upload
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
# Check authentication
|
|
421
|
+
if error := auth.require_auth(request):
|
|
422
|
+
return error
|
|
423
|
+
|
|
424
|
+
zip_filename: str | None = None
|
|
425
|
+
|
|
426
|
+
# Handle multipart upload (form-data)
|
|
427
|
+
if request.content_type and request.content_type.startswith("multipart/"):
|
|
428
|
+
reader = await request.multipart()
|
|
429
|
+
field = await reader.next()
|
|
430
|
+
|
|
431
|
+
if field is None or not isinstance(field, BodyPartReader):
|
|
432
|
+
return web.json_response(
|
|
433
|
+
dumps=_json_dumps,
|
|
434
|
+
data={
|
|
435
|
+
"error": "Bad Request",
|
|
436
|
+
"message": "Missing 'file' field in form data",
|
|
437
|
+
},
|
|
438
|
+
status=400,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
if field.name != "file":
|
|
442
|
+
return web.json_response(
|
|
443
|
+
dumps=_json_dumps,
|
|
444
|
+
data={
|
|
445
|
+
"error": "Bad Request",
|
|
446
|
+
"message": "Missing 'file' field in form data",
|
|
447
|
+
},
|
|
448
|
+
status=400,
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Get the original filename
|
|
452
|
+
zip_filename = field.filename
|
|
453
|
+
|
|
454
|
+
# Read the uploaded file
|
|
455
|
+
zip_data = await field.read(decode=False)
|
|
456
|
+
|
|
457
|
+
# Handle raw binary upload
|
|
458
|
+
elif request.content_type == "application/zip":
|
|
459
|
+
zip_filename = _get_upload_filename(request)
|
|
460
|
+
zip_data = await request.read()
|
|
461
|
+
|
|
462
|
+
else:
|
|
463
|
+
return web.json_response(
|
|
464
|
+
dumps=_json_dumps,
|
|
465
|
+
data={
|
|
466
|
+
"error": "Bad Request",
|
|
467
|
+
"message": "Expected multipart/form-data or application/zip",
|
|
468
|
+
},
|
|
469
|
+
status=400,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if not zip_data:
|
|
473
|
+
return web.json_response(
|
|
474
|
+
dumps=_json_dumps,
|
|
475
|
+
data={"error": "Bad Request", "message": "Empty file uploaded"},
|
|
476
|
+
status=400,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
# Validate it's a valid zip file
|
|
480
|
+
try:
|
|
481
|
+
zip_buffer = io.BytesIO(zip_data)
|
|
482
|
+
with zipfile.ZipFile(zip_buffer, "r") as zip_file:
|
|
483
|
+
# Security: check for path traversal in zip entries
|
|
484
|
+
for name in zip_file.namelist():
|
|
485
|
+
if name.startswith("/") or ".." in name:
|
|
486
|
+
return web.json_response(
|
|
487
|
+
dumps=_json_dumps,
|
|
488
|
+
data={
|
|
489
|
+
"error": "Bad Request",
|
|
490
|
+
"message": "Invalid file paths in zip",
|
|
491
|
+
},
|
|
492
|
+
status=400,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Parse filename to extract project and version
|
|
496
|
+
# e.g., "my-project-1.2.3-docs.zip" -> dir "my-project-1.2.3"
|
|
497
|
+
if not zip_filename:
|
|
498
|
+
return web.json_response(
|
|
499
|
+
dumps=_json_dumps,
|
|
500
|
+
data={
|
|
501
|
+
"error": "Bad Request",
|
|
502
|
+
"message": (
|
|
503
|
+
"Filename is required. For multipart uploads, pass the file"
|
|
504
|
+
" as form field 'file'. For raw application/zip uploads,"
|
|
505
|
+
" provide a filename via X-Filename or Content-Disposition."
|
|
506
|
+
),
|
|
507
|
+
},
|
|
508
|
+
status=400,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
parsed = _parse_docs_filename(zip_filename)
|
|
512
|
+
if not parsed:
|
|
513
|
+
return web.json_response(
|
|
514
|
+
dumps=_json_dumps,
|
|
515
|
+
data={
|
|
516
|
+
"error": "Bad Request",
|
|
517
|
+
"message": (
|
|
518
|
+
"Could not parse project and version from filename."
|
|
519
|
+
" Expected format: {project}-{version}.zip (optional"
|
|
520
|
+
" suffixes: -docs, -documentation, -doc)"
|
|
521
|
+
),
|
|
522
|
+
},
|
|
523
|
+
status=400,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
project, version = parsed
|
|
527
|
+
target_dir_name = f"{project}-{version}"
|
|
528
|
+
|
|
529
|
+
# Check if token is allowed to upload to this project
|
|
530
|
+
if not auth.check_project_allowed(request, project):
|
|
531
|
+
token_name = auth.get_token_name(request)
|
|
532
|
+
print(
|
|
533
|
+
f"[{datetime.now():%Y-%m-%d %H:%M:%S}] Upload denied:"
|
|
534
|
+
f" ip={request.remote} token={token_name} project={project}"
|
|
535
|
+
f" version={version} reason=project_not_allowed"
|
|
536
|
+
)
|
|
537
|
+
return web.json_response(
|
|
538
|
+
dumps=_json_dumps,
|
|
539
|
+
data={
|
|
540
|
+
"error": "Forbidden",
|
|
541
|
+
"message": f"Token not authorized for project '{project}'",
|
|
542
|
+
},
|
|
543
|
+
status=403,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Check if token is restricted to existing projects only
|
|
547
|
+
if auth.check_existing_projects_only(request):
|
|
548
|
+
# Check if any directory exists for this project
|
|
549
|
+
try:
|
|
550
|
+
dir_list = os.listdir(root_directory)
|
|
551
|
+
except OSError:
|
|
552
|
+
dir_list = []
|
|
553
|
+
project_exists = any(
|
|
554
|
+
d == project or d.startswith(f"{project}-")
|
|
555
|
+
for d in dir_list
|
|
556
|
+
if os.path.isdir(os.path.join(root_directory, d))
|
|
557
|
+
)
|
|
558
|
+
if not project_exists:
|
|
559
|
+
token_name = auth.get_token_name(request)
|
|
560
|
+
print(
|
|
561
|
+
f"[{datetime.now():%Y-%m-%d %H:%M:%S}] Upload denied:"
|
|
562
|
+
f" ip={request.remote} token={token_name} project={project}"
|
|
563
|
+
f" version={version} reason=new_project_not_allowed"
|
|
564
|
+
)
|
|
565
|
+
return web.json_response(
|
|
566
|
+
dumps=_json_dumps,
|
|
567
|
+
data={
|
|
568
|
+
"error": "Forbidden",
|
|
569
|
+
"message": (
|
|
570
|
+
f"Project '{project}' does not exist and token "
|
|
571
|
+
"does not have permission to create new projects"
|
|
572
|
+
),
|
|
573
|
+
},
|
|
574
|
+
status=403,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# Validate directory name (no path traversal)
|
|
578
|
+
if ".." in target_dir_name or "/" in target_dir_name:
|
|
579
|
+
return web.json_response(
|
|
580
|
+
dumps=_json_dumps,
|
|
581
|
+
data={
|
|
582
|
+
"error": "Bad Request",
|
|
583
|
+
"message": "Invalid filename",
|
|
584
|
+
},
|
|
585
|
+
status=400,
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
target_dir = os.path.join(root_directory, target_dir_name)
|
|
589
|
+
|
|
590
|
+
# Check if directory exists and overwrite is not allowed
|
|
591
|
+
if os.path.exists(target_dir) and not auth.check_overwrite_allowed(request):
|
|
592
|
+
token_name = auth.get_token_name(request)
|
|
593
|
+
print(
|
|
594
|
+
f"[{datetime.now():%Y-%m-%d %H:%M:%S}] Upload denied:"
|
|
595
|
+
f" ip={request.remote} token={token_name} project={project}"
|
|
596
|
+
f" version={version} reason=overwrite_not_allowed"
|
|
597
|
+
)
|
|
598
|
+
return web.json_response(
|
|
599
|
+
dumps=_json_dumps,
|
|
600
|
+
data={
|
|
601
|
+
"error": "Forbidden",
|
|
602
|
+
"message": (
|
|
603
|
+
f"Directory '{target_dir_name}' already exists and "
|
|
604
|
+
"token does not have overwrite permission"
|
|
605
|
+
),
|
|
606
|
+
},
|
|
607
|
+
status=403,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
# Create target directory and extract
|
|
611
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
612
|
+
zip_file.extractall(target_dir)
|
|
613
|
+
extracted_files = zip_file.namelist()
|
|
614
|
+
|
|
615
|
+
# Log successful upload
|
|
616
|
+
token_name = auth.get_token_name(request)
|
|
617
|
+
print(
|
|
618
|
+
f"[{datetime.now():%Y-%m-%d %H:%M:%S}] Upload: ip={request.remote}"
|
|
619
|
+
f" token={token_name} project={project} version={version}"
|
|
620
|
+
f" files={len(extracted_files)}"
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
except zipfile.BadZipFile:
|
|
624
|
+
return web.json_response(
|
|
625
|
+
dumps=_json_dumps,
|
|
626
|
+
data={"error": "Bad Request", "message": "Invalid zip file"},
|
|
627
|
+
status=400,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
base_url = request.app.get("base_url", "")
|
|
631
|
+
return web.json_response(
|
|
632
|
+
dumps=_json_dumps,
|
|
633
|
+
data={
|
|
634
|
+
"success": True,
|
|
635
|
+
"message": "Documentation uploaded successfully",
|
|
636
|
+
"project": project,
|
|
637
|
+
"version": version,
|
|
638
|
+
"directory": target_dir_name,
|
|
639
|
+
"url": f"{base_url}/{target_dir_name}/",
|
|
640
|
+
"files_extracted": len(extracted_files),
|
|
641
|
+
},
|
|
642
|
+
status=201,
|
|
643
|
+
)
|