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/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
+ )