Skip to content

Commit

Permalink
feat(backend): Allow project auto-claim with pre-defined token
Browse files Browse the repository at this point in the history
By declaring the environment variable(s) `DOCAT_GLOBAL_CLAIM_TOKEN` (and
optionally `DOCAT_GLOBAL_CLAIM_SALT`), all projects can be
automatically claimed with a previously defined token (and salt).

This resolves docat-org#618.
  • Loading branch information
g3n35i5 committed Jan 16, 2024
1 parent 9c5e20b commit abf8499
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 20 deletions.
7 changes: 6 additions & 1 deletion doc/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ Using `docatl`:
docatl claim awesome-project --host http://localhost:8000
```

In addition, it is possible to claim all projects with a token previously defined via environment variables
instead of generating a new token for each project. This is particularly useful for CI/CD applications where the
documentation is automatically uploaded by a build server. This feature needs to be enabled in the backend. You can
read more about this feature in the [backend documentation](../docat/README.md).

#### Authentication

To make an authenticated call, specify a header with the key `Docat-Api-Key` and your token as the value:
Expand Down Expand Up @@ -170,4 +175,4 @@ Using `docatl`:

```sh
docatl show awesome-project 0.0.1 --host http://localhost:8000 --api-key <token>
```
```
2 changes: 2 additions & 0 deletions docat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ poetry install
* **DOCAT_SERVE_FILES**: Serve static documentation instead of a nginx (for testing)
* **DOCAT_STORAGE_PATH**: Upload directory for static files (needs to match nginx config)
* **FLASK_DEBUG**: Start flask in debug mode
* **DOCAT_GLOBAL_CLAIM_TOKEN**: If set, all uploaded projects are being claimed automatically with that token.
* **DOCAT_GLOBAL_CLAIM_SALT**: If set, all claims will be made with this salt.

## Usage

Expand Down
49 changes: 30 additions & 19 deletions docat/docat/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,23 @@
:license: MIT, see LICENSE for more details.
"""
import os
import secrets
import shutil
from pathlib import Path
from typing import Optional
from typing import Optional, Union

import magic
from fastapi import Depends, FastAPI, File, Header, Response, UploadFile, status
from fastapi.staticfiles import StaticFiles
from starlette.responses import JSONResponse
from tinydb import Query, TinyDB

from docat.constants import get_global_claim_token
from docat.models import ApiResponse, ClaimResponse, ProjectDetail, Projects, TokenStatus
from docat.utils import (
DB_PATH,
UPLOAD_FOLDER,
calculate_token,
claim_project,
create_symlink,
extract_archive,
get_all_projects,
Expand Down Expand Up @@ -197,15 +198,16 @@ def show_version(
return ApiResponse(message=f"Version {version} is now shown")


@app.post("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
@app.post("/api/{project}/{version}/", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
@app.post("/api/{project}/{version}", response_model=Union[ApiResponse, ClaimResponse], status_code=status.HTTP_201_CREATED)
@app.post("/api/{project}/{version}/", response_model=Union[ApiResponse, ClaimResponse], status_code=status.HTTP_201_CREATED)
def upload(
project: str,
version: str,
response: Response,
file: UploadFile = File(...),
docat_api_key: Optional[str] = Header(None),
db: TinyDB = Depends(get_db),
global_claim_token: bool = Depends(get_global_claim_token),
):
if is_forbidden_project_name(project):
response.status_code = status.HTTP_400_BAD_REQUEST
Expand Down Expand Up @@ -241,11 +243,24 @@ def upload(
shutil.copyfileobj(file.file, buffer)

extract_archive(target_file, base_path)
index_file_exists = (base_path / "index.html").exists()

if not (base_path / "index.html").exists():
return ApiResponse(message="Documentation uploaded successfully, but no index.html found at root of archive.")
token: Optional[str] = None
if global_claim_token is not None:
token = claim_project(project=project, db=db)
if index_file_exists:
message = "Documentation uploaded and claimed successfully"
else:
message = "Documentation uploaded and claimed successfully, but no index.html found at root of archive."
else:
if index_file_exists:
message = "Documentation uploaded successfully"
else:
message = "Documentation uploaded successfully, but no index.html found at root of archive."

return ApiResponse(message="Documentation uploaded successfully")
if token:
return ClaimResponse(message=message, token=token)
return ApiResponse(message=message)


@app.put("/api/{project}/{version}/tags/{new_tag}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
Expand Down Expand Up @@ -278,18 +293,14 @@ def tag(project: str, version: str, new_tag: str, response: Response):
responses={status.HTTP_409_CONFLICT: {"model": ApiResponse}},
)
def claim(project: str, db: TinyDB = Depends(get_db)):
Project = Query()
table = db.table("claims")
result = table.search(Project.name == project)
if result:
return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"message": f"Project {project} is already claimed!"})

token = secrets.token_hex(16)
salt = os.urandom(32)
token_hash = calculate_token(token, salt)
table.insert({"name": project, "token": token_hash, "salt": salt.hex()})

return ClaimResponse(message=f"Project {project} successfully claimed", token=token)
try:
token = claim_project(project=project, db=db)
return ClaimResponse(message=f"Project {project} successfully claimed", token=token)
except PermissionError as error:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"message": str(error)},
)


@app.put("/api/{project}/rename/{new_project_name}", response_model=ApiResponse, status_code=status.HTTP_200_OK)
Expand Down
26 changes: 26 additions & 0 deletions docat/docat/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import os
from typing import Optional

ENV_GLOBAL_CLAIM_TOKEN = "DOCAT_GLOBAL_CLAIM_TOKEN"
ENV_GLOBAL_CLAIM_SALT = "DOCAT_GLOBAL_CLAIM_SALT"


def get_global_claim_token() -> Optional[str]:
"""Returns the global claim token which can be defined by an environment variable.
Returns:
The optional global claim token or None.
"""
return os.environ.get(ENV_GLOBAL_CLAIM_TOKEN, None)


def get_global_claim_salt() -> Optional[bytes]:
"""Returns the global claim salt which can be defined by an environment variable.
Returns:
The optional global claim salt or None.
"""
global_claim_salt = os.environ.get(ENV_GLOBAL_CLAIM_SALT, None)
if global_claim_salt is not None:
return global_claim_salt.encode()
return None
33 changes: 33 additions & 0 deletions docat/docat/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
"""
import hashlib
import os
import secrets
import shutil
from pathlib import Path
from zipfile import ZipFile

from tinydb import Query, TinyDB

from docat.constants import get_global_claim_salt, get_global_claim_token
from docat.models import Project, ProjectDetail, Projects, ProjectVersion

NGINX_CONFIG_PATH = Path("/etc/nginx/locations.d")
Expand Down Expand Up @@ -162,3 +166,32 @@ def should_include(name: str) -> bool:
reverse=True,
),
)


def claim_project(project: str, db: TinyDB) -> str:
"""Claims a project.
Args:
project: The project name.
db: The database to use.
Raises:
PermissionError: If the project has already been claimed.
Returns:
The claim token.
"""
table = db.table("claims")

# Check if the project has already been claimed
if table.search(Query().name == project):
raise PermissionError(f"Project {project} is already claimed!")

# Check if the global claim token/salt is configured. Otherwise, use randomly generated values.
token = get_global_claim_token() or secrets.token_hex(16)
salt = get_global_claim_salt() or os.urandom(32)

token_hash = calculate_token(token, salt)
table.insert({"name": project, "token": token_hash, "salt": salt.hex()})

return token
18 changes: 18 additions & 0 deletions docat/tests/test_upload.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import io
import os
from pathlib import Path
from unittest.mock import call, patch

import docat.app as docat
from docat.app import check_token_for_project
from docat.constants import ENV_GLOBAL_CLAIM_SALT, ENV_GLOBAL_CLAIM_TOKEN


def test_successfully_upload(client):
Expand All @@ -15,6 +18,21 @@ def test_successfully_upload(client):
assert (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "index.html").exists()


@patch.dict(os.environ, {ENV_GLOBAL_CLAIM_SALT: "test-salt", ENV_GLOBAL_CLAIM_TOKEN: "test-token"})
def test_successfully_upload_with_global_claim(client):
with patch("docat.app.remove_docs"):
response = client.post("/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"<h1>Hello World</h1>"), "plain/text")})
response_data = response.json()

assert response.status_code == 201
assert response_data["message"] == "Documentation uploaded and claimed successfully"
assert "test-token" == response_data["token"]
assert (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "index.html").exists()

status = check_token_for_project(db=docat.db, token="test-token", project="some-project")
assert True is status.valid


def test_successfully_override(client_with_claimed_project):
with patch("docat.app.remove_docs") as remove_mock:
response = client_with_claimed_project.post(
Expand Down

0 comments on commit abf8499

Please sign in to comment.