Repository: randombenj/docat
Branch: main
Commit: 9b58a9553c0f
Files: 91
Total size: 221.8 KB
Directory structure:
gitextract_7bq8lh13/
├── .dockerignore
├── .github/
│ └── workflows/
│ └── docat.yml
├── .prettierrc
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── doc/
│ └── getting-started.md
├── docat/
│ ├── .gitignore
│ ├── Makefile
│ ├── README.md
│ ├── docat/
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ ├── app.py
│ │ ├── models.py
│ │ ├── nginx/
│ │ │ └── default
│ │ └── utils.py
│ ├── pyproject.toml
│ └── tests/
│ ├── __init__.py
│ ├── conftest.py
│ ├── test_claim.py
│ ├── test_delete.py
│ ├── test_hide_show.py
│ ├── test_project.py
│ ├── test_rename.py
│ ├── test_stats.py
│ ├── test_upload.py
│ ├── test_upload_icon.py
│ └── test_utils.py
└── web/
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── README.md
├── eslint.config.js
├── index.html
├── package.json
├── src/
│ ├── App.tsx
│ ├── components/
│ │ ├── DataSelect.tsx
│ │ ├── DocumentControlButtons.tsx
│ │ ├── FavoriteStar.tsx
│ │ ├── FileInput.tsx
│ │ ├── Footer.tsx
│ │ ├── Header.tsx
│ │ ├── IFrame.tsx
│ │ ├── InfoBanner.tsx
│ │ ├── NavigationTitle.tsx
│ │ ├── PageLayout.tsx
│ │ ├── Project.tsx
│ │ ├── ProjectList.tsx
│ │ ├── SearchBar.tsx
│ │ └── StyledForm.tsx
│ ├── data-providers/
│ │ ├── ConfigDataProvider.tsx
│ │ ├── MessageBannerProvider.tsx
│ │ ├── ProjectDataProvider.tsx
│ │ ├── RandomId.tsx
│ │ ├── SearchProvider.tsx
│ │ └── StatsDataProvider.tsx
│ ├── index.css
│ ├── index.tsx
│ ├── models/
│ │ ├── ProjectDetails.ts
│ │ └── ProjectsResponse.ts
│ ├── pages/
│ │ ├── Claim.tsx
│ │ ├── Delete.tsx
│ │ ├── Docs.tsx
│ │ ├── Help.tsx
│ │ ├── Home.tsx
│ │ ├── LoadingPage.tsx
│ │ ├── NotFound.tsx
│ │ └── Upload.tsx
│ ├── react-app-env.d.ts
│ ├── repositories/
│ │ └── ProjectRepository.ts
│ ├── style/
│ │ ├── components/
│ │ │ ├── ControlButtons.module.css
│ │ │ ├── DocumentControlButtons.module.css
│ │ │ ├── FileInput.module.css
│ │ │ ├── Footer.module.css
│ │ │ ├── Header.module.css
│ │ │ ├── IFrame.module.css
│ │ │ ├── NavigationTitle.module.css
│ │ │ ├── PageLayout.module.css
│ │ │ ├── Project.module.css
│ │ │ ├── ProjectList.module.css
│ │ │ ├── SearchBar.module.css
│ │ │ └── StyledForm.module.css
│ │ └── pages/
│ │ ├── Help.module.css
│ │ ├── Home.module.css
│ │ ├── NotFound.module.css
│ │ └── Upload.module.css
│ └── tests/
│ └── repositories/
│ └── ProjectRepository.test.ts
├── tsconfig.json
├── vite-env.d.ts
└── vite.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
.git
*.pyc
docat/env
docat/__pycache__
docat/upload
docat/.tox
docat/tests
web/node_modules
web/build
web/.env*
================================================
FILE: .github/workflows/docat.yml
================================================
name: docat ci
on: [push, pull_request]
jobs:
python-test:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.14"]
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
- name: install dependencies
working-directory: docat
run: uv sync --locked --all-extras --dev
- name: run backend linter
working-directory: docat
run: |
uv run ruff check
uv run ruff format --check
- name: run backend static code analysis
working-directory: docat
run: |
uv run mypy .
- name: run backend tests
working-directory: docat
run: |
uv run pytest
javascript-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: '24'
- name: install JavaScript dependencies
working-directory: web
run: yarn install
- name: building frontend
working-directory: web
run: yarn build
- name: run linter against code
working-directory: web
run: yarn lint
- name: run test suite
working-directory: web
run: yarn test
container-image:
runs-on: ubuntu-latest
needs: [python-test, javascript-test]
strategy:
max-parallel: 2
matrix:
registry:
- name: ghcr.io
org: ${{ github.repository_owner }}
token: GITHUB_TOKEN
- name: docker.io
org: randombenj
token: DOCKERHUB
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build Image
run: |
docker build . --build-arg DOCAT_VERSION=$(git describe --tags --always) --tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }}
docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:unstable
- name: tag latest and version on release
run: |
docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:$(git describe --tags)
docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:latest
if: startsWith(github.event.ref, 'refs/tags')
- name: Registry Login
uses: docker/login-action@v3
with:
registry: ${{ matrix.registry.name }}
username: ${{ matrix.registry.org }}
password: ${{ secrets[matrix.registry.token] }}
# Note(Fliiiix): Only login and push on main repo where the secrets are available
if: "!(github.event.pull_request.head.repo.fork || github.actor == 'dependabot[bot]')"
- name: Publish Image
run: |
docker push --all-tags ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat
if: "!(github.event.pull_request.head.repo.fork || github.actor == 'dependabot[bot]')"
================================================
FILE: .prettierrc
================================================
{
"semi": false,
"singleQuote": true,
"trailingComma": "none"
}
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to docat
Thanks for contributing to docat!
In order to keep the quality of the source-code high,
please follow those rules when submitting a change.
If you just want to fix a bug or make a small improvement
feel free to just send a pull request.
Please first discuss any big new features you wish to make via issue, email,
or any other method with the owners of this repository before making a change.
## Pull Request Process
Commits should be the following format
```
type(scope): commit title
commit body (if any)
this should document api breaks
fixes # (if any)
```
Type could be one of *feat, docs, fix, ...* and scope could be *docat, web, ...*
you don't have to provide a scope when the change is for the whole repository like README updates.
Execute linters by running `make lint` in the back-end or `yarn lint`.
A pull request will only be merged when the pipeline runs through.
================================================
FILE: Dockerfile
================================================
# building frontend
FROM node:24-slim AS frontend
WORKDIR /app/frontend
COPY web/package.json web/yarn.lock ./
RUN yarn install --frozen-lockfile
# fix docker not following symlinks
COPY web ./
COPY doc/getting-started.md ./src/assets/
ARG DOCAT_VERSION=unknown
ENV VITE_DOCAT_VERSION=$DOCAT_VERSION
RUN yarn build
# setup Python
FROM python:3.14-slim AS backend
# configure docker container
ENV PYTHONDONTWRITEBYTECODE=1
COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /uvx /bin/
COPY /docat/pyproject.toml /docat/uv.lock /app/
# Install the application
WORKDIR /app/docat
RUN uv sync --no-install-project --no-dev --color never
# production
FROM python:3.14-slim
# defaults
ENV MAX_UPLOAD_SIZE=100M
# set up the system
RUN apt-get update && \
apt-get install --yes nginx dumb-init libmagic1 gettext && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /var/docat/doc
# install the application
RUN mkdir -p /var/www/html
COPY --from=frontend /app/frontend/dist /var/www/html
COPY docat /app/docat
WORKDIR /app/docat
# Copy the build artifact (.venv)
COPY --from=backend /app /app/docat
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["sh", "-c", "envsubst '$MAX_UPLOAD_SIZE' < /app/docat/docat/nginx/default > /etc/nginx/sites-enabled/default && nginx && .venv/bin/python -m uvicorn --host 0.0.0.0 --port 5000 docat.app:app"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2019 https://github.com/docat-org/docat
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================

**Host your docs. Simple. Versioned. Fancy.**
[](https://github.com/docat-org/docat/actions)
[](https://gitter.im/docat-docs-hosting/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
## Why DOCAT?
When generating static documentation using
[mkdocs](https://www.mkdocs.org/), [sphinx](http://www.sphinx-doc.org/en/master/), ...
hosting just one version of the docs might not be enough.
Many users might still use older versions and might need to read
those versions of the documentation.
Docat solves this problem by providing a simple tool that
hosts multiple documentation projects with multiple versions.
*The main design decision with docat was to keep the tool as simple as possible.*
## Getting started
The simplest way to get started is to run the docker container,
you can optionally use volumes to persist state:
```sh
# run container in background and persist data (docs, nginx configs and tokens database)
# use 'ghcr.io/docat-org/docat:unstable' to get the latest changes
mkdir -p docat-run/doc
docker run \
--detach \
--volume $PWD/docat-run:/var/docat/ \
--publish 8000:80 \
ghcr.io/docat-org/docat
```
Go to [localhost:8000](http://localhost:8000) to view your docat instance:
### Using DOCAT
> 🛈 Please note that docat does not provide any way to write documentation.
> It's sole responsibility is to host documentation.
>
> There are many awesome tools to write documenation:
> - [mkdocs](https://www.mkdocs.org/)
> - [sphinx](http://www.sphinx-doc.org/en/master/)
> - [mdbook](https://rust-lang.github.io/mdBook/)
> - ...
A CLI tool called [docatl](https://github.com/docat-org/docatl) is available
for easy interaction with the docat server.
However, interacting with docat can also be done through [`curl`](doc/getting-started.md).
To push documentation (and tag as `latest`) in the folder `docs/` simply run:
```sh
docatl push --host http://localhost:8000 ./docs PROJECT VERSION --tag latest
```
More detailed instructions can be found in the [**getting started guide**](doc/getting-started.md).
## Authentication
By default, anyone can upload new documentation or add a new version to documentation.
A project can be claimed. A claim returns a token that then must be used
to add or delete versions.
When hosting docat publicly, it is recommended to use
[http basic auth](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/)
for all `POST`/`PUT` and `DELETE` http calls.
docat http basic authentication example
This example shows how to configure the NGINX inside the docker image
to be password protected using http basic auth.
1) Create your [`.htpasswd` file](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/#creating-a-password-file).
2) And a custom `default` NGINX config:
```
upstream python_backend {
server 127.0.0.1:5000;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
add_header Content-Security-Policy "frame-ancestors 'self';";
index index.html index.htm index.pdf /index.html;
server_name _;
location /doc {
root /var/docat;
}
location /api {
limit_except GET HEAD {
auth_basic 'Restricted';
auth_basic_user_file /etc/nginx/.htpasswd;
}
client_max_body_size $MAX_UPLOAD_SIZE;
proxy_pass http://python_backend;
}
location / {
try_files $uri $uri/ =404;
}
}
```
1) Mounted to the correct location inside the container:
```
docker run \
--detach \
--volume $PWD/docat-run:/var/docat/ \
--volume $PWD/nginx/default:/app/docat/docat/nginx/default \
--volume $PWD/nginx/.htpasswd:/etc/nginx/.htpasswd \
--publish 8000:80 \
ghcr.io/docat-org/docat
```
## Configuring DOCAT
#### Frontend Config
It is possible to configure some things after the fact.
1. Create a `config.json` file
2. Mount it inside your docker container `--volume $PWD/config.json:/var/docat/doc/config.json`
Supported config options:
```json
{
"headerHTML": "
Custom Header HTML!
",
"footerHTML": "CONTACT: Maintainers"
}
```
#### System Config
Further proxy configurations can be done through the following environmental variables:
| Variable | Default | Description |
|---|---|---|
| `MAX_UPLOAD_SIZE` | [100M](./Dockerfile) | Limits the size of individual archives posted to the API |
## Local Development
For local development, first configure and start the backend (inside the `docat/` folder):
```sh
# create a folder for local development (uploading docs)
DEV_DOCAT_PATH="$(mktemp -d)"
# install dependencies
uv venv .venv
uv sync
# run the local development version
DOCAT_SERVE_FILES=1 DOCAT_STORAGE_PATH="$DEV_DOCAT_PATH" uv run python -m docat
```
After this you need to start the frontend (inside the `web/` folder):
```sh
# install dependencies
yarn install --frozen-lockfile
# run the web app
yarn start
```
For more advanced options, have a look at the
[backend](docat/README.md) and [web](web/README.md) docs.
================================================
FILE: doc/getting-started.md
================================================
## Getting Started with DOCAT
### Using `docatl`, the docat CLI 🙀
The most convenient way to interact with docat is with it's official CLI tool,
[docatl](https://github.com/docat-org/docatl).
You can download a standalone binary of the latest release for your platform
[here](https://github.com/docat-org/docatl/releases/latest) or
[use go](https://github.com/docat-org/docatl#using-go) or
[docker](https://github.com/docat-org/docatl#using-docker) to install it.
The commands below contain examples both using `curl` and `docatl`.
Do note that the host address and api-key can be omitted if specified in a `.docatl.yml` file.
See the [docatl documentation](https://github.com/docat-org/docatl/blob/main/README.md) for more information.
Use `docatl --help` to discover all commands available to manage your `docat` documentation!
### API endpoints
The following sections document the RAW API endpoints you can `curl`.
The API specification is exposed as an [OpenAPI Documentation](/api/v1/openapi.json),
via Swagger UI at [/api/docs](/api/docs) and
as a pure documentation with redoc at [/api/redoc](/api/redoc).
#### Upload your documentation
You can upload any static HTML page by zipping it and uploading the zip file.
> Note: if an `index.html` file is present in the root of the zip file
it will be served automatically.
For example to upload the file `docs.zip` as version `1.0.0` for `awesome-project` using `curl`:
```sh
curl -X POST -F "file=@docs.zip" http://localhost:8000/api/awesome-project/1.0.0
```
Using `docatl`:
```sh
docatl push docs.zip awesome-project 1.0.0 --host http://localhost:8000
```
Any file type can be uploaded. To view an uploaded pdf, specify it's full path:
`http://localhost:8000/awesome-project/1.0.0/my_awesome.pdf`
You can also manually upload your documentation.
A very simple web form can be found under [upload](/upload).
#### Tag documentation
After uploading you can tag a specific version. This can be useful when
the latest version should be available as `http://localhost:8000/docs/awesome-project/latest`
To tag the version `1.0.0` as `latest` for `awesome-project`:
```sh
curl -X PUT http://localhost:8000/api/awesome-project/1.0.0/tags/latest
```
Using `docatl`:
```sh
docatl tag awesome-project 1.0.0 latest --host http://localhost:8000
```
#### Claim Project
Claiming a Project returns a `token` which can be used for actions
which require authentication (for example for deleting a version).
Each Project can be claimed **exactly once**, so best store the token safely.
```sh
curl -X GET http://localhost:8000/api/awesome-project/claim
```
Using `docatl`:
```sh
docatl claim awesome-project --host http://localhost:8000
```
#### Authentication
To make an authenticated call, specify a header with the key `Docat-Api-Key` and your token as the value:
```sh
curl -X DELETE --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/1.0.0
```
Using `docatl`:
```sh
docatl delete awesome-project 1.0.0 --host http://localhost:8000 --api-key
```
#### Delete Version
To delete a Project version you need to be authenticated.
To remove the version `1.0.0` from `awesome-project`:
```sh
curl -X DELETE --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/1.0.0
```
Using `docatl`:
```sh
docatl delete awesome-project 1.0.0 --host http://localhost:8000 --api-key
```
#### Upload Project Icon
To upload a icon, you don't need a token, except if you want to replace an existing icon.
To set `example-image.png` as the icon for `awesome-project`, which already has an icon:
```sh
curl -X POST -F "file=@example-image.png" --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/icon
```
Using `docatl`:
```sh
docatl push-icon awesome-project example-image.png --host http://localhost:8000 --api-key
```
#### Rename a Project
To rename a Project, you need a token.
To rename `awesome-project` to `new-awesome-project`:
```sh
curl -X PUT --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/rename/new-awesome-project
```
Using `docatl`:
```sh
docatl rename awesome-project new-awesome-project --host http://localhost:8000 --api-key
```
#### Hide a Version
If you want to hide a version from the version select as well as the search results,
you can hide it. You need to be authenticated to do this.
To hide version `0.0.1` of `awesome-project`:
```sh
curl -X POST --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/0.0.1/hide
```
Using `docatl`:
```sh
docatl hide awesome-project 0.0.1 --host http://localhost:8000 --api-key
```
#### Show a Version
This is the reverse of `hide`, and also requires a token.
To show version `0.0.1` of `awesome-project` again:
```sh
curl -X POST --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/0.0.1/show
```
Using `docatl`:
```sh
docatl show awesome-project 0.0.1 --host http://localhost:8000 --api-key
```
================================================
FILE: docat/.gitignore
================================================
*.pyc
env
__pycache__
upload
.tox
.coverage
db.json
.python-version
docat.egg-info/
================================================
FILE: docat/Makefile
================================================
.PHONY: all
all: format lint typing pytest
format:
uv run ruff check --fix
uv run ruff format
lint:
uv run ruff check
typing:
uv run mypy .
pytest:
uv run pytest
================================================
FILE: docat/README.md
================================================
# docat backend
The backend hosts the documentation and an api to push documentation and
tag versions of the documentation.
## development enviroment
You will need to install [uv](https://docs.astral.sh/uv/#installation) `curl -LsSf https://astral.sh/uv/install.sh | sh
`.
Install the dependencies and run the application:
```sh
# install dependencies
uv venv .venv
uv sync
# run the app
[DOCAT_SERVE_FILES=1] [DOCAT_STORAGE_PATH=/tmp] [PORT=8888] uv run python -m docat
```
### Config Options
* **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)
* **PORT**: Port for the Python backend (needs to match nginx config for production)
## Usage
See [getting-started.md](../doc/getting-started.md)
================================================
FILE: docat/docat/__init__.py
================================================
================================================
FILE: docat/docat/__main__.py
================================================
import os
import uvicorn
from docat.app import app
if __name__ == "__main__":
try:
port = int(os.environ.get("PORT", "5000"))
except ValueError:
port = 5000
uvicorn.run(app, host="0.0.0.0", port=port)
================================================
FILE: docat/docat/app.py
================================================
"""
docat
~~~~~
Host your docs. Simple. Versioned. Fancy.
:copyright: (c) 2019 by docat, https://github.com/docat-org/docat
:license: MIT, see LICENSE for more details.
"""
import logging
import os
import secrets
import shutil
from contextlib import asynccontextmanager
from pathlib import Path
import magic
from fastapi import APIRouter, Depends, FastAPI, File, Header, Response, UploadFile, status
from fastapi.staticfiles import StaticFiles
from starlette.responses import JSONResponse
from tinydb import Query, TinyDB
from docat.models import ApiResponse, ClaimResponse, ProjectDetail, Projects, Stats, TokenStatus
from docat.utils import (
DB_PATH,
UPLOAD_FOLDER,
calculate_token,
create_symlink,
extract_archive,
get_all_projects,
get_dir_size,
get_project_details,
get_system_stats,
is_forbidden_project_name,
remove_docs,
)
DOCAT_STORAGE_PATH = Path(os.getenv("DOCAT_STORAGE_PATH", Path("/var/docat")))
DOCAT_DB_PATH = DOCAT_STORAGE_PATH / DB_PATH
DOCAT_UPLOAD_FOLDER = DOCAT_STORAGE_PATH / UPLOAD_FOLDER
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI):
# Create the folders if they don't exist
DOCAT_UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
yield
def get_db() -> TinyDB:
"""Return the cached TinyDB instance."""
return TinyDB(DOCAT_DB_PATH)
#: Holds the FastAPI application
app = FastAPI(
title="docat",
description="API for docat, https://github.com/docat-org/docat",
openapi_url="/api/v1/openapi.json",
docs_url="/api/docs",
redoc_url="/api/redoc",
lifespan=lifespan,
)
router = APIRouter()
@router.get("/api/stats", response_model=Stats, status_code=status.HTTP_200_OK)
def get_stats():
if not DOCAT_UPLOAD_FOLDER.exists():
return Projects(projects=[])
return get_system_stats(DOCAT_UPLOAD_FOLDER)
@router.get("/api/projects", response_model=Projects, status_code=status.HTTP_200_OK)
def get_projects(include_hidden: bool = False):
if not DOCAT_UPLOAD_FOLDER.exists():
return Projects(projects=[])
return get_all_projects(DOCAT_UPLOAD_FOLDER, include_hidden)
@router.get(
"/api/projects/{project}",
response_model=ProjectDetail,
status_code=status.HTTP_200_OK,
responses={status.HTTP_404_NOT_FOUND: {"model": ApiResponse}},
)
def get_project(project, include_hidden: bool = False):
details = get_project_details(DOCAT_UPLOAD_FOLDER, project, include_hidden)
if not details:
return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"message": f"Project {project} does not exist"})
return details
@router.post("/api/{project}/icon", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def upload_icon(
project: str,
response: Response,
file: UploadFile = File(...),
docat_api_key: str | None = Header(None),
db: TinyDB = Depends(get_db),
):
project_base_path = DOCAT_UPLOAD_FOLDER / project
icon_path = project_base_path / "logo"
if not project_base_path.exists():
response.status_code = status.HTTP_404_NOT_FOUND
return ApiResponse(message=f"Project {project} not found")
mime_type_checker = magic.Magic(mime=True)
mime_type = mime_type_checker.from_buffer(file.file.read())
if not mime_type.startswith("image/"):
response.status_code = status.HTTP_400_BAD_REQUEST
return ApiResponse(message="Icon must be an image")
# require a token if the project already has an icon
if icon_path.is_file():
token_status = check_token_for_project(db, docat_api_key, project)
if not token_status.valid:
response.status_code = status.HTTP_401_UNAUTHORIZED
return ApiResponse(message=token_status.reason)
# remove the old icon
os.remove(icon_path)
# save the uploaded icon
file.file.seek(0)
with icon_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# force cache revalidation
get_system_stats.cache_clear()
get_dir_size.cache_clear()
return ApiResponse(message="Icon successfully uploaded")
@router.post("/api/{project}/{version}/hide", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def hide_version(
project: str,
version: str,
response: Response,
docat_api_key: str | None = Header(None),
db: TinyDB = Depends(get_db),
):
project_base_path = DOCAT_UPLOAD_FOLDER / project
version_path = project_base_path / version
hidden_file = version_path / ".hidden"
if not project_base_path.exists():
response.status_code = status.HTTP_404_NOT_FOUND
return ApiResponse(message=f"Project {project} not found")
if not version_path.exists():
response.status_code = status.HTTP_404_NOT_FOUND
return ApiResponse(message=f"Version {version} not found")
if hidden_file.exists():
response.status_code = status.HTTP_400_BAD_REQUEST
return ApiResponse(message=f"Version {version} is already hidden")
token_status = check_token_for_project(db, docat_api_key, project)
if not token_status.valid:
response.status_code = status.HTTP_401_UNAUTHORIZED
return ApiResponse(message=token_status.reason)
with open(hidden_file, "w") as f:
f.close()
return ApiResponse(message=f"Version {version} is now hidden")
@router.post("/api/{project}/{version}/show", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def show_version(
project: str,
version: str,
response: Response,
docat_api_key: str | None = Header(None),
db: TinyDB = Depends(get_db),
):
project_base_path = DOCAT_UPLOAD_FOLDER / project
version_path = project_base_path / version
hidden_file = version_path / ".hidden"
if not project_base_path.exists():
response.status_code = status.HTTP_404_NOT_FOUND
return ApiResponse(message=f"Project {project} not found")
if not version_path.exists():
response.status_code = status.HTTP_404_NOT_FOUND
return ApiResponse(message=f"Version {version} not found")
if not hidden_file.exists():
response.status_code = status.HTTP_400_BAD_REQUEST
return ApiResponse(message=f"Version {version} is not hidden")
token_status = check_token_for_project(db, docat_api_key, project)
if not token_status.valid:
response.status_code = status.HTTP_401_UNAUTHORIZED
return ApiResponse(message=token_status.reason)
os.remove(hidden_file)
return ApiResponse(message=f"Version {version} is now shown")
@router.post("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
def upload(
project: str,
version: str,
response: Response,
file: UploadFile = File(...),
docat_api_key: str | None = Header(None),
db: TinyDB = Depends(get_db),
):
if is_forbidden_project_name(project):
response.status_code = status.HTTP_400_BAD_REQUEST
return ApiResponse(message=f'Project name "{project}" is forbidden, as it conflicts with pages in docat web.')
if file.filename is None:
response.status_code = status.HTTP_400_BAD_REQUEST
return ApiResponse(message="Uploaded file is None aborting upload.")
project_base_path = DOCAT_UPLOAD_FOLDER / project
base_path = project_base_path / version
target_file = base_path / str(file.filename)
if base_path.is_symlink():
# disallow overwriting of tags (symlinks) with new uploads
response.status_code = status.HTTP_409_CONFLICT
return ApiResponse(message="Cannot overwrite existing tag with new version.")
if base_path.exists():
token_status = check_token_for_project(db, docat_api_key, project)
if not token_status.valid:
response.status_code = status.HTTP_401_UNAUTHORIZED
return ApiResponse(message=token_status.reason)
remove_docs(project, version, DOCAT_UPLOAD_FOLDER)
# ensure directory for the uploaded doc exists
base_path.mkdir(parents=True, exist_ok=True)
# save the uploaded documentation
file.file.seek(0)
with target_file.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
try:
extract_archive(target_file, base_path)
except Exception:
logger.exception("Failed to unzip {target_file=}")
response.status_code = status.HTTP_400_BAD_REQUEST
return ApiResponse(message="Cannot extract zip file.")
# force cache revalidation
get_system_stats.cache_clear()
get_dir_size.cache_clear()
if not (base_path / "index.html").exists():
return ApiResponse(message="Documentation uploaded successfully, but no index.html found at root of archive.")
return ApiResponse(message="Documentation uploaded successfully")
@router.put("/api/{project}/{version}/tags/{new_tag}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)
def tag(project: str, version: str, new_tag: str, response: Response):
destination = DOCAT_UPLOAD_FOLDER / project / new_tag
source = DOCAT_UPLOAD_FOLDER / project / version
if not source.exists():
response.status_code = status.HTTP_404_NOT_FOUND
return ApiResponse(message=f"Version {version} not found")
if not create_symlink(version, destination):
response.status_code = status.HTTP_409_CONFLICT
return ApiResponse(message=f"Tag {new_tag} would overwrite an existing version!")
return ApiResponse(message=f"Tag {new_tag} -> {version} successfully created")
@router.get(
"/api/{project}/claim",
response_model=ClaimResponse,
status_code=status.HTTP_201_CREATED,
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)
@router.put("/api/{project}/rename/{new_project_name}", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def rename(
project: str,
new_project_name: str,
response: Response,
docat_api_key: str = Header(None),
db: TinyDB = Depends(get_db),
):
if is_forbidden_project_name(new_project_name):
response.status_code = status.HTTP_400_BAD_REQUEST
return ApiResponse(message=f'New project name "{new_project_name}" is forbidden, as it conflicts with pages in docat web.')
project_base_path = DOCAT_UPLOAD_FOLDER / project
new_project_base_path = DOCAT_UPLOAD_FOLDER / new_project_name
if not project_base_path.exists():
response.status_code = status.HTTP_404_NOT_FOUND
return ApiResponse(message=f"Project {project} not found")
if new_project_base_path.exists():
response.status_code = status.HTTP_409_CONFLICT
return ApiResponse(message=f"New project name {new_project_name} already in use")
token_status = check_token_for_project(db, docat_api_key, project)
if not token_status.valid:
response.status_code = status.HTTP_401_UNAUTHORIZED
return ApiResponse(message=token_status.reason)
# update the claim to the new project name
Project = Query()
claims_table = db.table("claims")
claims_table.update({"name": new_project_name}, Project.name == project)
os.rename(project_base_path, new_project_base_path)
response.status_code = status.HTTP_200_OK
return ApiResponse(message=f"Successfully renamed project {project} to {new_project_name}")
@router.delete("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_200_OK)
def delete(
project: str,
version: str,
response: Response,
docat_api_key: str = Header(None),
db: TinyDB = Depends(get_db),
):
token_status = check_token_for_project(db, docat_api_key, project)
if not token_status.valid:
response.status_code = status.HTTP_401_UNAUTHORIZED
return ApiResponse(message=token_status.reason)
message = remove_docs(project, version, DOCAT_UPLOAD_FOLDER)
if message:
response.status_code = status.HTTP_404_NOT_FOUND
return ApiResponse(message=message)
# force cache revalidation
get_system_stats.cache_clear()
get_dir_size.cache_clear()
return ApiResponse(message=f"Successfully deleted version '{version}'")
def check_token_for_project(db, token, project) -> TokenStatus:
Project = Query()
table = db.table("claims")
result = table.search(Project.name == project)
if result and token:
token_hash = calculate_token(token, bytes.fromhex(result[0]["salt"]))
if result[0]["token"] == token_hash:
return TokenStatus(True, "Docat-Api-Key token is valid")
else:
return TokenStatus(False, f"Docat-Api-Key token is not valid for {project}")
else:
return TokenStatus(False, f"Please provide a header with a valid Docat-Api-Key token for {project}")
# serve_local_docs for local testing without a nginx
if os.environ.get("DOCAT_SERVE_FILES"):
DOCAT_UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
app.mount("/doc", StaticFiles(directory=DOCAT_UPLOAD_FOLDER, html=True), name="docs")
app.include_router(router)
================================================
FILE: docat/docat/models.py
================================================
from dataclasses import dataclass
from datetime import datetime
from pydantic import BaseModel
@dataclass(frozen=True)
class TokenStatus:
valid: bool
reason: str
class ApiResponse(BaseModel):
message: str
class ClaimResponse(ApiResponse):
token: str
class ProjectVersion(BaseModel):
name: str
timestamp: datetime
tags: list[str]
hidden: bool
class Project(BaseModel):
name: str
logo: bool
storage: str
versions: list[ProjectVersion]
class Projects(BaseModel):
projects: list[Project]
class Stats(BaseModel):
n_projects: int
n_versions: int
storage: str
class ProjectDetail(BaseModel):
name: str
storage: str
versions: list[ProjectVersion]
================================================
FILE: docat/docat/nginx/default
================================================
upstream python_backend {
server 127.0.0.1:5000;
}
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/html;
add_header Content-Security-Policy "frame-ancestors 'self';";
index index.html index.htm index.pdf /index.html;
server_name _;
location /doc {
root /var/docat;
absolute_redirect off;
}
location /api {
client_max_body_size $MAX_UPLOAD_SIZE;
proxy_pass http://python_backend;
}
location / {
try_files $uri $uri/ /index.html =404;
}
}
================================================
FILE: docat/docat/utils.py
================================================
"""
docat utilities
"""
import hashlib
import os
import shutil
from datetime import datetime
from functools import cache
from pathlib import Path
from zipfile import ZipFile, ZipInfo
from docat.models import Project, ProjectDetail, Projects, ProjectVersion, Stats
NGINX_CONFIG_PATH = Path("/etc/nginx/locations.d")
UPLOAD_FOLDER = "doc"
DB_PATH = "db.json"
def is_dir(self):
"""Return True if this archive member is a directory."""
if self.filename.endswith("/"):
return True
# The ZIP format specification requires to use forward slashes
# as the directory separator, but in practice some ZIP files
# created on Windows can use backward slashes. For compatibility
# with the extraction code which already handles this:
if os.path.altsep:
return self.filename.endswith((os.path.sep, os.path.altsep))
return False
# Patch is_dir to allow windows zip files to be
# extracted correctly
# see: https://github.com/python/cpython/issues/117084
ZipInfo.is_dir = is_dir # type: ignore[method-assign]
def create_symlink(source, destination):
"""
Create a symlink from source to destination, if the
destination is already a symlink, it will be overwritten.
Args:
source (pathlib.Path): path to the source
destination (pathlib.Path): path to the destination
"""
if not destination.exists() or (destination.exists() and destination.is_symlink()):
if destination.is_symlink():
destination.unlink() # overwrite existing tag
destination.symlink_to(source)
return True
else:
return False
def extract_archive(target_file, destination):
"""
Extracts the given archive to the directory
and deletes the source afterwards.
Args:
target_file (pathlib.Path): target archive
destination: (pathlib.Path): destination of the extracted archive
"""
if target_file.suffix == ".zip":
# this is required to extract zip files created
# on windows machines (https://stackoverflow.com/a/52091659/12356463)
os.path.altsep = "\\"
with ZipFile(target_file, "r") as zipf:
zipf.extractall(path=destination)
target_file.unlink() # remove the zip file
def remove_docs(project: str, version: str, upload_folder_path: Path):
"""
Delete documentation
Args:
project (str): name of the project
version (str): project version
"""
docs = upload_folder_path / project / version
if docs.exists():
# remove the requested version
# rmtree can not remove a symlink
if docs.is_symlink():
docs.unlink()
else:
shutil.rmtree(docs)
# remove dead symlinks
for link in (s for s in docs.parent.iterdir() if s.is_symlink()):
if not link.resolve().exists():
link.unlink()
# remove size info
(upload_folder_path / project / ".size").unlink(missing_ok=True)
# remove empty projects
if not [d for d in docs.parent.iterdir() if d.is_dir()]:
docs.parent.rmdir()
nginx_config = NGINX_CONFIG_PATH / f"{project}-doc.conf"
if nginx_config.exists():
nginx_config.unlink()
else:
return f"Could not find version '{docs}'"
def calculate_token(password, salt):
"""
Wrapper function for pbkdf2_hmac to ensure consistent use of
hash digest algorithm and iteration count.
Args:
password (str): the password to hash
salt (byte): the salt used for the password
"""
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100000).hex()
def is_forbidden_project_name(name: str) -> bool:
"""
Checks if the given project name is forbidden.
The project name is forbidden if it conflicts with
a page on the docat website.
"""
name = name.lower().strip()
return name in ["upload", "claim", "delete", "help", "doc", "api"]
UNITS_MAPPING = [
(1 << 50, " PB"),
(1 << 40, " TB"),
(1 << 30, " GB"),
(1 << 20, " MB"),
(1 << 10, " KB"),
(1, " byte"),
]
def readable_size(bytes: int) -> str:
"""
Get human-readable file sizes.
simplified version of https://pypi.python.org/pypi/hurry.filesize/
https://stackoverflow.com/a/12912296/12356463
"""
size_suffix = ""
for factor, suffix in UNITS_MAPPING:
if bytes >= factor:
size_suffix = suffix
break
amount = int(bytes / factor)
if size_suffix == " byte" and amount > 1:
size_suffix = size_suffix + "s"
if amount == 0:
size_suffix = " bytes"
return str(amount) + size_suffix
@cache
def get_dir_size(path: Path) -> int:
"""
Calculate the total size of a directory.
Results are cached (memoizing) by path.
"""
total = 0
with os.scandir(path) as it:
for entry in it:
if entry.is_symlink():
# skip symlinks
pass
elif entry.is_file():
total += entry.stat().st_size
elif entry.is_dir():
total += get_dir_size(entry.path)
return total
@cache
def get_system_stats(upload_folder_path: Path) -> Stats:
"""
Return all docat statistics.
Results are cached (memoizing) by path.
"""
dirs = 0
versions = 0
size = 0
# Note: Not great nesting with the deep nesting
# but it needs to run fast, consider speed when refactoring!
with os.scandir(upload_folder_path) as root:
for f in root:
if f.is_dir():
dirs += 1
with os.scandir(f.path) as project:
for v in project:
if v.is_dir() and not v.is_symlink():
size += get_dir_size(v.path)
versions += 1
return Stats(
n_projects=dirs,
n_versions=versions,
storage=readable_size(size),
)
def get_all_projects(upload_folder_path: Path, include_hidden: bool) -> Projects:
"""
Returns all projects in the upload folder.
"""
projects: list[Project] = []
for project in sorted(upload_folder_path.iterdir()):
if not project.is_dir():
continue
details = get_project_details(upload_folder_path, project.name, include_hidden)
if details is None:
continue
if len(details.versions) < 1:
continue
project_name = str(project.relative_to(upload_folder_path))
project_has_logo = (upload_folder_path / project / "logo").exists()
projects.append(
Project(
name=project_name,
logo=project_has_logo,
versions=details.versions,
storage=readable_size(get_dir_size(upload_folder_path / project)),
)
)
return Projects(projects=projects)
def get_version_timestamp(version_folder: Path) -> datetime:
"""
Returns the timestamp of a version
"""
return datetime.fromtimestamp(version_folder.stat().st_ctime)
def get_project_details(upload_folder_path: Path, project_name: str, include_hidden: bool) -> ProjectDetail | None:
"""
Returns all versions and tags for a project.
"""
docs_folder = upload_folder_path / project_name
if not docs_folder.exists():
return None
tags = [x for x in docs_folder.iterdir() if x.is_dir() and x.is_symlink()]
def should_include(name: str) -> bool:
if include_hidden:
return True
return not (docs_folder / name / ".hidden").exists()
return ProjectDetail(
name=project_name,
storage=readable_size(get_dir_size(docs_folder)),
versions=sorted(
[
ProjectVersion(
name=str(x.relative_to(docs_folder)),
tags=[str(t.relative_to(docs_folder)) for t in tags if t.resolve() == x],
timestamp=get_version_timestamp(x),
hidden=(docs_folder / x.name / ".hidden").exists(),
)
for x in docs_folder.iterdir()
if x.is_dir() and not x.is_symlink() and should_include(x.name)
],
key=lambda k: k.name,
reverse=True,
),
)
================================================
FILE: docat/pyproject.toml
================================================
[project]
name = "docat"
version = "0.0.0"
description = "Host your docs. Simple. Versioned. Fancy."
authors = [
{ name = "Felix", email = "hi@l33t.name" },
{ name = "Benj", email = "randombenj@gmail.com" }
]
license = { text = "MIT" }
requires-python = ">=3.10"
dependencies = [
"tinydb",
"fastapi[all]",
"uvicorn",
"python-multipart",
"python-magic",
]
[dependency-groups]
dev = [
"ruff",
"pytest",
"pytest-cov",
"requests",
"mypy",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "--ff -ra -v"
testpaths = [
"tests"
]
[[tool.mypy.overrides]]
module = [
"tinydb",
"tinydb.storages",
"uvicorn"
]
ignore_missing_imports = true
[tool.ruff]
line-length = 140
# Rule descriptions: https://docs.astral.sh/ruff/rules/
lint.select = ["I", "E", "B", "F", "W", "N", "C4", "C90", "ARG", "PL", "RUF", "UP"]
# TODO: Should be reduct to no global exceptions
lint.ignore = ["B008", "N806", "PLR0911", "PLR0913"]
[tool.ruff.lint.per-file-ignores]
# Ignore for all tests (Magic value used in comparison)
# We use magic values in tests
"tests/*" = ["PLR2004"]
================================================
FILE: docat/tests/__init__.py
================================================
================================================
FILE: docat/tests/conftest.py
================================================
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from tinydb import TinyDB
import docat.app as docat
from docat.utils import create_symlink
@pytest.fixture(autouse=True)
def setup_docat_paths():
"""
Set up the temporary paths for the docat app.
"""
temp_dir = tempfile.TemporaryDirectory()
docat.DOCAT_STORAGE_PATH = Path(temp_dir.name)
docat.DOCAT_DB_PATH = Path(temp_dir.name) / "db.json"
docat.DOCAT_UPLOAD_FOLDER = Path(temp_dir.name) / "doc"
yield
temp_dir.cleanup()
@pytest.fixture
def client():
docat.db = TinyDB(docat.DOCAT_DB_PATH)
yield TestClient(docat.app)
docat.app.db = None
@pytest.fixture
def client_with_claimed_project(client):
table = docat.db.table("claims")
token_hash_1234 = b"\xe0\x8cS\xa3)\xb4\xb5\xa5\xda\xc3K\x96\xf6).\xdd-\xacR\x8e3Q\x17\x87\xfb\x94\x0c-\xc2h\x1c\xf3"
table.insert({"name": "some-project", "token": token_hash_1234.hex(), "salt": ""})
yield client
@pytest.fixture
def temp_project_version():
def __create(project, version):
version_docs = docat.DOCAT_UPLOAD_FOLDER / project / version
version_docs.mkdir(parents=True)
(version_docs / "index.html").touch()
create_symlink(version_docs, docat.DOCAT_UPLOAD_FOLDER / project / "latest")
return docat.DOCAT_UPLOAD_FOLDER
yield __create
================================================
FILE: docat/tests/test_claim.py
================================================
def test_successfully_claim_token(client):
response = client.get("/api/some-project/claim")
response_data = response.json()
assert response.status_code == 201
assert response_data["message"] == "Project some-project successfully claimed"
assert "token" in response_data
def test_already_claimed(client):
client.get("/api/some-project/claim")
response = client.get("/api/some-project/claim")
response_data = response.json()
assert response.status_code == 409
assert response_data["message"] == "Project some-project is already claimed!"
================================================
FILE: docat/tests/test_delete.py
================================================
from unittest.mock import patch
def test_successfully_delete(client_with_claimed_project):
with patch("docat.app.remove_docs", return_value="remove mock"):
response = client_with_claimed_project.delete("/api/some-project/1.0.0", headers={"Docat-Api-Key": "1234"})
assert b"remove mock" in response.content
def test_no_valid_token_delete(client_with_claimed_project):
with patch("docat.app.remove_docs", return_value="remove mock"):
response = client_with_claimed_project.delete("/api/some-project/1.0.0", headers={"Docat-Api-Key": "abcd"})
response_data = response.json()
assert response.status_code == 401
assert response_data["message"] == "Docat-Api-Key token is not valid for some-project"
def test_no_token_delete(client_with_claimed_project):
with patch("docat.app.remove_docs", return_value="remove mock"):
response = client_with_claimed_project.delete("/api/some-project/1.0.0")
response_data = response.json()
assert response.status_code == 401
assert response_data["message"] == "Please provide a header with a valid Docat-Api-Key token for some-project"
================================================
FILE: docat/tests/test_hide_show.py
================================================
import io
from datetime import datetime
from unittest.mock import patch
import docat.app as docat
@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_hide(_, client_with_claimed_project):
"""
Tests that the version is marked as hidden when getting the details after hiding
"""
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# check detected before hiding
project_details_response = client_with_claimed_project.get("/api/projects/some-project")
assert project_details_response.status_code == 200
assert project_details_response.json() == {
"name": "some-project",
"storage": "20 bytes",
"versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
}
# hide the version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
# check hidden
project_details_response = client_with_claimed_project.get("/api/projects/some-project")
assert project_details_response.status_code == 200
assert project_details_response.json() == {
"name": "some-project",
"storage": "20 bytes",
"versions": [],
}
@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_hide_only_version_not_listed_in_projects(_, client_with_claimed_project):
"""
Test that the project is not listed in the projects endpoint when the only version is hidden
"""
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# check detected before hiding
projects_response = client_with_claimed_project.get("/api/projects")
assert projects_response.status_code == 200
assert projects_response.json() == {
"projects": [
{
"name": "some-project",
"logo": False,
"storage": "20 bytes",
"versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
}
],
}
# hide the only version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
# check hidden
projects_response = client_with_claimed_project.get("/api/projects")
assert projects_response.status_code == 200
assert projects_response.json() == {
"projects": [],
}
# check versions hidden
project_details_response = client_with_claimed_project.get("/api/projects/some-project")
assert project_details_response.status_code == 200
assert project_details_response.json() == {"name": "some-project", "storage": "20 bytes", "versions": []}
def test_hide_creates_hidden_file(client_with_claimed_project):
"""
Tests that the hidden file is created when hiding a version
"""
hidden_file_path = docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / ".hidden"
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# check open was called at least once with the correct path
with patch("docat.app.open") as open_file_mock:
# hide
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
open_file_mock.assert_called_once_with(hidden_file_path, "w")
def test_hide_fails_project_does_not_exist(client_with_claimed_project):
"""
Tests that hiding a version fails when the project does not exist
"""
with patch("docat.app.open") as open_file_mock:
hide_response = client_with_claimed_project.post("/api/does-not-exist/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 404
assert hide_response.json() == {"message": "Project does-not-exist not found"}
open_file_mock.assert_not_called()
def test_hide_fails_version_does_not_exist(client_with_claimed_project):
"""
Tests that hiding a version fails when the version does not exist
"""
with patch("docat.app.open") as open_file_mock:
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide different version
hide_response = client_with_claimed_project.post("/api/some-project/2.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 404
assert hide_response.json() == {"message": "Version 2.0.0 not found"}
open_file_mock.assert_not_called()
def test_hide_fails_already_hidden(client_with_claimed_project):
"""
Tests that hiding a version fails when the version is already hidden
"""
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
with patch("docat.app.open") as open_file_mock:
# hide version again
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 400
assert hide_response.json() == {"message": "Version 1.0.0 is already hidden"}
open_file_mock.assert_not_called()
def test_hide_fails_no_token(client_with_claimed_project):
"""
Tests that hiding a version fails when no token is provided
"""
with patch("docat.app.open") as open_file_mock:
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide")
assert hide_response.status_code == 401
assert hide_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}
open_file_mock.assert_not_called()
def test_hide_fails_invalid_token(client_with_claimed_project):
"""
Tests that hiding a version fails when an invalid token is provided
"""
with patch("docat.app.open") as open_file_mock:
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "invalid"})
assert hide_response.status_code == 401
assert hide_response.json() == {"message": "Docat-Api-Key token is not valid for some-project"}
open_file_mock.assert_not_called()
@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_show(_, client_with_claimed_project):
"""
Tests that the version is no longer marked as hidden after requesting show.
"""
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide the version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
# check hidden
project_details_response = client_with_claimed_project.get("/api/projects/some-project")
assert project_details_response.status_code == 200
assert project_details_response.json() == {
"name": "some-project",
"storage": "20 bytes",
"versions": [],
}
# show the version
show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
assert show_response.status_code == 200
assert show_response.json() == {"message": "Version 1.0.0 is now shown"}
# check detected again
project_details_response = client_with_claimed_project.get("/api/projects/some-project")
assert project_details_response.status_code == 200
assert project_details_response.json() == {
"name": "some-project",
"storage": "20 bytes",
"versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}],
}
def test_show_deletes_hidden_file(client_with_claimed_project):
"""
Tests that the hidden file is deleted when requesting show.
"""
hidden_file_path = docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / ".hidden"
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide the version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
# check os.remove was called at least once with the correct path
with patch("os.remove") as remove_file_mock:
# show again
show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
assert show_response.status_code == 200
assert show_response.json() == {"message": "Version 1.0.0 is now shown"}
remove_file_mock.assert_called_once_with(hidden_file_path)
def test_show_fails_project_does_not_exist(client_with_claimed_project):
"""
Tests that showing a version fails when the project does not exist
"""
with patch("os.remove") as delete_file_mock:
show_response = client_with_claimed_project.post("/api/does-not-exist/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert show_response.status_code == 404
assert show_response.json() == {"message": "Project does-not-exist not found"}
delete_file_mock.assert_not_called()
def test_show_fails_version_does_not_exist(client_with_claimed_project):
"""
Tests that showing a version fails when the version does not exist
"""
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide the version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
with patch("os.remove") as delete_file_mock:
# show different version
show_response = client_with_claimed_project.post("/api/some-project/2.0.0/show", headers={"Docat-Api-Key": "1234"})
assert show_response.status_code == 404
assert show_response.json() == {"message": "Version 2.0.0 not found"}
delete_file_mock.assert_not_called()
def test_show_fails_already_shown(client_with_claimed_project):
"""
Tests that showing a version fails when the version is already shown
"""
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
with patch("os.remove") as delete_file_mock:
# show version
show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "1234"})
assert show_response.status_code == 400
assert show_response.json() == {"message": "Version 1.0.0 is not hidden"}
delete_file_mock.assert_not_called()
def test_show_fails_no_token(client_with_claimed_project):
"""
Tests that showing a version fails when no token is provided
"""
with patch("os.remove") as remove_file_mock:
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
# try to show without token
show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show")
assert show_response.status_code == 401
assert show_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}
remove_file_mock.assert_not_called()
def test_show_fails_invalid_token(client_with_claimed_project):
"""
Tests that showing a version fails when an invalid token is provided
"""
with patch("os.remove") as remove_file_mock:
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert create_response.status_code == 201
# hide version
hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"})
assert hide_response.status_code == 200
assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"}
# try to show without token
show_response = client_with_claimed_project.post("/api/some-project/1.0.0/show", headers={"Docat-Api-Key": "invalid"})
assert show_response.status_code == 401
assert show_response.json() == {"message": "Docat-Api-Key token is not valid for some-project"}
remove_file_mock.assert_not_called()
@patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0))
def test_hide_and_show_with_tag(_, client_with_claimed_project):
"""
Tests that the version is no longer marked as hidden after requesting show on a tag.
"""
# create a version
create_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
"), "plain/text")}
)
assert create_second_project_response.status_code == 201
create_tag_response = client_with_claimed_project.put(f"/api/{project_name}/{version}/tags/{tag}")
assert create_tag_response.status_code == 409
assert create_tag_response.json() == {"message": f"Tag {tag} would overwrite an existing version!"}
def test_create_fails_on_overwrite_of_tag(client_with_claimed_project):
"""
Create a version with the same name as a tag.
"""
project_name = "some-project"
version = "1.0.0"
tag = "some-tag"
create_project_response = client_with_claimed_project.post(
f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"
"), "plain/text")},
headers={"Docat-Api-Key": "456"},
)
response_data = response.json()
assert response.status_code == 401
assert response_data["message"] == "Docat-Api-Key token is not valid for some-project"
assert remove_mock.mock_calls == []
def test_upload_rejects_forbidden_project_name(client_with_claimed_project):
"""
Names that conflict with pages in docat web are forbidden,
and creating a project with such a name should fail.
"""
with patch("docat.app.remove_docs") as remove_mock:
for project_name in ["upload", "claim", " Delete ", "help", "DOC", "api"]:
response = client_with_claimed_project.post(
f"/api/{project_name}/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert response.status_code == 400
assert response.json() == {"message": f'Project name "{project_name}" is forbidden, as it conflicts with pages in docat web.'}
assert remove_mock.mock_calls == []
def test_upload_issues_warning_missing_index_file(client_with_claimed_project):
"""
When a project is uploaded without an index.html file,
a warning should be issued, but the upload should succeed.
"""
response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("some-other-file.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
response_data = response.json()
assert response.status_code == 201
assert response_data["message"] == "Documentation uploaded successfully, but no index.html found at root of archive."
assert (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "some-other-file.html").exists()
assert not (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "index.html").exists()
================================================
FILE: docat/tests/test_upload_icon.py
================================================
import base64
import io
from datetime import datetime
from unittest.mock import call, patch
import docat.app as docat
ONE_PIXEL_PNG = base64.decodebytes(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII="
)
def test_successful_icon_upload(client_with_claimed_project):
upload_folder_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert upload_folder_response.status_code == 201
with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
upload_response = client_with_claimed_project.post(
"/api/some-project/icon",
files={"file": ("icon.jpg", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
)
assert upload_response.status_code == 200
assert upload_response.json() == {"message": "Icon successfully uploaded"}
assert remove_file_mock.mock_calls == []
assert len(copyfileobj_mock.mock_calls) == 1
def test_icon_upload_fails_with_no_project(client_with_claimed_project):
with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
upload_response = client_with_claimed_project.post(
"/api/non-existing-project/icon",
files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
)
assert upload_response.status_code == 404
assert upload_response.json() == {"message": "Project non-existing-project not found"}
assert remove_file_mock.mock_calls == []
assert copyfileobj_mock.mock_calls == []
def test_icon_upload_fails_no_token_and_existing_icon(client):
"""
upload twice, first time should be successful (nothing replaced),
second time should fail (would need token to replace)
"""
upload_folder_response = client.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
Hello World
"), "plain/text")}
)
assert upload_folder_response.status_code == 201
with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
upload_response_1 = client.post(
"/api/some-project/icon",
files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
)
assert upload_response_1.status_code == 200
assert upload_response_1.json() == {"message": "Icon successfully uploaded"}
assert remove_file_mock.mock_calls == []
assert len(copyfileobj_mock.mock_calls) == 1
with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock:
upload_response_2 = client.post(
"/api/some-project/icon",
files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")},
)
assert upload_response_2.status_code == 401
assert upload_response_2.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"}
assert remove_file_mock.mock_calls == []
assert len(copyfileobj_mock.mock_calls) == 0
def test_icon_upload_successful_replacement_with_token(client_with_claimed_project):
"""
upload twice, both times should be successful (token provided)
"""
icon_path = docat.DOCAT_UPLOAD_FOLDER / "some-project" / "logo"
upload_folder_response = client_with_claimed_project.post(
"/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"
If you want to automate the upload of your documentation consider using{' '}
curl to post it to the server. There are some examples in the{' '}
docat repository
.