|
9 | 9 | """
|
10 | 10 | import os
|
11 | 11 | import secrets
|
12 |
| -from http import HTTPStatus |
| 12 | +import shutil |
| 13 | +from dataclasses import dataclass |
13 | 14 | from pathlib import Path
|
| 15 | +from typing import Optional |
14 | 16 |
|
15 |
| -from flask import Flask, request, send_from_directory |
| 17 | +from fastapi import Depends, FastAPI, File, Header, Response, UploadFile, status |
| 18 | +from fastapi.staticfiles import StaticFiles |
| 19 | +from pydantic import BaseModel |
| 20 | +from starlette.responses import JSONResponse |
16 | 21 | from tinydb import Query, TinyDB
|
17 |
| -from werkzeug.utils import secure_filename |
18 | 22 |
|
19 | 23 | from docat.utils import UPLOAD_FOLDER, calculate_token, create_nginx_config, create_symlink, extract_archive, remove_docs
|
20 | 24 |
|
21 |
| -app = Flask(__name__) |
22 |
| -app.config["UPLOAD_FOLDER"] = Path(os.getenv("DOCAT_DOC_PATH", UPLOAD_FOLDER)) |
23 |
| -app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100M |
24 |
| -app.db = TinyDB("db.json") |
| 25 | +#: Holds the FastAPI application |
| 26 | +app = FastAPI( |
| 27 | + title="docat", |
| 28 | + description="API for docat, https://github.com/docat-org/docat", |
| 29 | + openapi_url="/api/v1/openapi.json", |
| 30 | + docs_url="/api/docs", |
| 31 | + redoc_url="/api/redoc", |
| 32 | +) |
| 33 | +#: Holds an instance to the TinyDB |
| 34 | +db = TinyDB("db.json") |
| 35 | +#: Holds the static base path where the uploaded documentation artifacts are stored |
| 36 | +DOCAT_UPLOAD_FOLDER = Path(os.getenv("DOCAT_DOC_PATH", UPLOAD_FOLDER)) |
25 | 37 |
|
26 | 38 |
|
27 |
| -@app.route("/api/<project>/<version>", methods=["POST"]) |
28 |
| -def upload(project, version): |
29 |
| - if "file" not in request.files: |
30 |
| - return {"message": "No file part in the request"}, HTTPStatus.BAD_REQUEST |
| 39 | +def get_db(): |
| 40 | + """Return the cached TinyDB instance.""" |
| 41 | + return db |
31 | 42 |
|
32 |
| - uploaded_file = request.files["file"] |
33 |
| - if uploaded_file.filename == "": |
34 |
| - return {"message": "No file selected for uploading"}, HTTPStatus.BAD_REQUEST |
35 | 43 |
|
36 |
| - project_base_path = app.config["UPLOAD_FOLDER"] / project |
| 44 | +@dataclass |
| 45 | +class TokenStatus: |
| 46 | + valid: bool |
| 47 | + reason: Optional[str] = None |
| 48 | + |
| 49 | + |
| 50 | +class ApiResponse(BaseModel): |
| 51 | + message: str |
| 52 | + |
| 53 | + |
| 54 | +class ClaimResponse(ApiResponse): |
| 55 | + token: str |
| 56 | + |
| 57 | + |
| 58 | +@app.post("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED) |
| 59 | +def upload( |
| 60 | + project: str, |
| 61 | + version: str, |
| 62 | + response: Response, |
| 63 | + file: UploadFile = File(...), |
| 64 | + docat_api_key: Optional[str] = Header(None), |
| 65 | + db: TinyDB = Depends(get_db), |
| 66 | +): |
| 67 | + project_base_path = DOCAT_UPLOAD_FOLDER / project |
37 | 68 | base_path = project_base_path / version
|
38 |
| - target_file = base_path / secure_filename(uploaded_file.filename) |
| 69 | + target_file = base_path / file.filename |
39 | 70 |
|
40 | 71 | if base_path.exists():
|
41 |
| - token = request.headers.get("Docat-Api-Key") |
42 |
| - result = check_token_for_project(token, project) |
43 |
| - if result is True: |
| 72 | + token_status = check_token_for_project(db, docat_api_key, project) |
| 73 | + if token_status.valid: |
44 | 74 | remove_docs(project, version)
|
45 | 75 | else:
|
46 |
| - return result |
| 76 | + response.status_code = status.HTTP_401_UNAUTHORIZED |
| 77 | + return ApiResponse(message=token_status.reason) |
47 | 78 |
|
48 | 79 | # ensure directory for the uploaded doc exists
|
49 | 80 | base_path.mkdir(parents=True, exist_ok=True)
|
50 | 81 |
|
51 | 82 | # save the uploaded documentation
|
52 |
| - uploaded_file.save(str(target_file)) |
53 |
| - extract_archive(target_file, base_path) |
| 83 | + file.file.seek(0) |
| 84 | + with target_file.open("wb") as buffer: |
| 85 | + shutil.copyfileobj(file.file, buffer) |
54 | 86 |
|
| 87 | + extract_archive(target_file, base_path) |
55 | 88 | create_nginx_config(project, project_base_path)
|
56 |
| - |
57 |
| - return {"message": "File successfully uploaded"}, HTTPStatus.CREATED |
| 89 | + return ApiResponse(message="File successfully uploaded") |
58 | 90 |
|
59 | 91 |
|
60 |
| -@app.route("/api/<project>/<version>/tags/<new_tag>", methods=["PUT"]) |
61 |
| -def tag(project, version, new_tag): |
62 |
| - source = version |
63 |
| - destination = app.config["UPLOAD_FOLDER"] / project / new_tag |
| 92 | +@app.put("/api/{project}/{version}/tags/{new_tag}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED) |
| 93 | +def tag(project: str, version: str, new_tag: str, response: Response): |
| 94 | + destination = DOCAT_UPLOAD_FOLDER / project / new_tag |
64 | 95 |
|
65 |
| - if create_symlink(source, destination): |
66 |
| - return ( |
67 |
| - {"message": f"Tag {new_tag} -> {version} successfully created"}, |
68 |
| - HTTPStatus.CREATED, |
69 |
| - ) |
| 96 | + if create_symlink(version, destination): |
| 97 | + return ApiResponse(message=f"Tag {new_tag} -> {version} successfully created") |
70 | 98 | else:
|
71 |
| - return ( |
72 |
| - {"message": f"Tag {new_tag} would overwrite an existing version!"}, |
73 |
| - HTTPStatus.CONFLICT, |
74 |
| - ) |
| 99 | + response.status_code = status.HTTP_409_CONFLICT |
| 100 | + return ApiResponse(message=f"Tag {new_tag} would overwrite an existing version!") |
75 | 101 |
|
76 | 102 |
|
77 |
| -@app.route("/api/<project>/claim", methods=["GET"]) |
78 |
| -def claim(project): |
| 103 | +@app.get( |
| 104 | + "/api/{project}/claim", |
| 105 | + response_model=ClaimResponse, |
| 106 | + status_code=status.HTTP_201_CREATED, |
| 107 | + responses={status.HTTP_409_CONFLICT: {"model": ApiResponse}}, |
| 108 | +) |
| 109 | +def claim(project: str, db: TinyDB = Depends(get_db)): |
79 | 110 | Project = Query()
|
80 |
| - table = app.db.table("claims") |
| 111 | + table = db.table("claims") |
81 | 112 | result = table.search(Project.name == project)
|
82 | 113 | if result:
|
83 |
| - return ( |
84 |
| - {"message": f"Project {project} is already claimed!"}, |
85 |
| - HTTPStatus.CONFLICT, |
86 |
| - ) |
| 114 | + return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"message": f"Project {project} is already claimed!"}) |
87 | 115 |
|
88 | 116 | token = secrets.token_hex(16)
|
89 | 117 | salt = os.urandom(32)
|
90 | 118 | token_hash = calculate_token(token, salt)
|
91 | 119 | table.insert({"name": project, "token": token_hash, "salt": salt.hex()})
|
92 | 120 |
|
93 |
| - return {"message": f"Project {project} successfully claimed", "token": token}, HTTPStatus.CREATED |
| 121 | + return ClaimResponse(message=f"Project {project} successfully claimed", token=token) |
94 | 122 |
|
95 | 123 |
|
96 |
| -@app.route("/api/<project>/<version>", methods=["DELETE"]) |
97 |
| -def delete(project, version): |
98 |
| - token = request.headers.get("Docat-Api-Key") |
99 |
| - |
100 |
| - result = check_token_for_project(token, project) |
101 |
| - if result is True: |
| 124 | +@app.delete("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_200_OK) |
| 125 | +def delete(project: str, version: str, response: Response, docat_api_key: str = Header(None), db: TinyDB = Depends(get_db)): |
| 126 | + token_status = check_token_for_project(db, docat_api_key, project) |
| 127 | + if token_status.valid: |
102 | 128 | message = remove_docs(project, version)
|
103 | 129 | if message:
|
104 |
| - return ({"message": message}, HTTPStatus.NOT_FOUND) |
| 130 | + response.status_code = status.HTTP_404_NOT_FOUND |
| 131 | + return ApiResponse(message=message) |
105 | 132 | else:
|
106 |
| - return ( |
107 |
| - {"message": f"Successfully deleted version '{version}'"}, |
108 |
| - HTTPStatus.OK, |
109 |
| - ) |
| 133 | + return ApiResponse(message=f"Successfully deleted version '{version}'") |
110 | 134 | else:
|
111 |
| - return result |
| 135 | + response.status_code = status.HTTP_401_UNAUTHORIZED |
| 136 | + return ApiResponse(message=token_status.reason) |
112 | 137 |
|
113 | 138 |
|
114 |
| -def check_token_for_project(token, project): |
| 139 | +def check_token_for_project(db, token, project) -> TokenStatus: |
115 | 140 | Project = Query()
|
116 |
| - table = app.db.table("claims") |
| 141 | + table = db.table("claims") |
117 | 142 | result = table.search(Project.name == project)
|
118 | 143 |
|
119 | 144 | if result and token:
|
120 | 145 | token_hash = calculate_token(token, bytes.fromhex(result[0]["salt"]))
|
121 | 146 | if result[0]["token"] == token_hash:
|
122 |
| - return True |
| 147 | + return TokenStatus(True) |
123 | 148 | else:
|
124 |
| - return ({"message": f"Docat-Api-Key token is not valid for {project}"}, HTTPStatus.UNAUTHORIZED) |
| 149 | + return TokenStatus(False, f"Docat-Api-Key token is not valid for {project}") |
125 | 150 | else:
|
126 |
| - return ({"message": f"Please provide a header with a valid Docat-Api-Key token for {project}"}, HTTPStatus.UNAUTHORIZED) |
| 151 | + return TokenStatus(False, f"Please provide a header with a valid Docat-Api-Key token for {project}") |
127 | 152 |
|
128 | 153 |
|
129 | 154 | # serve_local_docs for local testing without a nginx
|
130 | 155 | if os.environ.get("DOCAT_SERVE_FILES"):
|
131 |
| - |
132 |
| - @app.route("/doc/<path:path>") |
133 |
| - def serve_local_docs(path): |
134 |
| - return send_from_directory(app.config["UPLOAD_FOLDER"], path) |
| 156 | + app.mount("/doc", StaticFiles(directory=DOCAT_UPLOAD_FOLDER), name="docs") |
0 commit comments