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 ================================================ ![docat](doc/assets/docat-teaser.png) **Host your docs. Simple. Versioned. Fancy.** [![build](https://github.com/docat-org/docat/workflows/docat%20ci/badge.svg)](https://github.com/docat-org/docat/actions) [![Gitter](https://badges.gitter.im/docat-docs-hosting/community.svg)](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"

Hello World

"), "plain/text")} ) assert create_response.status_code == 201 # create a tag create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest") assert create_tag_response.status_code == 201 assert create_tag_response.json() == {"message": "Tag latest -> 1.0.0 successfully created"} # hide the tag hide_response = client_with_claimed_project.post("/api/some-project/latest/hide", headers={"Docat-Api-Key": "1234"}) assert hide_response.status_code == 200 assert hide_response.json() == {"message": "Version latest 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/latest/show", headers={"Docat-Api-Key": "1234"}) assert show_response.status_code == 200 assert show_response.json() == {"message": "Version latest 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": ["latest"], "hidden": False}], } ================================================ FILE: docat/tests/test_project.py ================================================ import io from datetime import datetime from unittest.mock import patch import httpx from fastapi.testclient import TestClient import docat.app as docat from docat.models import ProjectDetail, ProjectVersion from docat.utils import get_project_details client = TestClient(docat.app) @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) def test_project_api(_, temp_project_version): docs = temp_project_version("project", "1.0") docs = temp_project_version("different-project", "1.0") with patch("docat.app.DOCAT_UPLOAD_FOLDER", docs): response = client.get("/api/projects") assert response.status_code == httpx.codes.OK assert response.json() == { "projects": [ { "name": "different-project", "logo": False, "storage": "0 bytes", "versions": [ {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}, ], }, { "name": "project", "logo": False, "storage": "0 bytes", "versions": [ {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}, ], }, ] } def test_project_api_without_any_projects(): response = client.get("/api/projects") assert response.status_code == httpx.codes.OK assert response.json() == {"projects": []} @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) def test_project_details_api(_, temp_project_version): project = "project" docs = temp_project_version(project, "1.0") symlink_to_latest = docs / project / "latest" assert symlink_to_latest.is_symlink() with patch("docat.app.DOCAT_UPLOAD_FOLDER", docs): response = client.get(f"/api/projects/{project}") assert response.status_code == httpx.codes.OK assert response.json() == { "name": "project", "storage": "0 bytes", "versions": [{"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}], } def test_project_details_api_with_a_project_that_does_not_exist(): response = client.get("/api/projects/i-do-not-exist") assert not response.status_code == httpx.codes.OK assert response.json() == {"message": "Project i-do-not-exist does not exist"} @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) def test_get_project_details_with_hidden_versions(_, client_with_claimed_project): """ Make sure that get_project_details works when include_hidden is set to True. """ # 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 details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True) assert details == ProjectDetail( name="some-project", storage="20 bytes", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), 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 details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True) assert details == ProjectDetail( name="some-project", storage="20 bytes", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=True)], ) @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) def test_project_details_without_hidden_versions(_, client_with_claimed_project): """ Make sure that project_details works when include_hidden is set to False. """ # 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 details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False) assert details == ProjectDetail( name="some-project", storage="20 bytes", versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), 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 details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False) assert details == ProjectDetail(name="some-project", storage="20 bytes", versions=[]) @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) def test_include_hidden_parameter_for_get_projects(_, client_with_claimed_project): """ Make sure that include_hidden has the desired effect on the /api/projects endpoint. """ # 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 get_projects_response = client_with_claimed_project.get("/api/projects") assert get_projects_response.status_code == 200 assert get_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}], } ] } # check include_hidden=True get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=true") assert get_projects_response.status_code == 200 assert get_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 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 include_hidden=False get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=false") assert get_projects_response.status_code == 200 assert get_projects_response.json() == {"projects": []} # check include_hidden=True get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=true") assert get_projects_response.status_code == 200 assert get_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": True}], } ] } @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) def test_include_hidden_parameter_for_get_project_details(_, client_with_claimed_project): """ Make sure that include_hidden has the desired effect on the /api/project/{project} endpoint. """ # 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 get_projects_response = client_with_claimed_project.get("/api/projects/some-project") assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } # check include_hidden=True get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=true") assert get_projects_response.status_code == 200 assert get_projects_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 include_hidden=False get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=false") assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", "storage": "20 bytes", "versions": [], } # check include_hidden=True get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=true") assert get_projects_response.status_code == 200 assert get_projects_response.json() == { "name": "some-project", "storage": "20 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}], } ================================================ FILE: docat/tests/test_rename.py ================================================ import io from pathlib import Path from unittest.mock import call, patch from tinydb import Query import docat.app as docat def test_rename_fail_project_does_not_exist(client_with_claimed_project): with patch("os.rename") as rename_mock: response = client_with_claimed_project.put("/api/does-not-exist/rename/new-project-name") assert response.status_code == 404 assert response.json() == {"message": "Project does-not-exist not found"} assert rename_mock.mock_calls == [] def test_rename_fail_new_project_name_already_used(client_with_claimed_project): with patch("os.rename") as rename_mock: create_first_project_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_first_project_response.status_code == 201 create_second_project_response = client_with_claimed_project.post( "/api/second-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} ) assert create_second_project_response.status_code == 201 rename_response = client_with_claimed_project.put("/api/some-project/rename/second-project") assert rename_response.status_code == 409 assert rename_response.json() == {"message": "New project name second-project already in use"} assert rename_mock.mock_calls == [] def test_rename_not_authenticated(client_with_claimed_project): with patch("os.rename") as rename_mock: create_project_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_project_response.status_code == 201 rename_response = client_with_claimed_project.put("/api/some-project/rename/new-project-name") assert rename_response.status_code == 401 assert rename_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"} assert rename_mock.mock_calls == [] def test_rename_success(client_with_claimed_project): with patch("os.rename") as rename_mock: create_project_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_project_response.status_code == 201 rename_response = client_with_claimed_project.put("/api/some-project/rename/new-project-name", headers={"Docat-Api-Key": "1234"}) assert rename_response.status_code == 200 assert rename_response.json() == {"message": "Successfully renamed project some-project to new-project-name"} old_path = docat.DOCAT_UPLOAD_FOLDER / Path("some-project") new_path = docat.DOCAT_UPLOAD_FOLDER / Path("new-project-name") assert rename_mock.mock_calls == [call(old_path, new_path)] Project = Query() table = docat.db.table("claims") claims_with_old_name = table.search(Project.name == "some-project") assert len(claims_with_old_name) == 0 claims_with_new_name = table.search(Project.name == "new-project-name") assert len(claims_with_new_name) == 1 def test_rename_rejects_forbidden_project_name(client_with_claimed_project): """ Names that conflict with pages in docat web are forbidden, and renaming a project to such a name should fail. """ 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.rename") as rename_mock: for project_name in ["upload", "claim", "Delete ", "help", "Doc", "API"]: rename_response = client_with_claimed_project.put(f"/api/some-project/rename/{project_name}", headers={"Docat-Api-Key": "1234"}) assert rename_response.status_code == 400 assert rename_response.json() == { "message": f'New project name "{project_name}" is forbidden, as it conflicts with pages in docat web.' } assert rename_mock.mock_calls == [] ================================================ FILE: docat/tests/test_stats.py ================================================ import io from datetime import datetime from unittest.mock import patch import pytest @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) @pytest.mark.parametrize( ("project_config", "n_projects", "n_versions", "storage"), [ ([("some-project", ["1.0.0"])], 1, 1, "20 bytes"), ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"), ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"), ([("some-project", ["1.0.0", "2.0.0"]), ("another-project", ["1"])], 2, 3, "60 bytes"), ], ) def test_get_stats(_, project_config, n_projects, n_versions, storage, client_with_claimed_project): """ Make sure that get_stats works. """ # create a version for project_name, versions in project_config: for version in versions: create_response = client_with_claimed_project.post( f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} ) assert create_response.status_code == 201 # get system stats hide_response = client_with_claimed_project.get("/api/stats") assert hide_response.status_code == 200 assert hide_response.json() == {"n_projects": n_projects, "n_versions": n_versions, "storage": storage} ================================================ FILE: docat/tests/test_upload.py ================================================ import io from pathlib import Path from unittest.mock import call, patch import docat.app as docat def test_successfully_upload(client): with patch("docat.app.remove_docs"): response = client.post("/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")}) response_data = response.json() assert response.status_code == 201 assert response_data["message"] == "Documentation uploaded successfully" assert (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "index.html").exists() def test_successfully_override(client_with_claimed_project): with patch("docat.app.remove_docs") as remove_mock: response = client_with_claimed_project.post( "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} ) assert response.status_code == 201 response = client_with_claimed_project.post( "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")}, headers={"Docat-Api-Key": "1234"}, ) response_data = response.json() assert response.status_code == 201 assert response_data["message"] == "Documentation uploaded successfully" assert remove_mock.mock_calls == [call("some-project", "1.0.0", docat.DOCAT_UPLOAD_FOLDER)] def test_tags_are_not_overwritten_without_api_key(client_with_claimed_project): with patch("docat.app.remove_docs") as remove_mock: response = client_with_claimed_project.post( "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} ) assert response.status_code == 201 response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest") assert response.status_code == 201 response = client_with_claimed_project.post( "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} ) 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" assert remove_mock.mock_calls == [] def test_successful_tag_creation(client_with_claimed_project): with patch("docat.app.create_symlink") as create_symlink_mock: create_project_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_project_response.status_code == 201 create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest") assert create_tag_response.status_code == 201 assert create_tag_response.json() == {"message": "Tag latest -> 1.0.0 successfully created"} destination_path = docat.DOCAT_UPLOAD_FOLDER / Path("some-project") / Path("latest") assert create_symlink_mock.mock_calls == [call("1.0.0", destination_path), call().__bool__()] def test_create_tag_fails_when_version_does_not_exist(client_with_claimed_project): with patch("docat.app.create_symlink") as create_symlink_mock: create_project_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_project_response.status_code == 201 create_tag_response = client_with_claimed_project.put("/api/some-project/non-existing-version/tags/new-tag") assert create_tag_response.status_code == 404 assert create_tag_response.json() == {"message": "Version non-existing-version not found"} assert create_symlink_mock.mock_calls == [] def test_create_tag_fails_on_overwrite_of_version(client_with_claimed_project): """ Create a tag with the same name as a version. """ project_name = "some-project" version = "1.0.0" tag = "latest" create_first_project_response = client_with_claimed_project.post( f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} ) assert create_first_project_response.status_code == 201 create_second_project_response = client_with_claimed_project.post( f"/api/{project_name}/{tag}", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "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"

Hello World

"), "plain/text")} ) assert create_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 == 201 assert create_tag_response.json() == {"message": f"Tag {tag} -> {version} successfully created"} create_project_with_name_of_tag_response = client_with_claimed_project.post( f"/api/{project_name}/{tag}", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} ) assert create_project_with_name_of_tag_response.status_code == 409 assert create_project_with_name_of_tag_response.json() == {"message": "Cannot overwrite existing tag with new version."} def test_fails_with_invalid_token(client_with_claimed_project): with patch("docat.app.remove_docs") as remove_mock: response = client_with_claimed_project.post( "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} ) assert response.status_code == 201 response = client_with_claimed_project.post( "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "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"

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_with_claimed_project.post( "/api/some-project/icon", files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, headers={"Docat-Api-Key": "1234"}, ) 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_1 = client_with_claimed_project.post( "/api/some-project/icon", files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, headers={"Docat-Api-Key": "1234"}, ) assert upload_response_1.status_code == 200 assert upload_response_1.json() == {"message": "Icon successfully uploaded"} assert remove_file_mock.mock_calls == [call(icon_path)] assert len(copyfileobj_mock.mock_calls) == 1 def test_icon_upload_successful_no_token_no_existing_icon(client): 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 = client.post( "/api/some-project/icon", files={"file": ("icon.png", 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_no_image(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": ("file.zip", io.BytesIO(b"not image data"), "application/zip")}, ) assert upload_response.status_code == 400 assert upload_response.json() == {"message": "Icon must be an image"} assert remove_file_mock.mock_calls == [] assert copyfileobj_mock.mock_calls == [] @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) def test_get_project_recongizes_icon(_, client_with_claimed_project): """ get_projects should return true, if the project has an icon """ 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 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}], } ] } 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 projects_response = client_with_claimed_project.get("/api/projects") assert projects_response.status_code == 200 assert projects_response.json() == { "projects": [ { "name": "some-project", "logo": True, "storage": "103 bytes", "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], } ] } ================================================ FILE: docat/tests/test_utils.py ================================================ from pathlib import Path from unittest.mock import MagicMock, patch import docat.app as docat from docat.utils import create_symlink, extract_archive, get_dir_size, remove_docs def test_symlink_creation(): """ Tests the creation of a symlink """ source = MagicMock() destination = MagicMock() destination.exists.return_value = False destination.symlink_to.return_value = MagicMock() assert create_symlink(source, destination) destination.symlink_to.assert_called_once_with(source) def test_symlink_creation_overwrite_destination(): """ Tests the creation of a symlink and overwriting of existing symlink """ source = MagicMock() destination = MagicMock() destination.exists.return_value = True destination.is_symlink.return_value = True destination.unlink.return_value = MagicMock() destination.symlink_to.return_value = MagicMock() assert create_symlink(source, destination) destination.unlink.assert_called_once() destination.symlink_to.assert_called_once_with(source) def test_symlink_creation_do_not_overwrite_destination(): """ Tests wether a symlinc is not created when it would overwrite an existing version """ source = MagicMock() destination = MagicMock() destination.exists.return_value = True destination.is_symlink.return_value = False destination.unlink.return_value = MagicMock() destination.symlink_to.return_value = MagicMock() assert not create_symlink(source, destination) destination.unlink.assert_not_called() destination.symlink_to.assert_not_called() def test_archive_artifact(): target_file = Path("/some/zipfile.zip") destination = "/tmp/null" with patch.object(Path, "unlink") as mock_unlink, patch("docat.utils.ZipFile") as mock_zip: mock_zip_open = MagicMock() mock_zip.return_value.__enter__.return_value.extractall = mock_zip_open extract_archive(target_file, destination) mock_zip.assert_called_once_with(target_file, "r") mock_zip_open.assert_called_once() mock_unlink.assert_called_once() def test_remove_version(temp_project_version): docs = temp_project_version("project", "1.0") remove_docs("project", "1.0", docat.DOCAT_UPLOAD_FOLDER) assert docs.exists() assert not (docs / "project").exists() def test_remove_symlink_version(temp_project_version): project = "project" docs = temp_project_version(project, "1.0") symlink_to_latest = docs / project / "latest" assert symlink_to_latest.is_symlink() remove_docs(project, "latest", docat.DOCAT_UPLOAD_FOLDER) assert not symlink_to_latest.exists() def test_broken_symlinks_in_projects(temp_project_version): project = "project" docs = temp_project_version(project, "1.0") create_symlink(docs / project / "broken", docs / project / "latest") get_dir_size(docs / project) ================================================ FILE: web/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # production /build /dist # misc .prettierrc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env .env.local .env.*.local ================================================ FILE: web/.prettierignore ================================================ node_modules # Ignore artifacts: build coverage ================================================ FILE: web/.prettierrc.json ================================================ { "trailingComma": "none", "tabWidth": 2, "semi": false, "singleQuote": true } ================================================ FILE: web/README.md ================================================ # docat web ## Project setup ```sh yarn install [--pure-lockfile] ``` ### Compiles and hot-reloads for development The script for `yarn start` automatically sets `VITE_DOCAT_VERSION` to display the current version in the footer, so you can just run: ```sh yarn start ``` ### Compiles and minifies for production To display the current version of docat in the footer, use the following script to set `VITE_DOCAT_VERSION`. This one liner uses the latest tag, if there is one on the current commit, and the current commit if not. ```sh VITE_DOCAT_VERSION=$(git describe --tags --always) yarn build ``` Otherwise you can just use the following and the footer will show `unknown`. ```sh yarn build ``` ### Lints and fixes files ```sh yarn lint ``` ### Tests ```sh yarn test ``` ### Basic Header Theming Not happy with the default Docat logo and header? Just add your custom html header to the `/var/www/html/config.json` file. ```json { "headerHTML": "

MyCompany

", "footerHTML": "Contact Maintainers" } ``` ## Development ```sh sudo docker run \ --detach \ --volume /path/to/doc:/var/docat/doc/ \ --publish 8000:80 \ docat ``` ## Errors If you get a 403 response when trying to read a version, try changing the permissions of your docs folder on your host. ```sh sudo chmod 777 /path/to/doc -r ``` ================================================ FILE: web/eslint.config.js ================================================ import eslintReact from "@eslint-react/eslint-plugin"; import eslintJs from "@eslint/js"; import { defineConfig } from "eslint/config"; import tseslint from "typescript-eslint"; import eslintConfigPrettier from "eslint-config-prettier"; import globals from 'globals' export default defineConfig( { files: ["**/*.ts", "**/*.tsx"], ignores: ['dist/**', 'vite-env.d.ts', 'vite.config.ts'], // Extend recommended rule sets from: // 1. ESLint JS's recommended rules // 2. TypeScript ESLint recommended rules // 3. ESLint React's recommended-typescript rules // 4. Prettier (Must be last to disable conflicting rules) extends: [ eslintJs.configs.recommended, tseslint.configs.recommended, eslintReact.configs["recommended-typescript"], eslintConfigPrettier, ], // Configure language/parsing options languageOptions: { ecmaVersion: 'latest', // Allow modern JS syntax globals: { ...globals.browser, // Allow browser globals like `window` }, parser: tseslint.parser, // Your existing parser parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, }, }, // TODO: See if some of these could be fixed rules: { "@eslint-react/dom-no-dangerously-set-innerhtml": "off", "@eslint-react/exhaustive-deps": "off", "@eslint-react/set-state-in-effect": "off", }, }, ); ================================================ FILE: web/index.html ================================================
================================================ FILE: web/package.json ================================================ { "name": "docat-web", "version": "0.1.0", "private": true, "type": "module", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.0", "@mui/material": "^9.0.0", "fuse.js": "^7.3.0", "react": "^19.2.5", "react-dom": "^19.2.5", "react-markdown": "^10.1.0", "react-router": "^7.14.0", "react-router-dom": "^7.14.0", "semver": "^7.7.4", "typescript": "^6.0.2", "vite": "^8.0.8" }, "scripts": { "start": "VITE_DOCAT_VERSION=$(git describe --tags --always) vite", "build": "tsc && vite build", "preview": "vite preview", "test": "vitest --watch=false", "lint": "eslint ." }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "devDependencies": { "@vitejs/plugin-react": "^6.0.1", "@eslint/js": "^10.0.1", "@types/semver": "^7.7.1", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "jsdom": "^29.0.2", "eslint": "^10.2.0", "eslint-config-prettier": "^10.1.8", "@eslint-react/eslint-plugin": "^4.2.3", "globals": "^17.4.0", "prettier": "^3.8.2", "typescript-eslint": "^8.58.1", "vitest": "^4.1.4" } } ================================================ FILE: web/src/App.tsx ================================================ import React from 'react'; import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { ConfigDataProvider } from './data-providers/ConfigDataProvider' import { MessageBannerProvider } from './data-providers/MessageBannerProvider' import { ProjectDataProvider } from './data-providers/ProjectDataProvider' import { SearchProvider } from './data-providers/SearchProvider' import { StatsDataProvider } from './data-providers/StatsDataProvider' import Claim from './pages/Claim' import Delete from './pages/Delete' import Docs from './pages/Docs' import Help from './pages/Help' import Home from './pages/Home' import NotFound from './pages/NotFound' import Upload from './pages/Upload' function App(): React.JSX.Element { const router = createBrowserRouter([ { path: '/', errorElement: , children: [ { path: '', element: }, { path: 'upload', element: }, { path: 'claim', element: }, { path: 'delete', element: }, { path: 'help', element: }, { path: ':project', children: [ { path: '', element: }, { path: ':version', children: [ { path: '', element: }, { path: '*', element: } ] } ] } ] } ]) return (
) } export default App ================================================ FILE: web/src/components/DataSelect.tsx ================================================ import { FormGroup, MenuItem, TextField } from '@mui/material' import React, { useState } from 'react' interface Props { emptyMessage: string errorMsg?: string value?: string label: string values: string[] onChange: (value: string) => void } export default function DataSelect(props: Props): React.JSX.Element { const [selectedValue, setSelectedValue] = useState( props.value ?? 'none' ) // clear field if selected value is not in options if (selectedValue !== 'none' && !props.values.includes(selectedValue)) { setSelectedValue('none') } return ( { setSelectedValue(e.target.value) props.onChange(e.target.value) }} value={props.values.length > 0 ? selectedValue : 'none'} label={props.label} error={props.errorMsg !== undefined && props.errorMsg !== ''} helperText={props.errorMsg} select > {props.emptyMessage} {props.values.map((value) => { return ( {value} ) })} ) } ================================================ FILE: web/src/components/DocumentControlButtons.tsx ================================================ import { Home, Share } from '@mui/icons-material' import { Checkbox, FormControl, FormControlLabel, FormGroup, MenuItem, Modal, Select, Tooltip } from '@mui/material' import React, { useState } from 'react' import { Link } from 'react-router-dom' import type ProjectDetails from '../models/ProjectDetails' import styles from './../style/components/DocumentControlButtons.module.css' interface Props { version: string versions: ProjectDetails[] onVersionChange: (version: string) => void getShareUrl: (options: { useLatest: boolean, hideUi: boolean }) => string } export default function DocumentControlButtons(props: Props): React.JSX.Element { const buttonStyle = { width: '25px', height: '25px' } const [shareModalOpen, setShareModalOpen] = useState(false) const [shareModalUseLatest, setShareModalUseLatest] = useState(false) const [shareModalHideUi, setShareModalHideUi] = useState(false) // Cannot copy when page is served over HTTP const canCopy = navigator.clipboard !== undefined return (
{ setShareModalOpen(false) }} aria-labelledby="share-modal-title" aria-describedby="share-modal-description" >

{props.getShareUrl({ useLatest: shareModalUseLatest, hideUi: shareModalHideUi })}

{canCopy && (
)}
{ setShareModalHideUi(e.target.checked) }} /> } label="Hide Version Selector" className={styles['share-modal-label']} /> { setShareModalUseLatest(e.target.checked) }} /> } label="Always use latest version" className={styles['share-modal-label']} />
) } ================================================ FILE: web/src/components/FavoriteStar.tsx ================================================ import { Star, StarOutlined } from '@mui/icons-material' import React, { useState } from 'react' import ProjectRepository from '../repositories/ProjectRepository' interface Props { projectName: string onFavoriteChanged: () => void } export default function FavoriteStar(props: Props): React.JSX.Element { const [isFavorite, setIsFavorite] = useState( ProjectRepository.isFavorite(props.projectName) ) const toggleFavorite = (): void => { const newIsFavorite = !isFavorite ProjectRepository.setFavorite(props.projectName, newIsFavorite) setIsFavorite(newIsFavorite) props.onFavoriteChanged() } const StarType = isFavorite ? Star : StarOutlined return ( ) } ================================================ FILE: web/src/components/FileInput.tsx ================================================ import { InputLabel } from '@mui/material' import React, { useRef, useState } from 'react' import styles from './../style/components/FileInput.module.css' interface Props { label: string okTypes: string[] file: File | undefined onChange: (file: File | undefined) => void isValid: (file: File) => boolean } export default function FileInput(props: Props): React.JSX.Element { const [fileName, setFileName] = useState( props.file?.name !== undefined ? props.file.name : '' ) const [dragActive, setDragActive] = useState(false) const inputRef = useRef(null) /** * Checks if a file was selected and if it is valid * before it is selected. * @param files FileList from the event */ const updateFileIfValid = (files: FileList | null): void => { if (files == null || files.length < 1 || files[0] == null) { return } const file = files[0] if (!props.isValid(file)) { return } setFileName(file.name) props.onChange(file) } /** * This updates the file upload container to show a custom style when * the user is dragging a file into or out of the container. * @param e drag enter event */ const handleDragEvents = (e: React.DragEvent): void => { e.preventDefault() e.stopPropagation() if (e.type === 'dragenter' || e.type === 'dragover') { setDragActive(true) } else if (e.type === 'dragleave') { setDragActive(false) } } /** * Handles the drop event when the user drops a file into the container. * @param e DragEvent */ const handleDrop = (e: React.DragEvent): void => { e.preventDefault() e.stopPropagation() setDragActive(false) if (e.dataTransfer?.files[0] == null) { return } updateFileIfValid(e.dataTransfer.files) } /** * Handles the file input via the file browser. * @param e change event */ const handleSelect = (e: React.ChangeEvent): void => { e.preventDefault() updateFileIfValid(e.target.files) } /** * This triggers the input when the container is clicked. */ const onButtonClick = (): void => { if (inputRef?.current != null) { // @ts-expect-error - the ref is not null, therefore the button should be able to be clicked inputRef.current.click() } } return (
{!dragActive && ( {props.label} )}
{fileName !== '' && ( <>

{fileName}

-

)}

Drag zip file here or

{dragActive && (
)}
) } ================================================ FILE: web/src/components/Footer.tsx ================================================ import { Box } from '@mui/material' import { useState, JSX } from 'react' import { Link } from 'react-router-dom' import { useConfig } from '../data-providers/ConfigDataProvider' import styles from './../style/components/Footer.module.css' export default function Footer(): JSX.Element { const defaultFooter = ( <> ) const [footer, setFooter] = useState(defaultFooter) const config = useConfig() // set custom header if found in config if (config.footerHTML != null && footer === defaultFooter) { setFooter(
) } return (
HELP {footer}
VERSION{' '} {import.meta.env.VITE_DOCAT_VERSION ?? 'unknown'}
) } ================================================ FILE: web/src/components/Header.tsx ================================================ import { useState, JSX } from 'react' import { Link } from 'react-router-dom' import { useConfig } from '../data-providers/ConfigDataProvider' import docatLogo from '../assets/logo.png' import styles from './../style/components/Header.module.css' export default function Header(): JSX.Element { const defaultHeader = ( <> docat logo

DOCAT

) const [header, setHeader] = useState(defaultHeader) const config = useConfig() // set custom header if found in config if (config.headerHTML != null && header === defaultHeader) { setHeader(
) } return (
{header}
) } ================================================ FILE: web/src/components/IFrame.tsx ================================================ import React, { useRef } from 'react' import { generateKey } from '../data-providers/RandomId' import styles from '../style/components/IFrame.module.css' interface Props { src: string onPageChanged: (page: string, hash: string, title?: string) => void onHashChanged: (hash: string) => void onTitleChanged: (title: string) => void onNotFound: () => void onFaviconChanged?: (faviconUrl: string | null) => void } export default function IFrame(props: Props): React.JSX.Element { const iFrameRef = useRef(null) const onIframeLoad = (): void => { if (iFrameRef.current === null) { console.error('iFrameRef is null') return } // remove the event listeners to prevent memory leaks iFrameRef.current.contentWindow?.removeEventListener( 'hashchange', hashChangeEventListener ) iFrameRef.current.contentWindow?.removeEventListener( 'titlechange', titleChangeEventListener ) const url = iFrameRef.current?.contentDocument?.location.href if (url == null) { console.warn('IFrame onload event triggered, but url is null') return } // make all external links in iframe open in new tab // and make internal links replace the iframe url so that change // doesn't show up in the page history (we'd need to click back twice) iFrameRef.current.contentDocument ?.querySelectorAll('a') .forEach((a: HTMLAnchorElement) => { if (typeof a.href === 'string' && !a.href.startsWith(window.location.origin)) { a.setAttribute('target', '_blank') return } const href = a.getAttribute('href') ?? '' if (href.trim() === '') { // ignore empty links, may be handled with js internally. // Will inevitably cause the user to have to click back // multiple times to get back to the previous page. return } // From here: https://www.ozzu.com/questions/358584/how-do-you-ignore-iframes-javascript-history a.onclick = () => { iFrameRef.current?.contentWindow?.location.replace(a.href) return false } }) // React to page 404ing void (async (): Promise => { const response = await fetch(url, { method: 'HEAD' }) if (response.status === 404) { props.onNotFound() } })() // Add the event listener again iFrameRef.current.contentWindow?.addEventListener( 'hashchange', hashChangeEventListener ) iFrameRef.current.contentWindow?.addEventListener( 'titlechange', titleChangeEventListener ) const parts = url.split('/doc/').slice(1).join('/doc/').split('/') const urlPageAndHash = parts.slice(2).join('/') const hashIndex = urlPageAndHash.includes('#') ? urlPageAndHash.indexOf('#') : urlPageAndHash.length const urlPage = urlPageAndHash.slice(0, hashIndex) const urlHash = urlPageAndHash.slice(hashIndex) const title = iFrameRef.current?.contentDocument?.title props.onPageChanged(urlPage, urlHash, title) const favicon = extractFaviconUrl(iFrameRef.current.contentDocument) props.onFaviconChanged?.(favicon) } const extractFaviconUrl = (doc: Document | null | undefined): string | null => { if (doc == null) { return null } const link = doc.querySelector('link[rel="icon"]') as HTMLLinkElement | null if (!link?.href) { return null } return link.href } const hashChangeEventListener = (): void => { if (iFrameRef.current === null) { console.error('hashChangeEvent from iframe but iFrameRef is null') return } const url = iFrameRef.current?.contentDocument?.location.href if (url == null) { return } let hash = url.split('#')[1] if (hash !== null) { hash = `#${hash}` } else { hash = '' } props.onHashChanged(hash) } const titleChangeEventListener = (): void => { if (iFrameRef.current === null) { console.error('titleChangeEvent from iframe but iFrameRef is null') return } const title = iFrameRef.current?.contentDocument?.title if (title == null) { return } props.onTitleChanged(title) } return (