├── .dockerignore ├── .github └── workflows │ └── docat.yml ├── .prettierrc ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── doc ├── assets │ ├── docat-teaser.png │ └── docat.gif └── getting-started.md ├── docat ├── .gitignore ├── Makefile ├── README.md ├── docat │ ├── __init__.py │ ├── __main__.py │ ├── app.py │ ├── models.py │ ├── nginx │ │ └── default │ └── utils.py ├── poetry.lock ├── 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 ├── public └── favicon.ico ├── src ├── App.tsx ├── assets │ ├── getting-started.md │ └── logo.png ├── 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 │ ├── SearchProvider.tsx │ └── StatsDataProvider.tsx ├── index.css ├── index.tsx ├── models │ ├── ProjectDetails.ts │ └── ProjectsResponse.ts ├── pages │ ├── Claim.tsx │ ├── Delete.tsx │ ├── Docs.tsx │ ├── Help.tsx │ ├── Home.tsx │ ├── IframePageNotFound.tsx │ ├── LoadingPage.tsx │ ├── NotFound.tsx │ └── Upload.tsx ├── react-app-env.d.ts ├── reportWebVitals.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 │ │ ├── IframePageNotFound.module.css │ │ ├── NotFound.module.css │ │ └── Upload.module.css └── tests │ └── repositories │ └── ProjectRepository.test.ts ├── tsconfig.json ├── vite-env.d.ts ├── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | *.pyc 3 | docat/env 4 | docat/__pycache__ 5 | docat/upload 6 | docat/.tox 7 | docat/tests 8 | web/node_modules 9 | web/build 10 | web/.env* 11 | -------------------------------------------------------------------------------- /.github/workflows/docat.yml: -------------------------------------------------------------------------------- 1 | name: docat ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | python-test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 4 10 | matrix: 11 | python-version: ["3.12"] 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | 20 | - name: install dependencies 21 | working-directory: docat 22 | run: | 23 | python -m pip install --upgrade pip 24 | python -m pip install poetry==1.8.3 25 | python -m poetry install 26 | 27 | - name: run backend linter 28 | working-directory: docat 29 | run: | 30 | python -m poetry run ruff check 31 | python -m poetry run ruff format --check 32 | 33 | - name: run backend static code analysis 34 | working-directory: docat 35 | run: | 36 | python -m poetry run mypy . 37 | 38 | - name: run backend tests 39 | working-directory: docat 40 | run: | 41 | python -m poetry run pytest 42 | 43 | javascript-test: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/setup-node@v4 48 | with: 49 | node-version: '22' 50 | - name: install JavaScript dependencies 51 | working-directory: web 52 | run: yarn install 53 | 54 | - name: building frontend 55 | working-directory: web 56 | run: yarn build 57 | 58 | - name: run linter against code 59 | working-directory: web 60 | run: yarn lint 61 | 62 | - name: run test suite 63 | working-directory: web 64 | run: yarn test 65 | 66 | container-image: 67 | runs-on: ubuntu-latest 68 | needs: [python-test, javascript-test] 69 | 70 | strategy: 71 | max-parallel: 2 72 | matrix: 73 | registry: 74 | - name: ghcr.io 75 | org: ${{ github.repository_owner }} 76 | token: GITHUB_TOKEN 77 | - name: docker.io 78 | org: randombenj 79 | token: DOCKERHUB 80 | 81 | steps: 82 | - uses: actions/checkout@v4 83 | with: 84 | fetch-depth: 0 85 | - name: Build Image 86 | run: | 87 | docker build . --build-arg DOCAT_VERSION=$(git describe --tags --always) --tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} 88 | docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:unstable 89 | 90 | - name: tag latest and version on release 91 | run: | 92 | docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:$(git describe --tags) 93 | docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:latest 94 | if: startsWith(github.event.ref, 'refs/tags') 95 | 96 | - name: Registry Login 97 | uses: docker/login-action@v3 98 | with: 99 | registry: ${{ matrix.registry.name }} 100 | username: ${{ matrix.registry.org }} 101 | password: ${{ secrets[matrix.registry.token] }} 102 | # Note(Fliiiix): Only login and push on main repo where the secrets are available 103 | if: "!(github.event.pull_request.head.repo.fork || github.actor == 'dependabot[bot]')" 104 | 105 | - name: Publish Image 106 | run: | 107 | docker push --all-tags ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat 108 | if: "!(github.event.pull_request.head.repo.fork || github.actor == 'dependabot[bot]')" 109 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to docat 2 | 3 | Thanks for contributing to docat! 4 | In order to keep the quality of the source-code high, 5 | please follow those rules when submitting a change. 6 | 7 | If you just want to fix a bug or make a small improvement 8 | feel free to just send a pull request. 9 | 10 | Please first discuss any big new features you wish to make via issue, email, 11 | or any other method with the owners of this repository before making a change. 12 | 13 | ## Pull Request Process 14 | 15 | Commits should be the following format 16 | 17 | ``` 18 | type(scope): commit title 19 | 20 | commit body (if any) 21 | this should document api breaks 22 | 23 | fixes # (if any) 24 | ``` 25 | 26 | Type could be one of *feat, docs, fix, ...* and scope could be *docat, web, ...* 27 | you don't have to provide a scope when the change is for the whole repository like README updates. 28 | 29 | Execute linters by running `make lint` in the back-end or `yarn lint`. 30 | 31 | A pull request will only be merged when the pipeline runs through. 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # building frontend 2 | FROM node:22-slim AS frontend 3 | WORKDIR /app/frontend 4 | 5 | COPY web/package.json web/yarn.lock ./ 6 | RUN yarn install --frozen-lockfile 7 | 8 | # fix docker not following symlinks 9 | COPY web ./ 10 | COPY doc/getting-started.md ./src/assets/ 11 | 12 | ARG DOCAT_VERSION=unknown 13 | ENV VITE_DOCAT_VERSION=$DOCAT_VERSION 14 | 15 | RUN yarn build 16 | 17 | # setup Python 18 | FROM python:3.12-slim AS backend 19 | 20 | # configure docker container 21 | ENV PYTHONDONTWRITEBYTECODE=1 \ 22 | # make poetry create the virtual environment in the project's root 23 | # it gets named `.venv` 24 | POETRY_VIRTUALENVS_IN_PROJECT=true \ 25 | # do not ask any interactive question 26 | POETRY_NO_INTERACTION=1 27 | 28 | RUN python -m pip install --upgrade pip 29 | RUN python -m pip install poetry==1.7.1 30 | COPY /docat/pyproject.toml /docat/poetry.lock /app/ 31 | 32 | # Install the application 33 | WORKDIR /app/docat 34 | RUN poetry install --no-root --no-ansi --only main 35 | 36 | # production 37 | FROM python:3.12-slim 38 | 39 | # defaults 40 | ENV MAX_UPLOAD_SIZE=100M 41 | 42 | # set up the system 43 | RUN apt-get update && \ 44 | apt-get install --yes nginx dumb-init libmagic1 gettext && \ 45 | rm -rf /var/lib/apt/lists/* 46 | 47 | RUN mkdir -p /var/docat/doc 48 | 49 | # install the application 50 | RUN mkdir -p /var/www/html 51 | COPY --from=frontend /app/frontend/dist /var/www/html 52 | COPY docat /app/docat 53 | WORKDIR /app/docat 54 | 55 | # Copy the build artifact (.venv) 56 | COPY --from=backend /app /app/docat 57 | 58 | ENTRYPOINT ["/usr/bin/dumb-init", "--"] 59 | 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"] 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 https://github.com/docat-org/docat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![docat](doc/assets/docat-teaser.png) 2 | 3 | **Host your docs. Simple. Versioned. Fancy.** 4 | 5 | [![build](https://github.com/docat-org/docat/workflows/docat%20ci/badge.svg)](https://github.com/docat-org/docat/actions) 6 | [![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) 7 | 8 | ## Why DOCAT? 9 | 10 | When generating static documentation using 11 | [mkdocs](https://www.mkdocs.org/), [sphinx](http://www.sphinx-doc.org/en/master/), ... 12 | hosting just one version of the docs might not be enough. 13 | Many users might still use older versions and might need to read 14 | those versions of the documentation. 15 | 16 | Docat solves this problem by providing a simple tool that 17 | hosts multiple documentation projects with multiple versions. 18 | 19 | *The main design decision with docat was to keep the tool as simple as possible.* 20 | 21 | ## Getting started 22 | 23 | The simplest way to get started is to run the docker container, 24 | you can optionally use volumes to persist state: 25 | 26 | ```sh 27 | # run container in background and persist data (docs, nginx configs and tokens database) 28 | # use 'ghcr.io/docat-org/docat:unstable' to get the latest changes 29 | mkdir -p docat-run/doc 30 | docker run \ 31 | --detach \ 32 | --volume $PWD/docat-run:/var/docat/ \ 33 | --publish 8000:80 \ 34 | ghcr.io/docat-org/docat 35 | ``` 36 | 37 | Go to [localhost:8000](http://localhost:8000) to view your docat instance: 38 | 39 | 40 | 41 | ### Using DOCAT 42 | 43 | > 🛈 Please note that docat does not provide any way to write documentation. 44 | > It's sole responsibility is to host documentation. 45 | > 46 | > There are many awesome tools to write documenation: 47 | > - [mkdocs](https://www.mkdocs.org/) 48 | > - [sphinx](http://www.sphinx-doc.org/en/master/) 49 | > - [mdbook](https://rust-lang.github.io/mdBook/) 50 | > - ... 51 | 52 | 53 | A CLI tool called [docatl](https://github.com/docat-org/docatl) is available 54 | for easy interaction with the docat server. 55 | However, interacting with docat can also be done through [`curl`](doc/getting-started.md). 56 | 57 | To push documentation (and tag as `latest`) in the folder `docs/` simply run: 58 | 59 | ```sh 60 | docatl push --host http://localhost:8000 ./docs PROJECT VERSION --tag latest 61 | ``` 62 | 63 | More detailed instructions can be found in the [**getting started guide**](doc/getting-started.md). 64 | 65 | ## Authentication 66 | 67 | By default, anyone can upload new documentation or add a new version to documentation. 68 | A project can be claimed. A claim returns a token that then must be used 69 | to add or delete versions. 70 | 71 | When hosting docat publicly, it is recommended to use 72 | [http basic auth](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/) 73 | for all `POST`/`PUT` and `DELETE` http calls. 74 | 75 |
76 | docat http basic authentication example 77 | 78 | This example shows how to configure the NGINX inside the docker image 79 | to be password protected using http basic auth. 80 | 81 | 1) Create your [`.htpasswd` file](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/#creating-a-password-file). 82 | 2) And a custom `default` NGINX config: 83 | 84 | ``` 85 | upstream python_backend { 86 | server 127.0.0.1:5000; 87 | } 88 | 89 | server { 90 | listen 80 default_server; 91 | listen [::]:80 default_server; 92 | 93 | root /var/www/html; 94 | 95 | add_header Content-Security-Policy "frame-ancestors 'self';"; 96 | index index.html index.htm index.pdf /index.html; 97 | 98 | server_name _; 99 | 100 | location /doc { 101 | root /var/docat; 102 | } 103 | 104 | location /api { 105 | limit_except GET HEAD { 106 | auth_basic 'Restricted'; 107 | auth_basic_user_file /etc/nginx/.htpasswd; 108 | } 109 | 110 | client_max_body_size $MAX_UPLOAD_SIZE; 111 | proxy_pass http://python_backend; 112 | } 113 | 114 | location / { 115 | try_files $uri $uri/ =404; 116 | } 117 | } 118 | ``` 119 | 120 | 1) Mounted to the correct location inside the container: 121 | 122 | ``` 123 | docker run \ 124 | --detach \ 125 | --volume $PWD/docat-run:/var/docat/ \ 126 | --volume $PWD/nginx/default:/app/docat/docat/nginx/default \ 127 | --volume $PWD/nginx/.htpasswd:/etc/nginx/.htpasswd \ 128 | --publish 8000:80 \ 129 | ghcr.io/docat-org/docat 130 | ``` 131 |
132 | 133 | ## Configuring DOCAT 134 | 135 | #### Frontend Config 136 | 137 | It is possible to configure some things after the fact. 138 | 139 | 1. Create a `config.json` file 140 | 2. Mount it inside your docker container `--volume $PWD/config.json:/var/docat/doc/config.json` 141 | 142 | Supported config options: 143 | 144 | ```json 145 | { 146 | "headerHTML": "

Custom Header HTML!

", 147 | "footerHTML": "CONTACT: Maintainers" 148 | } 149 | ``` 150 | 151 | #### System Config 152 | 153 | Further proxy configurations can be done through the following environmental variables: 154 | 155 | | Variable | Default | Description | 156 | |---|---|---| 157 | | `MAX_UPLOAD_SIZE` | [100M](./Dockerfile) | Limits the size of individual archives posted to the API | 158 | 159 | 160 | ## Local Development 161 | 162 | For local development, first configure and start the backend (inside the `docat/` folder): 163 | 164 | ```sh 165 | # create a folder for local development (uploading docs) 166 | DEV_DOCAT_PATH="$(mktemp -d)" 167 | 168 | # install dependencies 169 | poetry install 170 | 171 | # run the local development version 172 | DOCAT_SERVE_FILES=1 DOCAT_STORAGE_PATH="$DEV_DOCAT_PATH" poetry run python -m docat 173 | ``` 174 | 175 | After this you need to start the frontend (inside the `web/` folder): 176 | 177 | ```sh 178 | # install dependencies 179 | yarn install --frozen-lockfile 180 | 181 | # run the web app 182 | yarn serve 183 | ``` 184 | 185 | For more advanced options, have a look at the 186 | [backend](docat/README.md) and [web](web/README.md) docs. 187 | -------------------------------------------------------------------------------- /doc/assets/docat-teaser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docat-org/docat/9837dc0d3f0db303a0706dfcc5b2a3533bc45407/doc/assets/docat-teaser.png -------------------------------------------------------------------------------- /doc/assets/docat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docat-org/docat/9837dc0d3f0db303a0706dfcc5b2a3533bc45407/doc/assets/docat.gif -------------------------------------------------------------------------------- /doc/getting-started.md: -------------------------------------------------------------------------------- 1 | ## Getting Started with DOCAT 2 | 3 | 4 | 5 | 6 | ### Using `docatl`, the docat CLI 🙀 7 | 8 | The most convenient way to interact with docat is with it's official CLI tool, 9 | [docatl](https://github.com/docat-org/docatl). 10 | 11 | You can download a standalone binary of the latest release for your platform 12 | [here](https://github.com/docat-org/docatl/releases/latest) or 13 | [use go](https://github.com/docat-org/docatl#using-go) or 14 | [docker](https://github.com/docat-org/docatl#using-docker) to install it. 15 | 16 | The commands below contain examples both using `curl` and `docatl`. 17 | Do note that the host address and api-key can be omitted if specified in a `.docatl.yml` file. 18 | See the [docatl documentation](https://github.com/docat-org/docatl/blob/main/README.md) for more information. 19 | 20 | Use `docatl --help` to discover all commands available to manage your `docat` documentation! 21 | 22 | ### API endpoints 23 | 24 | The following sections document the RAW API endpoints you can `curl`. 25 | 26 | The API specification is exposed as an [OpenAPI Documentation](/api/v1/openapi.json), 27 | via Swagger UI at [/api/docs](/api/docs) and 28 | as a pure documentation with redoc at [/api/redoc](/api/redoc). 29 | 30 | #### Upload your documentation 31 | 32 | You can upload any static HTML page by zipping it and uploading the zip file. 33 | 34 | > Note: if an `index.html` file is present in the root of the zip file 35 | it will be served automatically. 36 | 37 | For example to upload the file `docs.zip` as version `1.0.0` for `awesome-project` using `curl`: 38 | 39 | ```sh 40 | curl -X POST -F "file=@docs.zip" http://localhost:8000/api/awesome-project/1.0.0 41 | ``` 42 | 43 | Using `docatl`: 44 | 45 | ```sh 46 | docatl push docs.zip awesome-project 1.0.0 --host http://localhost:8000 47 | ``` 48 | 49 | Any file type can be uploaded. To view an uploaded pdf, specify it's full path: 50 | 51 | `http://localhost:8000/awesome-project/1.0.0/my_awesome.pdf` 52 | 53 | You can also manually upload your documentation. 54 | A very simple web form can be found under [upload](/upload). 55 | 56 | #### Tag documentation 57 | 58 | After uploading you can tag a specific version. This can be useful when 59 | the latest version should be available as `http://localhost:8000/docs/awesome-project/latest` 60 | 61 | To tag the version `1.0.0` as `latest` for `awesome-project`: 62 | 63 | ```sh 64 | curl -X PUT http://localhost:8000/api/awesome-project/1.0.0/tags/latest 65 | ``` 66 | 67 | Using `docatl`: 68 | 69 | ```sh 70 | docatl tag awesome-project 1.0.0 latest --host http://localhost:8000 71 | ``` 72 | 73 | #### Claim Project 74 | 75 | Claiming a Project returns a `token` which can be used for actions 76 | which require authentication (for example for deleting a version). 77 | Each Project can be claimed **exactly once**, so best store the token safely. 78 | 79 | ```sh 80 | curl -X GET http://localhost:8000/api/awesome-project/claim 81 | ``` 82 | 83 | Using `docatl`: 84 | 85 | ```sh 86 | docatl claim awesome-project --host http://localhost:8000 87 | ``` 88 | 89 | #### Authentication 90 | 91 | To make an authenticated call, specify a header with the key `Docat-Api-Key` and your token as the value: 92 | 93 | ```sh 94 | curl -X DELETE --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/1.0.0 95 | ``` 96 | 97 | Using `docatl`: 98 | 99 | ```sh 100 | docatl delete awesome-project 1.0.0 --host http://localhost:8000 --api-key 101 | ``` 102 | 103 | #### Delete Version 104 | 105 | To delete a Project version you need to be authenticated. 106 | 107 | To remove the version `1.0.0` from `awesome-project`: 108 | 109 | ```sh 110 | curl -X DELETE --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/1.0.0 111 | ``` 112 | 113 | Using `docatl`: 114 | 115 | ```sh 116 | docatl delete awesome-project 1.0.0 --host http://localhost:8000 --api-key 117 | ``` 118 | 119 | #### Upload Project Icon 120 | 121 | To upload a icon, you don't need a token, except if you want to replace an existing icon. 122 | 123 | To set `example-image.png` as the icon for `awesome-project`, which already has an icon: 124 | 125 | ```sh 126 | curl -X POST -F "file=@example-image.png" --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/icon 127 | ``` 128 | 129 | Using `docatl`: 130 | 131 | ```sh 132 | docatl push-icon awesome-project example-image.png --host http://localhost:8000 --api-key 133 | ``` 134 | 135 | #### Rename a Project 136 | 137 | To rename a Project, you need a token. 138 | 139 | To rename `awesome-project` to `new-awesome-project`: 140 | 141 | ```sh 142 | curl -X PUT --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/rename/new-awesome-project 143 | ``` 144 | 145 | Using `docatl`: 146 | 147 | ```sh 148 | docatl rename awesome-project new-awesome-project --host http://localhost:8000 --api-key 149 | ``` 150 | 151 | #### Hide a Version 152 | 153 | If you want to hide a version from the version select as well as the search results, 154 | you can hide it. You need to be authenticated to do this. 155 | 156 | To hide version `0.0.1` of `awesome-project`: 157 | 158 | ```sh 159 | curl -X POST --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/0.0.1/hide 160 | ``` 161 | 162 | Using `docatl`: 163 | 164 | ```sh 165 | docatl hide awesome-project 0.0.1 --host http://localhost:8000 --api-key 166 | ``` 167 | 168 | #### Show a Version 169 | 170 | This is the reverse of `hide`, and also requires a token. 171 | 172 | To show version `0.0.1` of `awesome-project` again: 173 | 174 | ```sh 175 | curl -X POST --header "Docat-Api-Key: " http://localhost:8000/api/awesome-project/0.0.1/show 176 | ``` 177 | 178 | Using `docatl`: 179 | 180 | ```sh 181 | docatl show awesome-project 0.0.1 --host http://localhost:8000 --api-key 182 | ``` 183 | -------------------------------------------------------------------------------- /docat/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | env 3 | __pycache__ 4 | upload 5 | .tox 6 | .coverage 7 | db.json 8 | .python-version 9 | -------------------------------------------------------------------------------- /docat/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: format lint typing pytest 3 | 4 | format: 5 | poetry run ruff check --fix 6 | poetry run ruff format 7 | lint: 8 | poetry run ruff check 9 | typing: 10 | poetry run mypy . 11 | pytest: 12 | poetry run pytest 13 | -------------------------------------------------------------------------------- /docat/README.md: -------------------------------------------------------------------------------- 1 | # docat backend 2 | 3 | The backend hosts the documentation and an api to push documentation and 4 | tag versions of the documentation. 5 | 6 | ## development enviroment 7 | 8 | You will need to install [poetry](https://python-poetry.org/docs/#installation) `pip install poetry==1.7.1`. 9 | 10 | Install the dependencies and run the application: 11 | 12 | ```sh 13 | # install dependencies 14 | poetry install 15 | # run the app 16 | [DOCAT_SERVE_FILES=1] [FLASK_DEBUG=1] [PORT=8888] poetry run python -m docat 17 | ``` 18 | 19 | ### Config Options 20 | 21 | * **DOCAT_SERVE_FILES**: Serve static documentation instead of a nginx (for testing) 22 | * **DOCAT_STORAGE_PATH**: Upload directory for static files (needs to match nginx config) 23 | * **FLASK_DEBUG**: Start flask in debug mode 24 | 25 | ## Usage 26 | 27 | See [getting-started.md](../doc/getting-started.md) 28 | -------------------------------------------------------------------------------- /docat/docat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docat-org/docat/9837dc0d3f0db303a0706dfcc5b2a3533bc45407/docat/docat/__init__.py -------------------------------------------------------------------------------- /docat/docat/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import uvicorn 4 | 5 | from docat.app import app 6 | 7 | if __name__ == "__main__": 8 | try: 9 | port = int(os.environ.get("PORT", 5000)) 10 | except ValueError: 11 | port = 5000 12 | 13 | uvicorn.run(app, host="0.0.0.0", port=port) 14 | -------------------------------------------------------------------------------- /docat/docat/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | docat 3 | ~~~~~ 4 | 5 | Host your docs. Simple. Versioned. Fancy. 6 | 7 | :copyright: (c) 2019 by docat, https://github.com/docat-org/docat 8 | :license: MIT, see LICENSE for more details. 9 | """ 10 | 11 | import logging 12 | import os 13 | import secrets 14 | import shutil 15 | from contextlib import asynccontextmanager 16 | from pathlib import Path 17 | from typing import Optional 18 | 19 | import magic 20 | from fastapi import APIRouter, Depends, FastAPI, File, Header, Response, UploadFile, status 21 | from fastapi.staticfiles import StaticFiles 22 | from starlette.responses import JSONResponse 23 | from tinydb import Query, TinyDB 24 | 25 | from docat.models import ApiResponse, ClaimResponse, ProjectDetail, Projects, Stats, TokenStatus 26 | from docat.utils import ( 27 | DB_PATH, 28 | UPLOAD_FOLDER, 29 | calculate_token, 30 | create_symlink, 31 | extract_archive, 32 | get_all_projects, 33 | get_dir_size, 34 | get_project_details, 35 | get_system_stats, 36 | is_forbidden_project_name, 37 | remove_docs, 38 | ) 39 | 40 | DOCAT_STORAGE_PATH = Path(os.getenv("DOCAT_STORAGE_PATH", Path("/var/docat"))) 41 | DOCAT_DB_PATH = DOCAT_STORAGE_PATH / DB_PATH 42 | DOCAT_UPLOAD_FOLDER = DOCAT_STORAGE_PATH / UPLOAD_FOLDER 43 | 44 | logger = logging.getLogger(__name__) 45 | 46 | 47 | @asynccontextmanager 48 | async def lifespan(_: FastAPI): 49 | # Create the folders if they don't exist 50 | DOCAT_UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True) 51 | yield 52 | 53 | 54 | def get_db() -> TinyDB: 55 | """Return the cached TinyDB instance.""" 56 | return TinyDB(DOCAT_DB_PATH) 57 | 58 | 59 | #: Holds the FastAPI application 60 | app = FastAPI( 61 | title="docat", 62 | description="API for docat, https://github.com/docat-org/docat", 63 | openapi_url="/api/v1/openapi.json", 64 | docs_url="/api/docs", 65 | redoc_url="/api/redoc", 66 | lifespan=lifespan, 67 | ) 68 | router = APIRouter() 69 | 70 | 71 | @router.get("/api/stats", response_model=Stats, status_code=status.HTTP_200_OK) 72 | def get_stats(): 73 | if not DOCAT_UPLOAD_FOLDER.exists(): 74 | return Projects(projects=[]) 75 | return get_system_stats(DOCAT_UPLOAD_FOLDER) 76 | 77 | 78 | @router.get("/api/projects", response_model=Projects, status_code=status.HTTP_200_OK) 79 | def get_projects(include_hidden: bool = False): 80 | if not DOCAT_UPLOAD_FOLDER.exists(): 81 | return Projects(projects=[]) 82 | return get_all_projects(DOCAT_UPLOAD_FOLDER, include_hidden) 83 | 84 | 85 | @router.get( 86 | "/api/projects/{project}", 87 | response_model=ProjectDetail, 88 | status_code=status.HTTP_200_OK, 89 | responses={status.HTTP_404_NOT_FOUND: {"model": ApiResponse}}, 90 | ) 91 | def get_project(project, include_hidden: bool = False): 92 | details = get_project_details(DOCAT_UPLOAD_FOLDER, project, include_hidden) 93 | 94 | if not details: 95 | return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={"message": f"Project {project} does not exist"}) 96 | 97 | return details 98 | 99 | 100 | @router.post("/api/{project}/icon", response_model=ApiResponse, status_code=status.HTTP_200_OK) 101 | def upload_icon( 102 | project: str, 103 | response: Response, 104 | file: UploadFile = File(...), 105 | docat_api_key: Optional[str] = Header(None), 106 | db: TinyDB = Depends(get_db), 107 | ): 108 | project_base_path = DOCAT_UPLOAD_FOLDER / project 109 | icon_path = project_base_path / "logo" 110 | 111 | if not project_base_path.exists(): 112 | response.status_code = status.HTTP_404_NOT_FOUND 113 | return ApiResponse(message=f"Project {project} not found") 114 | 115 | mime_type_checker = magic.Magic(mime=True) 116 | mime_type = mime_type_checker.from_buffer(file.file.read()) 117 | 118 | if not mime_type.startswith("image/"): 119 | response.status_code = status.HTTP_400_BAD_REQUEST 120 | return ApiResponse(message="Icon must be an image") 121 | 122 | # require a token if the project already has an icon 123 | if icon_path.is_file(): 124 | token_status = check_token_for_project(db, docat_api_key, project) 125 | if not token_status.valid: 126 | response.status_code = status.HTTP_401_UNAUTHORIZED 127 | return ApiResponse(message=token_status.reason) 128 | 129 | # remove the old icon 130 | os.remove(icon_path) 131 | 132 | # save the uploaded icon 133 | file.file.seek(0) 134 | with icon_path.open("wb") as buffer: 135 | shutil.copyfileobj(file.file, buffer) 136 | 137 | # force cache revalidation 138 | get_system_stats.cache_clear() 139 | get_dir_size.cache_clear() 140 | 141 | return ApiResponse(message="Icon successfully uploaded") 142 | 143 | 144 | @router.post("/api/{project}/{version}/hide", response_model=ApiResponse, status_code=status.HTTP_200_OK) 145 | def hide_version( 146 | project: str, 147 | version: str, 148 | response: Response, 149 | docat_api_key: Optional[str] = Header(None), 150 | db: TinyDB = Depends(get_db), 151 | ): 152 | project_base_path = DOCAT_UPLOAD_FOLDER / project 153 | version_path = project_base_path / version 154 | hidden_file = version_path / ".hidden" 155 | 156 | if not project_base_path.exists(): 157 | response.status_code = status.HTTP_404_NOT_FOUND 158 | return ApiResponse(message=f"Project {project} not found") 159 | 160 | if not version_path.exists(): 161 | response.status_code = status.HTTP_404_NOT_FOUND 162 | return ApiResponse(message=f"Version {version} not found") 163 | 164 | if hidden_file.exists(): 165 | response.status_code = status.HTTP_400_BAD_REQUEST 166 | return ApiResponse(message=f"Version {version} is already hidden") 167 | 168 | token_status = check_token_for_project(db, docat_api_key, project) 169 | if not token_status.valid: 170 | response.status_code = status.HTTP_401_UNAUTHORIZED 171 | return ApiResponse(message=token_status.reason) 172 | 173 | with open(hidden_file, "w") as f: 174 | f.close() 175 | 176 | return ApiResponse(message=f"Version {version} is now hidden") 177 | 178 | 179 | @router.post("/api/{project}/{version}/show", response_model=ApiResponse, status_code=status.HTTP_200_OK) 180 | def show_version( 181 | project: str, 182 | version: str, 183 | response: Response, 184 | docat_api_key: Optional[str] = Header(None), 185 | db: TinyDB = Depends(get_db), 186 | ): 187 | project_base_path = DOCAT_UPLOAD_FOLDER / project 188 | version_path = project_base_path / version 189 | hidden_file = version_path / ".hidden" 190 | 191 | if not project_base_path.exists(): 192 | response.status_code = status.HTTP_404_NOT_FOUND 193 | return ApiResponse(message=f"Project {project} not found") 194 | 195 | if not version_path.exists(): 196 | response.status_code = status.HTTP_404_NOT_FOUND 197 | return ApiResponse(message=f"Version {version} not found") 198 | 199 | if not hidden_file.exists(): 200 | response.status_code = status.HTTP_400_BAD_REQUEST 201 | return ApiResponse(message=f"Version {version} is not hidden") 202 | 203 | token_status = check_token_for_project(db, docat_api_key, project) 204 | if not token_status.valid: 205 | response.status_code = status.HTTP_401_UNAUTHORIZED 206 | return ApiResponse(message=token_status.reason) 207 | 208 | os.remove(hidden_file) 209 | 210 | return ApiResponse(message=f"Version {version} is now shown") 211 | 212 | 213 | @router.post("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED) 214 | def upload( 215 | project: str, 216 | version: str, 217 | response: Response, 218 | file: UploadFile = File(...), 219 | docat_api_key: Optional[str] = Header(None), 220 | db: TinyDB = Depends(get_db), 221 | ): 222 | if is_forbidden_project_name(project): 223 | response.status_code = status.HTTP_400_BAD_REQUEST 224 | return ApiResponse(message=f'Project name "{project}" is forbidden, as it conflicts with pages in docat web.') 225 | 226 | if file.filename is None: 227 | response.status_code = status.HTTP_400_BAD_REQUEST 228 | return ApiResponse(message="Uploaded file is None aborting upload.") 229 | 230 | project_base_path = DOCAT_UPLOAD_FOLDER / project 231 | base_path = project_base_path / version 232 | target_file = base_path / str(file.filename) 233 | 234 | if base_path.is_symlink(): 235 | # disallow overwriting of tags (symlinks) with new uploads 236 | response.status_code = status.HTTP_409_CONFLICT 237 | return ApiResponse(message="Cannot overwrite existing tag with new version.") 238 | 239 | if base_path.exists(): 240 | token_status = check_token_for_project(db, docat_api_key, project) 241 | if not token_status.valid: 242 | response.status_code = status.HTTP_401_UNAUTHORIZED 243 | return ApiResponse(message=token_status.reason) 244 | 245 | remove_docs(project, version, DOCAT_UPLOAD_FOLDER) 246 | 247 | # ensure directory for the uploaded doc exists 248 | base_path.mkdir(parents=True, exist_ok=True) 249 | 250 | # save the uploaded documentation 251 | file.file.seek(0) 252 | with target_file.open("wb") as buffer: 253 | shutil.copyfileobj(file.file, buffer) 254 | 255 | try: 256 | extract_archive(target_file, base_path) 257 | except Exception: 258 | logger.exception("Failed to unzip {target_file=}") 259 | response.status_code = status.HTTP_400_BAD_REQUEST 260 | return ApiResponse(message="Cannot extract zip file.") 261 | 262 | # force cache revalidation 263 | get_system_stats.cache_clear() 264 | get_dir_size.cache_clear() 265 | 266 | if not (base_path / "index.html").exists(): 267 | return ApiResponse(message="Documentation uploaded successfully, but no index.html found at root of archive.") 268 | 269 | return ApiResponse(message="Documentation uploaded successfully") 270 | 271 | 272 | @router.put("/api/{project}/{version}/tags/{new_tag}", response_model=ApiResponse, status_code=status.HTTP_201_CREATED) 273 | def tag(project: str, version: str, new_tag: str, response: Response): 274 | destination = DOCAT_UPLOAD_FOLDER / project / new_tag 275 | source = DOCAT_UPLOAD_FOLDER / project / version 276 | 277 | if not source.exists(): 278 | response.status_code = status.HTTP_404_NOT_FOUND 279 | return ApiResponse(message=f"Version {version} not found") 280 | 281 | if not create_symlink(version, destination): 282 | response.status_code = status.HTTP_409_CONFLICT 283 | return ApiResponse(message=f"Tag {new_tag} would overwrite an existing version!") 284 | 285 | return ApiResponse(message=f"Tag {new_tag} -> {version} successfully created") 286 | 287 | 288 | @router.get( 289 | "/api/{project}/claim", 290 | response_model=ClaimResponse, 291 | status_code=status.HTTP_201_CREATED, 292 | responses={status.HTTP_409_CONFLICT: {"model": ApiResponse}}, 293 | ) 294 | def claim(project: str, db: TinyDB = Depends(get_db)): 295 | Project = Query() 296 | table = db.table("claims") 297 | result = table.search(Project.name == project) 298 | if result: 299 | return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={"message": f"Project {project} is already claimed!"}) 300 | 301 | token = secrets.token_hex(16) 302 | salt = os.urandom(32) 303 | token_hash = calculate_token(token, salt) 304 | table.insert({"name": project, "token": token_hash, "salt": salt.hex()}) 305 | 306 | return ClaimResponse(message=f"Project {project} successfully claimed", token=token) 307 | 308 | 309 | @router.put("/api/{project}/rename/{new_project_name}", response_model=ApiResponse, status_code=status.HTTP_200_OK) 310 | def rename( 311 | project: str, 312 | new_project_name: str, 313 | response: Response, 314 | docat_api_key: str = Header(None), 315 | db: TinyDB = Depends(get_db), 316 | ): 317 | if is_forbidden_project_name(new_project_name): 318 | response.status_code = status.HTTP_400_BAD_REQUEST 319 | return ApiResponse(message=f'New project name "{new_project_name}" is forbidden, as it conflicts with pages in docat web.') 320 | 321 | project_base_path = DOCAT_UPLOAD_FOLDER / project 322 | new_project_base_path = DOCAT_UPLOAD_FOLDER / new_project_name 323 | 324 | if not project_base_path.exists(): 325 | response.status_code = status.HTTP_404_NOT_FOUND 326 | return ApiResponse(message=f"Project {project} not found") 327 | 328 | if new_project_base_path.exists(): 329 | response.status_code = status.HTTP_409_CONFLICT 330 | return ApiResponse(message=f"New project name {new_project_name} already in use") 331 | 332 | token_status = check_token_for_project(db, docat_api_key, project) 333 | if not token_status.valid: 334 | response.status_code = status.HTTP_401_UNAUTHORIZED 335 | return ApiResponse(message=token_status.reason) 336 | 337 | # update the claim to the new project name 338 | Project = Query() 339 | claims_table = db.table("claims") 340 | claims_table.update({"name": new_project_name}, Project.name == project) 341 | 342 | os.rename(project_base_path, new_project_base_path) 343 | 344 | response.status_code = status.HTTP_200_OK 345 | return ApiResponse(message=f"Successfully renamed project {project} to {new_project_name}") 346 | 347 | 348 | @router.delete("/api/{project}/{version}", response_model=ApiResponse, status_code=status.HTTP_200_OK) 349 | def delete( 350 | project: str, 351 | version: str, 352 | response: Response, 353 | docat_api_key: str = Header(None), 354 | db: TinyDB = Depends(get_db), 355 | ): 356 | token_status = check_token_for_project(db, docat_api_key, project) 357 | if not token_status.valid: 358 | response.status_code = status.HTTP_401_UNAUTHORIZED 359 | return ApiResponse(message=token_status.reason) 360 | 361 | message = remove_docs(project, version, DOCAT_UPLOAD_FOLDER) 362 | if message: 363 | response.status_code = status.HTTP_404_NOT_FOUND 364 | return ApiResponse(message=message) 365 | 366 | # force cache revalidation 367 | get_system_stats.cache_clear() 368 | get_dir_size.cache_clear() 369 | 370 | return ApiResponse(message=f"Successfully deleted version '{version}'") 371 | 372 | 373 | def check_token_for_project(db, token, project) -> TokenStatus: 374 | Project = Query() 375 | table = db.table("claims") 376 | result = table.search(Project.name == project) 377 | 378 | if result and token: 379 | token_hash = calculate_token(token, bytes.fromhex(result[0]["salt"])) 380 | if result[0]["token"] == token_hash: 381 | return TokenStatus(True, "Docat-Api-Key token is valid") 382 | else: 383 | return TokenStatus(False, f"Docat-Api-Key token is not valid for {project}") 384 | else: 385 | return TokenStatus(False, f"Please provide a header with a valid Docat-Api-Key token for {project}") 386 | 387 | 388 | # serve_local_docs for local testing without a nginx 389 | if os.environ.get("DOCAT_SERVE_FILES"): 390 | DOCAT_UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True) 391 | app.mount("/doc", StaticFiles(directory=DOCAT_UPLOAD_FOLDER, html=True), name="docs") 392 | 393 | app.include_router(router) 394 | -------------------------------------------------------------------------------- /docat/docat/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | 4 | from pydantic import BaseModel 5 | 6 | 7 | @dataclass(frozen=True) 8 | class TokenStatus: 9 | valid: bool 10 | reason: str 11 | 12 | 13 | class ApiResponse(BaseModel): 14 | message: str 15 | 16 | 17 | class ClaimResponse(ApiResponse): 18 | token: str 19 | 20 | 21 | class ProjectVersion(BaseModel): 22 | name: str 23 | timestamp: datetime 24 | tags: list[str] 25 | hidden: bool 26 | 27 | 28 | class Project(BaseModel): 29 | name: str 30 | logo: bool 31 | storage: str 32 | versions: list[ProjectVersion] 33 | 34 | 35 | class Projects(BaseModel): 36 | projects: list[Project] 37 | 38 | 39 | class Stats(BaseModel): 40 | n_projects: int 41 | n_versions: int 42 | storage: str 43 | 44 | 45 | class ProjectDetail(BaseModel): 46 | name: str 47 | storage: str 48 | versions: list[ProjectVersion] 49 | -------------------------------------------------------------------------------- /docat/docat/nginx/default: -------------------------------------------------------------------------------- 1 | upstream python_backend { 2 | server 127.0.0.1:5000; 3 | } 4 | 5 | server { 6 | listen 80 default_server; 7 | listen [::]:80 default_server; 8 | 9 | root /var/www/html; 10 | 11 | add_header Content-Security-Policy "frame-ancestors 'self';"; 12 | index index.html index.htm index.pdf /index.html; 13 | 14 | server_name _; 15 | 16 | location /doc { 17 | root /var/docat; 18 | absolute_redirect off; 19 | } 20 | 21 | location /api { 22 | client_max_body_size $MAX_UPLOAD_SIZE; 23 | proxy_pass http://python_backend; 24 | } 25 | 26 | location / { 27 | try_files $uri $uri/ /index.html =404; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /docat/docat/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | docat utilities 3 | """ 4 | 5 | import hashlib 6 | import os 7 | import shutil 8 | from datetime import datetime 9 | from functools import cache 10 | from pathlib import Path 11 | from zipfile import ZipFile, ZipInfo 12 | 13 | from docat.models import Project, ProjectDetail, Projects, ProjectVersion, Stats 14 | 15 | NGINX_CONFIG_PATH = Path("/etc/nginx/locations.d") 16 | UPLOAD_FOLDER = "doc" 17 | DB_PATH = "db.json" 18 | 19 | 20 | def is_dir(self): 21 | """Return True if this archive member is a directory.""" 22 | if self.filename.endswith("/"): 23 | return True 24 | # The ZIP format specification requires to use forward slashes 25 | # as the directory separator, but in practice some ZIP files 26 | # created on Windows can use backward slashes. For compatibility 27 | # with the extraction code which already handles this: 28 | if os.path.altsep: 29 | return self.filename.endswith((os.path.sep, os.path.altsep)) 30 | return False 31 | 32 | 33 | # Patch is_dir to allow windows zip files to be 34 | # extracted correctly 35 | # see: https://github.com/python/cpython/issues/117084 36 | ZipInfo.is_dir = is_dir # type: ignore[method-assign] 37 | 38 | 39 | def create_symlink(source, destination): 40 | """ 41 | Create a symlink from source to destination, if the 42 | destination is already a symlink, it will be overwritten. 43 | 44 | Args: 45 | source (pathlib.Path): path to the source 46 | destination (pathlib.Path): path to the destination 47 | """ 48 | if not destination.exists() or (destination.exists() and destination.is_symlink()): 49 | if destination.is_symlink(): 50 | destination.unlink() # overwrite existing tag 51 | destination.symlink_to(source) 52 | return True 53 | else: 54 | return False 55 | 56 | 57 | def extract_archive(target_file, destination): 58 | """ 59 | Extracts the given archive to the directory 60 | and deletes the source afterwards. 61 | 62 | Args: 63 | target_file (pathlib.Path): target archive 64 | destination: (pathlib.Path): destination of the extracted archive 65 | """ 66 | if target_file.suffix == ".zip": 67 | # this is required to extract zip files created 68 | # on windows machines (https://stackoverflow.com/a/52091659/12356463) 69 | os.path.altsep = "\\" 70 | with ZipFile(target_file, "r") as zipf: 71 | zipf.extractall(path=destination) 72 | target_file.unlink() # remove the zip file 73 | 74 | 75 | def remove_docs(project: str, version: str, upload_folder_path: Path): 76 | """ 77 | Delete documentation 78 | 79 | Args: 80 | project (str): name of the project 81 | version (str): project version 82 | """ 83 | docs = upload_folder_path / project / version 84 | if docs.exists(): 85 | # remove the requested version 86 | # rmtree can not remove a symlink 87 | if docs.is_symlink(): 88 | docs.unlink() 89 | else: 90 | shutil.rmtree(docs) 91 | 92 | # remove dead symlinks 93 | for link in (s for s in docs.parent.iterdir() if s.is_symlink()): 94 | if not link.resolve().exists(): 95 | link.unlink() 96 | 97 | # remove size info 98 | (upload_folder_path / project / ".size").unlink(missing_ok=True) 99 | 100 | # remove empty projects 101 | if not [d for d in docs.parent.iterdir() if d.is_dir()]: 102 | docs.parent.rmdir() 103 | nginx_config = NGINX_CONFIG_PATH / f"{project}-doc.conf" 104 | if nginx_config.exists(): 105 | nginx_config.unlink() 106 | else: 107 | return f"Could not find version '{docs}'" 108 | 109 | 110 | def calculate_token(password, salt): 111 | """ 112 | Wrapper function for pbkdf2_hmac to ensure consistent use of 113 | hash digest algorithm and iteration count. 114 | 115 | Args: 116 | password (str): the password to hash 117 | salt (byte): the salt used for the password 118 | """ 119 | return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100000).hex() 120 | 121 | 122 | def is_forbidden_project_name(name: str) -> bool: 123 | """ 124 | Checks if the given project name is forbidden. 125 | The project name is forbidden if it conflicts with 126 | a page on the docat website. 127 | """ 128 | name = name.lower().strip() 129 | return name in ["upload", "claim", "delete", "help", "doc", "api"] 130 | 131 | 132 | UNITS_MAPPING = [ 133 | (1 << 50, " PB"), 134 | (1 << 40, " TB"), 135 | (1 << 30, " GB"), 136 | (1 << 20, " MB"), 137 | (1 << 10, " KB"), 138 | (1, " byte"), 139 | ] 140 | 141 | 142 | def readable_size(bytes: int) -> str: 143 | """ 144 | Get human-readable file sizes. 145 | simplified version of https://pypi.python.org/pypi/hurry.filesize/ 146 | 147 | https://stackoverflow.com/a/12912296/12356463 148 | """ 149 | size_suffix = "" 150 | for factor, suffix in UNITS_MAPPING: 151 | if bytes >= factor: 152 | size_suffix = suffix 153 | break 154 | 155 | amount = int(bytes / factor) 156 | if size_suffix == " byte" and amount > 1: 157 | size_suffix = size_suffix + "s" 158 | 159 | if amount == 0: 160 | size_suffix = " bytes" 161 | 162 | return str(amount) + size_suffix 163 | 164 | 165 | @cache 166 | def get_dir_size(path: Path) -> int: 167 | """ 168 | Calculate the total size of a directory. 169 | 170 | Results are cached (memoizing) by path. 171 | """ 172 | total = 0 173 | with os.scandir(path) as it: 174 | for entry in it: 175 | if entry.is_file(): 176 | total += entry.stat().st_size 177 | elif entry.is_dir(): 178 | total += get_dir_size(entry.path) 179 | return total 180 | 181 | 182 | @cache 183 | def get_system_stats(upload_folder_path: Path) -> Stats: 184 | """ 185 | Return all docat statistics. 186 | 187 | Results are cached (memoizing) by path. 188 | """ 189 | 190 | dirs = 0 191 | versions = 0 192 | size = 0 193 | # Note: Not great nesting with the deep nesting 194 | # but it needs to run fast, consider speed when refactoring! 195 | with os.scandir(upload_folder_path) as root: 196 | for f in root: 197 | if f.is_dir(): 198 | dirs += 1 199 | with os.scandir(f.path) as project: 200 | for v in project: 201 | if v.is_dir() and not v.is_symlink(): 202 | size += get_dir_size(v.path) 203 | versions += 1 204 | 205 | return Stats( 206 | n_projects=dirs, 207 | n_versions=versions, 208 | storage=readable_size(size), 209 | ) 210 | 211 | 212 | def get_all_projects(upload_folder_path: Path, include_hidden: bool) -> Projects: 213 | """ 214 | Returns all projects in the upload folder. 215 | """ 216 | projects: list[Project] = [] 217 | 218 | for project in sorted(upload_folder_path.iterdir()): 219 | if not project.is_dir(): 220 | continue 221 | 222 | details = get_project_details(upload_folder_path, project.name, include_hidden) 223 | 224 | if details is None: 225 | continue 226 | 227 | if len(details.versions) < 1: 228 | continue 229 | 230 | project_name = str(project.relative_to(upload_folder_path)) 231 | project_has_logo = (upload_folder_path / project / "logo").exists() 232 | projects.append( 233 | Project( 234 | name=project_name, 235 | logo=project_has_logo, 236 | versions=details.versions, 237 | storage=readable_size(get_dir_size(upload_folder_path / project)), 238 | ) 239 | ) 240 | 241 | return Projects(projects=projects) 242 | 243 | 244 | def get_version_timestamp(version_folder: Path) -> datetime: 245 | """ 246 | Returns the timestamp of a version 247 | """ 248 | return datetime.fromtimestamp(version_folder.stat().st_ctime) 249 | 250 | 251 | def get_project_details(upload_folder_path: Path, project_name: str, include_hidden: bool) -> ProjectDetail | None: 252 | """ 253 | Returns all versions and tags for a project. 254 | """ 255 | docs_folder = upload_folder_path / project_name 256 | 257 | if not docs_folder.exists(): 258 | return None 259 | 260 | tags = [x for x in docs_folder.iterdir() if x.is_dir() and x.is_symlink()] 261 | 262 | def should_include(name: str) -> bool: 263 | if include_hidden: 264 | return True 265 | 266 | return not (docs_folder / name / ".hidden").exists() 267 | 268 | return ProjectDetail( 269 | name=project_name, 270 | storage=readable_size(get_dir_size(docs_folder)), 271 | versions=sorted( 272 | [ 273 | ProjectVersion( 274 | name=str(x.relative_to(docs_folder)), 275 | tags=[str(t.relative_to(docs_folder)) for t in tags if t.resolve() == x], 276 | timestamp=get_version_timestamp(x), 277 | hidden=(docs_folder / x.name / ".hidden").exists(), 278 | ) 279 | for x in docs_folder.iterdir() 280 | if x.is_dir() and not x.is_symlink() and should_include(x.name) 281 | ], 282 | key=lambda k: k.name, 283 | reverse=True, 284 | ), 285 | ) 286 | -------------------------------------------------------------------------------- /docat/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "docat" 3 | version = "0.0.0" 4 | description = "Host your docs. Simple. Versioned. Fancy." 5 | authors = ["Felix ", "Benj "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.10" 10 | tinydb = "^4.8.1" 11 | fastapi = {version = "^0.115.0", extras = ["all"]} 12 | python-multipart = "^0.0.12" 13 | uvicorn = "^0.30.1" 14 | python-magic = "^0.4.27" 15 | 16 | [tool.poetry.dev-dependencies] 17 | ruff = "^0.6.9" 18 | pytest = "^8.3.3" 19 | pytest-cov = "^5.0.0" 20 | requests = "^2.32.3" 21 | mypy = "^1.11.2" 22 | 23 | [tool.pytest.ini_options] 24 | minversion = "6.0" 25 | addopts = "--ff -ra -v" 26 | testpaths = [ 27 | "tests" 28 | ] 29 | 30 | [build-system] 31 | requires = ["poetry-core>=1.0.0"] 32 | build-backend = "poetry.core.masonry.api" 33 | 34 | [[tool.mypy.overrides]] 35 | module = [ 36 | "tinydb", 37 | "tinydb.storages", 38 | "uvicorn" 39 | ] 40 | ignore_missing_imports = true 41 | 42 | [tool.ruff] 43 | line-length = 140 44 | # Rule descriptions: https://docs.astral.sh/ruff/rules/ 45 | lint.select = ["I", "E", "B", "F", "W", "N", "C4", "C90", "ARG", "PL", "RUF", "UP"] 46 | # TODO: Should be reduct to no global exceptions 47 | lint.ignore = ["B008", "N806", "PLR0911", "PLR0913"] 48 | 49 | [tool.ruff.lint.per-file-ignores] 50 | # Ignore for all tests (Magic value used in comparison) 51 | # We use magic values in tests 52 | "tests/*" = ["PLR2004"] 53 | -------------------------------------------------------------------------------- /docat/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docat-org/docat/9837dc0d3f0db303a0706dfcc5b2a3533bc45407/docat/tests/__init__.py -------------------------------------------------------------------------------- /docat/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from pathlib import Path 3 | 4 | import pytest 5 | from fastapi.testclient import TestClient 6 | from tinydb import TinyDB 7 | 8 | import docat.app as docat 9 | from docat.utils import create_symlink 10 | 11 | 12 | @pytest.fixture(autouse=True) 13 | def setup_docat_paths(): 14 | """ 15 | Set up the temporary paths for the docat app. 16 | """ 17 | 18 | temp_dir = tempfile.TemporaryDirectory() 19 | docat.DOCAT_STORAGE_PATH = Path(temp_dir.name) 20 | docat.DOCAT_DB_PATH = Path(temp_dir.name) / "db.json" 21 | docat.DOCAT_UPLOAD_FOLDER = Path(temp_dir.name) / "doc" 22 | 23 | yield 24 | 25 | temp_dir.cleanup() 26 | 27 | 28 | @pytest.fixture 29 | def client(): 30 | docat.db = TinyDB(docat.DOCAT_DB_PATH) 31 | 32 | yield TestClient(docat.app) 33 | 34 | docat.app.db = None 35 | 36 | 37 | @pytest.fixture 38 | def client_with_claimed_project(client): 39 | table = docat.db.table("claims") 40 | 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" 41 | table.insert({"name": "some-project", "token": token_hash_1234.hex(), "salt": ""}) 42 | yield client 43 | 44 | 45 | @pytest.fixture 46 | def temp_project_version(): 47 | def __create(project, version): 48 | version_docs = docat.DOCAT_UPLOAD_FOLDER / project / version 49 | version_docs.mkdir(parents=True) 50 | (version_docs / "index.html").touch() 51 | 52 | create_symlink(version_docs, docat.DOCAT_UPLOAD_FOLDER / project / "latest") 53 | 54 | return docat.DOCAT_UPLOAD_FOLDER 55 | 56 | yield __create 57 | -------------------------------------------------------------------------------- /docat/tests/test_claim.py: -------------------------------------------------------------------------------- 1 | def test_successfully_claim_token(client): 2 | response = client.get("/api/some-project/claim") 3 | response_data = response.json() 4 | assert response.status_code == 201 5 | assert response_data["message"] == "Project some-project successfully claimed" 6 | assert "token" in response_data 7 | 8 | 9 | def test_already_claimed(client): 10 | client.get("/api/some-project/claim") 11 | response = client.get("/api/some-project/claim") 12 | response_data = response.json() 13 | assert response.status_code == 409 14 | assert response_data["message"] == "Project some-project is already claimed!" 15 | -------------------------------------------------------------------------------- /docat/tests/test_delete.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | 4 | def test_successfully_delete(client_with_claimed_project): 5 | with patch("docat.app.remove_docs", return_value="remove mock"): 6 | response = client_with_claimed_project.delete("/api/some-project/1.0.0", headers={"Docat-Api-Key": "1234"}) 7 | assert b"remove mock" in response.content 8 | 9 | 10 | def test_no_valid_token_delete(client_with_claimed_project): 11 | with patch("docat.app.remove_docs", return_value="remove mock"): 12 | response = client_with_claimed_project.delete("/api/some-project/1.0.0", headers={"Docat-Api-Key": "abcd"}) 13 | response_data = response.json() 14 | 15 | assert response.status_code == 401 16 | assert response_data["message"] == "Docat-Api-Key token is not valid for some-project" 17 | 18 | 19 | def test_no_token_delete(client_with_claimed_project): 20 | with patch("docat.app.remove_docs", return_value="remove mock"): 21 | response = client_with_claimed_project.delete("/api/some-project/1.0.0") 22 | response_data = response.json() 23 | 24 | assert response.status_code == 401 25 | assert response_data["message"] == "Please provide a header with a valid Docat-Api-Key token for some-project" 26 | -------------------------------------------------------------------------------- /docat/tests/test_project.py: -------------------------------------------------------------------------------- 1 | import io 2 | from datetime import datetime 3 | from unittest.mock import patch 4 | 5 | import httpx 6 | from fastapi.testclient import TestClient 7 | 8 | import docat.app as docat 9 | from docat.models import ProjectDetail, ProjectVersion 10 | from docat.utils import get_project_details 11 | 12 | client = TestClient(docat.app) 13 | 14 | 15 | @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) 16 | def test_project_api(_, temp_project_version): 17 | docs = temp_project_version("project", "1.0") 18 | docs = temp_project_version("different-project", "1.0") 19 | 20 | with patch("docat.app.DOCAT_UPLOAD_FOLDER", docs): 21 | response = client.get("/api/projects") 22 | 23 | assert response.status_code == httpx.codes.OK 24 | assert response.json() == { 25 | "projects": [ 26 | { 27 | "name": "different-project", 28 | "logo": False, 29 | "storage": "0 bytes", 30 | "versions": [ 31 | {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}, 32 | ], 33 | }, 34 | { 35 | "name": "project", 36 | "logo": False, 37 | "storage": "0 bytes", 38 | "versions": [ 39 | {"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}, 40 | ], 41 | }, 42 | ] 43 | } 44 | 45 | 46 | def test_project_api_without_any_projects(): 47 | response = client.get("/api/projects") 48 | 49 | assert response.status_code == httpx.codes.OK 50 | assert response.json() == {"projects": []} 51 | 52 | 53 | @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) 54 | def test_project_details_api(_, temp_project_version): 55 | project = "project" 56 | docs = temp_project_version(project, "1.0") 57 | symlink_to_latest = docs / project / "latest" 58 | assert symlink_to_latest.is_symlink() 59 | 60 | with patch("docat.app.DOCAT_UPLOAD_FOLDER", docs): 61 | response = client.get(f"/api/projects/{project}") 62 | 63 | assert response.status_code == httpx.codes.OK 64 | assert response.json() == { 65 | "name": "project", 66 | "storage": "0 bytes", 67 | "versions": [{"name": "1.0", "timestamp": "2000-01-01T01:01:00", "tags": ["latest"], "hidden": False}], 68 | } 69 | 70 | 71 | def test_project_details_api_with_a_project_that_does_not_exist(): 72 | response = client.get("/api/projects/i-do-not-exist") 73 | 74 | assert not response.status_code == httpx.codes.OK 75 | assert response.json() == {"message": "Project i-do-not-exist does not exist"} 76 | 77 | 78 | @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) 79 | def test_get_project_details_with_hidden_versions(_, client_with_claimed_project): 80 | """ 81 | Make sure that get_project_details works when include_hidden is set to True. 82 | """ 83 | # create a version 84 | create_response = client_with_claimed_project.post( 85 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 86 | ) 87 | assert create_response.status_code == 201 88 | 89 | # check detected before hiding 90 | details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True) 91 | assert details == ProjectDetail( 92 | name="some-project", 93 | storage="20 bytes", 94 | versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)], 95 | ) 96 | 97 | # hide the version 98 | hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"}) 99 | assert hide_response.status_code == 200 100 | assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"} 101 | 102 | # check hidden 103 | details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=True) 104 | assert details == ProjectDetail( 105 | name="some-project", 106 | storage="20 bytes", 107 | versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=True)], 108 | ) 109 | 110 | 111 | @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) 112 | def test_project_details_without_hidden_versions(_, client_with_claimed_project): 113 | """ 114 | Make sure that project_details works when include_hidden is set to False. 115 | """ 116 | # create a version 117 | create_response = client_with_claimed_project.post( 118 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 119 | ) 120 | assert create_response.status_code == 201 121 | 122 | # check detected before hiding 123 | details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False) 124 | assert details == ProjectDetail( 125 | name="some-project", 126 | storage="20 bytes", 127 | versions=[ProjectVersion(name="1.0.0", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)], 128 | ) 129 | 130 | # hide the version 131 | hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"}) 132 | assert hide_response.status_code == 200 133 | assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"} 134 | 135 | # check hidden 136 | details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, "some-project", include_hidden=False) 137 | assert details == ProjectDetail(name="some-project", storage="20 bytes", versions=[]) 138 | 139 | 140 | @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) 141 | def test_include_hidden_parameter_for_get_projects(_, client_with_claimed_project): 142 | """ 143 | Make sure that include_hidden has the desired effect on the /api/projects endpoint. 144 | """ 145 | # create a version 146 | create_response = client_with_claimed_project.post( 147 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 148 | ) 149 | assert create_response.status_code == 201 150 | 151 | # check detected before hiding 152 | get_projects_response = client_with_claimed_project.get("/api/projects") 153 | assert get_projects_response.status_code == 200 154 | assert get_projects_response.json() == { 155 | "projects": [ 156 | { 157 | "name": "some-project", 158 | "logo": False, 159 | "storage": "20 bytes", 160 | "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], 161 | } 162 | ] 163 | } 164 | 165 | # check include_hidden=True 166 | get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=true") 167 | assert get_projects_response.status_code == 200 168 | assert get_projects_response.json() == { 169 | "projects": [ 170 | { 171 | "name": "some-project", 172 | "logo": False, 173 | "storage": "20 bytes", 174 | "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], 175 | } 176 | ] 177 | } 178 | 179 | # hide the version 180 | hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"}) 181 | assert hide_response.status_code == 200 182 | assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"} 183 | 184 | # check include_hidden=False 185 | get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=false") 186 | assert get_projects_response.status_code == 200 187 | assert get_projects_response.json() == {"projects": []} 188 | 189 | # check include_hidden=True 190 | get_projects_response = client_with_claimed_project.get("/api/projects?include_hidden=true") 191 | assert get_projects_response.status_code == 200 192 | assert get_projects_response.json() == { 193 | "projects": [ 194 | { 195 | "name": "some-project", 196 | "logo": False, 197 | "storage": "20 bytes", 198 | "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}], 199 | } 200 | ] 201 | } 202 | 203 | 204 | @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) 205 | def test_include_hidden_parameter_for_get_project_details(_, client_with_claimed_project): 206 | """ 207 | Make sure that include_hidden has the desired effect on the /api/project/{project} endpoint. 208 | """ 209 | # create a version 210 | create_response = client_with_claimed_project.post( 211 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 212 | ) 213 | assert create_response.status_code == 201 214 | 215 | # check detected before hiding 216 | get_projects_response = client_with_claimed_project.get("/api/projects/some-project") 217 | assert get_projects_response.status_code == 200 218 | assert get_projects_response.json() == { 219 | "name": "some-project", 220 | "storage": "20 bytes", 221 | "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], 222 | } 223 | 224 | # check include_hidden=True 225 | get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=true") 226 | assert get_projects_response.status_code == 200 227 | assert get_projects_response.json() == { 228 | "name": "some-project", 229 | "storage": "20 bytes", 230 | "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], 231 | } 232 | 233 | # hide the version 234 | hide_response = client_with_claimed_project.post("/api/some-project/1.0.0/hide", headers={"Docat-Api-Key": "1234"}) 235 | assert hide_response.status_code == 200 236 | assert hide_response.json() == {"message": "Version 1.0.0 is now hidden"} 237 | 238 | # check include_hidden=False 239 | get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=false") 240 | assert get_projects_response.status_code == 200 241 | assert get_projects_response.json() == { 242 | "name": "some-project", 243 | "storage": "20 bytes", 244 | "versions": [], 245 | } 246 | 247 | # check include_hidden=True 248 | get_projects_response = client_with_claimed_project.get("/api/projects/some-project?include_hidden=true") 249 | assert get_projects_response.status_code == 200 250 | assert get_projects_response.json() == { 251 | "name": "some-project", 252 | "storage": "20 bytes", 253 | "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": True}], 254 | } 255 | -------------------------------------------------------------------------------- /docat/tests/test_rename.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path 3 | from unittest.mock import call, patch 4 | 5 | from tinydb import Query 6 | 7 | import docat.app as docat 8 | 9 | 10 | def test_rename_fail_project_does_not_exist(client_with_claimed_project): 11 | with patch("os.rename") as rename_mock: 12 | response = client_with_claimed_project.put("/api/does-not-exist/rename/new-project-name") 13 | assert response.status_code == 404 14 | assert response.json() == {"message": "Project does-not-exist not found"} 15 | 16 | assert rename_mock.mock_calls == [] 17 | 18 | 19 | def test_rename_fail_new_project_name_already_used(client_with_claimed_project): 20 | with patch("os.rename") as rename_mock: 21 | create_first_project_response = client_with_claimed_project.post( 22 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 23 | ) 24 | assert create_first_project_response.status_code == 201 25 | 26 | create_second_project_response = client_with_claimed_project.post( 27 | "/api/second-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 28 | ) 29 | assert create_second_project_response.status_code == 201 30 | 31 | rename_response = client_with_claimed_project.put("/api/some-project/rename/second-project") 32 | assert rename_response.status_code == 409 33 | assert rename_response.json() == {"message": "New project name second-project already in use"} 34 | 35 | assert rename_mock.mock_calls == [] 36 | 37 | 38 | def test_rename_not_authenticated(client_with_claimed_project): 39 | with patch("os.rename") as rename_mock: 40 | create_project_response = client_with_claimed_project.post( 41 | "/api/some-project/1.0.0", 42 | files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")}, 43 | ) 44 | assert create_project_response.status_code == 201 45 | 46 | rename_response = client_with_claimed_project.put("/api/some-project/rename/new-project-name") 47 | assert rename_response.status_code == 401 48 | assert rename_response.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"} 49 | 50 | assert rename_mock.mock_calls == [] 51 | 52 | 53 | def test_rename_success(client_with_claimed_project): 54 | with patch("os.rename") as rename_mock: 55 | create_project_response = client_with_claimed_project.post( 56 | "/api/some-project/1.0.0", 57 | files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")}, 58 | ) 59 | assert create_project_response.status_code == 201 60 | 61 | rename_response = client_with_claimed_project.put("/api/some-project/rename/new-project-name", headers={"Docat-Api-Key": "1234"}) 62 | assert rename_response.status_code == 200 63 | assert rename_response.json() == {"message": "Successfully renamed project some-project to new-project-name"} 64 | 65 | old_path = docat.DOCAT_UPLOAD_FOLDER / Path("some-project") 66 | new_path = docat.DOCAT_UPLOAD_FOLDER / Path("new-project-name") 67 | assert rename_mock.mock_calls == [call(old_path, new_path)] 68 | 69 | Project = Query() 70 | table = docat.db.table("claims") 71 | claims_with_old_name = table.search(Project.name == "some-project") 72 | assert len(claims_with_old_name) == 0 73 | claims_with_new_name = table.search(Project.name == "new-project-name") 74 | assert len(claims_with_new_name) == 1 75 | 76 | 77 | def test_rename_rejects_forbidden_project_name(client_with_claimed_project): 78 | """ 79 | Names that conflict with pages in docat web are forbidden, 80 | and renaming a project to such a name should fail. 81 | """ 82 | 83 | create_response = client_with_claimed_project.post( 84 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 85 | ) 86 | assert create_response.status_code == 201 87 | 88 | with patch("os.rename") as rename_mock: 89 | for project_name in ["upload", "claim", "Delete ", "help", "Doc", "API"]: 90 | rename_response = client_with_claimed_project.put(f"/api/some-project/rename/{project_name}", headers={"Docat-Api-Key": "1234"}) 91 | assert rename_response.status_code == 400 92 | assert rename_response.json() == { 93 | "message": f'New project name "{project_name}" is forbidden, as it conflicts with pages in docat web.' 94 | } 95 | 96 | assert rename_mock.mock_calls == [] 97 | -------------------------------------------------------------------------------- /docat/tests/test_stats.py: -------------------------------------------------------------------------------- 1 | import io 2 | from datetime import datetime 3 | from unittest.mock import patch 4 | 5 | import pytest 6 | 7 | 8 | @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) 9 | @pytest.mark.parametrize( 10 | ("project_config", "n_projects", "n_versions", "storage"), 11 | [ 12 | ([("some-project", ["1.0.0"])], 1, 1, "20 bytes"), 13 | ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"), 14 | ([("some-project", ["1.0.0", "2.0.0"])], 1, 2, "40 bytes"), 15 | ([("some-project", ["1.0.0", "2.0.0"]), ("another-project", ["1"])], 2, 3, "60 bytes"), 16 | ], 17 | ) 18 | def test_get_stats(_, project_config, n_projects, n_versions, storage, client_with_claimed_project): 19 | """ 20 | Make sure that get_stats works. 21 | """ 22 | # create a version 23 | for project_name, versions in project_config: 24 | for version in versions: 25 | create_response = client_with_claimed_project.post( 26 | f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 27 | ) 28 | assert create_response.status_code == 201 29 | 30 | # get system stats 31 | hide_response = client_with_claimed_project.get("/api/stats") 32 | assert hide_response.status_code == 200 33 | assert hide_response.json() == {"n_projects": n_projects, "n_versions": n_versions, "storage": storage} 34 | -------------------------------------------------------------------------------- /docat/tests/test_upload.py: -------------------------------------------------------------------------------- 1 | import io 2 | from pathlib import Path 3 | from unittest.mock import call, patch 4 | 5 | import docat.app as docat 6 | 7 | 8 | def test_successfully_upload(client): 9 | with patch("docat.app.remove_docs"): 10 | response = client.post("/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

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

Hello World

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

Hello World

"), "plain/text")}, 28 | headers={"Docat-Api-Key": "1234"}, 29 | ) 30 | response_data = response.json() 31 | 32 | assert response.status_code == 201 33 | assert response_data["message"] == "Documentation uploaded successfully" 34 | assert remove_mock.mock_calls == [call("some-project", "1.0.0", docat.DOCAT_UPLOAD_FOLDER)] 35 | 36 | 37 | def test_tags_are_not_overwritten_without_api_key(client_with_claimed_project): 38 | with patch("docat.app.remove_docs") as remove_mock: 39 | response = client_with_claimed_project.post( 40 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 41 | ) 42 | assert response.status_code == 201 43 | 44 | response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest") 45 | assert response.status_code == 201 46 | 47 | response = client_with_claimed_project.post( 48 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 49 | ) 50 | response_data = response.json() 51 | 52 | assert response.status_code == 401 53 | assert response_data["message"] == "Please provide a header with a valid Docat-Api-Key token for some-project" 54 | assert remove_mock.mock_calls == [] 55 | 56 | 57 | def test_successful_tag_creation(client_with_claimed_project): 58 | with patch("docat.app.create_symlink") as create_symlink_mock: 59 | create_project_response = client_with_claimed_project.post( 60 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 61 | ) 62 | assert create_project_response.status_code == 201 63 | 64 | create_tag_response = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest") 65 | 66 | assert create_tag_response.status_code == 201 67 | assert create_tag_response.json() == {"message": "Tag latest -> 1.0.0 successfully created"} 68 | 69 | destination_path = docat.DOCAT_UPLOAD_FOLDER / Path("some-project") / Path("latest") 70 | assert create_symlink_mock.mock_calls == [call("1.0.0", destination_path), call().__bool__()] 71 | 72 | 73 | def test_create_tag_fails_when_version_does_not_exist(client_with_claimed_project): 74 | with patch("docat.app.create_symlink") as create_symlink_mock: 75 | create_project_response = client_with_claimed_project.post( 76 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 77 | ) 78 | 79 | assert create_project_response.status_code == 201 80 | 81 | create_tag_response = client_with_claimed_project.put("/api/some-project/non-existing-version/tags/new-tag") 82 | 83 | assert create_tag_response.status_code == 404 84 | assert create_tag_response.json() == {"message": "Version non-existing-version not found"} 85 | 86 | assert create_symlink_mock.mock_calls == [] 87 | 88 | 89 | def test_create_tag_fails_on_overwrite_of_version(client_with_claimed_project): 90 | """ 91 | Create a tag with the same name as a version. 92 | """ 93 | project_name = "some-project" 94 | version = "1.0.0" 95 | tag = "latest" 96 | 97 | create_first_project_response = client_with_claimed_project.post( 98 | f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"

Hello World

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

Hello World

"), "plain/text")} 104 | ) 105 | assert create_second_project_response.status_code == 201 106 | 107 | create_tag_response = client_with_claimed_project.put(f"/api/{project_name}/{version}/tags/{tag}") 108 | assert create_tag_response.status_code == 409 109 | assert create_tag_response.json() == {"message": f"Tag {tag} would overwrite an existing version!"} 110 | 111 | 112 | def test_create_fails_on_overwrite_of_tag(client_with_claimed_project): 113 | """ 114 | Create a version with the same name as a tag. 115 | """ 116 | project_name = "some-project" 117 | version = "1.0.0" 118 | tag = "some-tag" 119 | 120 | create_project_response = client_with_claimed_project.post( 121 | f"/api/{project_name}/{version}", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 122 | ) 123 | assert create_project_response.status_code == 201 124 | 125 | create_tag_response = client_with_claimed_project.put(f"/api/{project_name}/{version}/tags/{tag}") 126 | assert create_tag_response.status_code == 201 127 | assert create_tag_response.json() == {"message": f"Tag {tag} -> {version} successfully created"} 128 | 129 | create_project_with_name_of_tag_response = client_with_claimed_project.post( 130 | f"/api/{project_name}/{tag}", files={"file": ("index.html", io.BytesIO(b"

Hello World

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

Hello World

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

Hello World

"), "plain/text")}, 146 | headers={"Docat-Api-Key": "456"}, 147 | ) 148 | response_data = response.json() 149 | 150 | assert response.status_code == 401 151 | assert response_data["message"] == "Docat-Api-Key token is not valid for some-project" 152 | 153 | assert remove_mock.mock_calls == [] 154 | 155 | 156 | def test_upload_rejects_forbidden_project_name(client_with_claimed_project): 157 | """ 158 | Names that conflict with pages in docat web are forbidden, 159 | and creating a project with such a name should fail. 160 | """ 161 | 162 | with patch("docat.app.remove_docs") as remove_mock: 163 | for project_name in ["upload", "claim", " Delete ", "help", "DOC", "api"]: 164 | response = client_with_claimed_project.post( 165 | f"/api/{project_name}/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 166 | ) 167 | assert response.status_code == 400 168 | assert response.json() == {"message": f'Project name "{project_name}" is forbidden, as it conflicts with pages in docat web.'} 169 | 170 | assert remove_mock.mock_calls == [] 171 | 172 | 173 | def test_upload_issues_warning_missing_index_file(client_with_claimed_project): 174 | """ 175 | When a project is uploaded without an index.html file, 176 | a warning should be issued, but the upload should succeed. 177 | """ 178 | 179 | response = client_with_claimed_project.post( 180 | "/api/some-project/1.0.0", files={"file": ("some-other-file.html", io.BytesIO(b"

Hello World

"), "plain/text")} 181 | ) 182 | response_data = response.json() 183 | 184 | assert response.status_code == 201 185 | assert response_data["message"] == "Documentation uploaded successfully, but no index.html found at root of archive." 186 | assert (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "some-other-file.html").exists() 187 | assert not (docat.DOCAT_UPLOAD_FOLDER / "some-project" / "1.0.0" / "index.html").exists() 188 | -------------------------------------------------------------------------------- /docat/tests/test_upload_icon.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import io 3 | from datetime import datetime 4 | from unittest.mock import call, patch 5 | 6 | import docat.app as docat 7 | 8 | ONE_PIXEL_PNG = base64.decodebytes( 9 | b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII=" 10 | ) 11 | 12 | 13 | def test_successful_icon_upload(client_with_claimed_project): 14 | upload_folder_response = client_with_claimed_project.post( 15 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 16 | ) 17 | assert upload_folder_response.status_code == 201 18 | 19 | with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock: 20 | upload_response = client_with_claimed_project.post( 21 | "/api/some-project/icon", 22 | files={"file": ("icon.jpg", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, 23 | ) 24 | 25 | assert upload_response.status_code == 200 26 | assert upload_response.json() == {"message": "Icon successfully uploaded"} 27 | assert remove_file_mock.mock_calls == [] 28 | assert len(copyfileobj_mock.mock_calls) == 1 29 | 30 | 31 | def test_icon_upload_fails_with_no_project(client_with_claimed_project): 32 | with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock: 33 | upload_response = client_with_claimed_project.post( 34 | "/api/non-existing-project/icon", 35 | files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, 36 | ) 37 | 38 | assert upload_response.status_code == 404 39 | assert upload_response.json() == {"message": "Project non-existing-project not found"} 40 | assert remove_file_mock.mock_calls == [] 41 | assert copyfileobj_mock.mock_calls == [] 42 | 43 | 44 | def test_icon_upload_fails_no_token_and_existing_icon(client): 45 | """ 46 | upload twice, first time should be successful (nothing replaced), 47 | second time should fail (would need token to replace) 48 | """ 49 | 50 | upload_folder_response = client.post( 51 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 52 | ) 53 | assert upload_folder_response.status_code == 201 54 | 55 | with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock: 56 | upload_response_1 = client.post( 57 | "/api/some-project/icon", 58 | files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, 59 | ) 60 | assert upload_response_1.status_code == 200 61 | assert upload_response_1.json() == {"message": "Icon successfully uploaded"} 62 | assert remove_file_mock.mock_calls == [] 63 | assert len(copyfileobj_mock.mock_calls) == 1 64 | 65 | with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock: 66 | upload_response_2 = client.post( 67 | "/api/some-project/icon", 68 | files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, 69 | ) 70 | assert upload_response_2.status_code == 401 71 | assert upload_response_2.json() == {"message": "Please provide a header with a valid Docat-Api-Key token for some-project"} 72 | assert remove_file_mock.mock_calls == [] 73 | assert len(copyfileobj_mock.mock_calls) == 0 74 | 75 | 76 | def test_icon_upload_successful_replacement_with_token(client_with_claimed_project): 77 | """ 78 | upload twice, both times should be successful (token provided) 79 | """ 80 | 81 | icon_path = docat.DOCAT_UPLOAD_FOLDER / "some-project" / "logo" 82 | 83 | upload_folder_response = client_with_claimed_project.post( 84 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 85 | ) 86 | assert upload_folder_response.status_code == 201 87 | 88 | with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock: 89 | upload_response_1 = client_with_claimed_project.post( 90 | "/api/some-project/icon", 91 | files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, 92 | headers={"Docat-Api-Key": "1234"}, 93 | ) 94 | assert upload_response_1.status_code == 200 95 | assert upload_response_1.json() == {"message": "Icon successfully uploaded"} 96 | assert remove_file_mock.mock_calls == [] 97 | assert len(copyfileobj_mock.mock_calls) == 1 98 | 99 | with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock: 100 | upload_response_1 = client_with_claimed_project.post( 101 | "/api/some-project/icon", 102 | files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, 103 | headers={"Docat-Api-Key": "1234"}, 104 | ) 105 | assert upload_response_1.status_code == 200 106 | assert upload_response_1.json() == {"message": "Icon successfully uploaded"} 107 | assert remove_file_mock.mock_calls == [call(icon_path)] 108 | assert len(copyfileobj_mock.mock_calls) == 1 109 | 110 | 111 | def test_icon_upload_successful_no_token_no_existing_icon(client): 112 | upload_folder_response = client.post( 113 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 114 | ) 115 | assert upload_folder_response.status_code == 201 116 | 117 | with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock: 118 | upload_response = client.post( 119 | "/api/some-project/icon", 120 | files={"file": ("icon.png", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, 121 | ) 122 | 123 | assert upload_response.status_code == 200 124 | assert upload_response.json() == {"message": "Icon successfully uploaded"} 125 | assert remove_file_mock.mock_calls == [] 126 | assert len(copyfileobj_mock.mock_calls) == 1 127 | 128 | 129 | def test_icon_upload_fails_no_image(client_with_claimed_project): 130 | upload_folder_response = client_with_claimed_project.post( 131 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 132 | ) 133 | assert upload_folder_response.status_code == 201 134 | 135 | with patch("shutil.copyfileobj") as copyfileobj_mock, patch("os.remove") as remove_file_mock: 136 | upload_response = client_with_claimed_project.post( 137 | "/api/some-project/icon", 138 | files={"file": ("file.zip", io.BytesIO(b"not image data"), "application/zip")}, 139 | ) 140 | 141 | assert upload_response.status_code == 400 142 | assert upload_response.json() == {"message": "Icon must be an image"} 143 | assert remove_file_mock.mock_calls == [] 144 | assert copyfileobj_mock.mock_calls == [] 145 | 146 | 147 | @patch("docat.utils.get_version_timestamp", return_value=datetime(2000, 1, 1, 1, 1, 0)) 148 | def test_get_project_recongizes_icon(_, client_with_claimed_project): 149 | """ 150 | get_projects should return true, if the project has an icon 151 | """ 152 | 153 | upload_folder_response = client_with_claimed_project.post( 154 | "/api/some-project/1.0.0", files={"file": ("index.html", io.BytesIO(b"

Hello World

"), "plain/text")} 155 | ) 156 | assert upload_folder_response.status_code == 201 157 | 158 | projects_response = client_with_claimed_project.get("/api/projects") 159 | assert projects_response.status_code == 200 160 | assert projects_response.json() == { 161 | "projects": [ 162 | { 163 | "name": "some-project", 164 | "logo": False, 165 | "storage": "20 bytes", 166 | "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], 167 | } 168 | ] 169 | } 170 | 171 | upload_response = client_with_claimed_project.post( 172 | "/api/some-project/icon", 173 | files={"file": ("icon.jpg", io.BytesIO(ONE_PIXEL_PNG), "image/png")}, 174 | ) 175 | assert upload_response.status_code == 200 176 | 177 | projects_response = client_with_claimed_project.get("/api/projects") 178 | assert projects_response.status_code == 200 179 | assert projects_response.json() == { 180 | "projects": [ 181 | { 182 | "name": "some-project", 183 | "logo": True, 184 | "storage": "103 bytes", 185 | "versions": [{"name": "1.0.0", "timestamp": "2000-01-01T01:01:00", "tags": [], "hidden": False}], 186 | } 187 | ] 188 | } 189 | -------------------------------------------------------------------------------- /docat/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import MagicMock, patch 3 | 4 | import docat.app as docat 5 | from docat.utils import create_symlink, extract_archive, remove_docs 6 | 7 | 8 | def test_symlink_creation(): 9 | """ 10 | Tests the creation of a symlink 11 | """ 12 | source = MagicMock() 13 | destination = MagicMock() 14 | destination.exists.return_value = False 15 | destination.symlink_to.return_value = MagicMock() 16 | 17 | assert create_symlink(source, destination) 18 | 19 | destination.symlink_to.assert_called_once_with(source) 20 | 21 | 22 | def test_symlink_creation_overwrite_destination(): 23 | """ 24 | Tests the creation of a symlink and overwriting 25 | of existing symlink 26 | """ 27 | source = MagicMock() 28 | destination = MagicMock() 29 | destination.exists.return_value = True 30 | destination.is_symlink.return_value = True 31 | destination.unlink.return_value = MagicMock() 32 | destination.symlink_to.return_value = MagicMock() 33 | 34 | assert create_symlink(source, destination) 35 | 36 | destination.unlink.assert_called_once() 37 | destination.symlink_to.assert_called_once_with(source) 38 | 39 | 40 | def test_symlink_creation_do_not_overwrite_destination(): 41 | """ 42 | Tests wether a symlinc is not created when it 43 | would overwrite an existing version 44 | """ 45 | source = MagicMock() 46 | destination = MagicMock() 47 | destination.exists.return_value = True 48 | destination.is_symlink.return_value = False 49 | destination.unlink.return_value = MagicMock() 50 | destination.symlink_to.return_value = MagicMock() 51 | 52 | assert not create_symlink(source, destination) 53 | 54 | destination.unlink.assert_not_called() 55 | destination.symlink_to.assert_not_called() 56 | 57 | 58 | def test_archive_artifact(): 59 | target_file = Path("/some/zipfile.zip") 60 | destination = "/tmp/null" 61 | with patch.object(Path, "unlink") as mock_unlink, patch("docat.utils.ZipFile") as mock_zip: 62 | mock_zip_open = MagicMock() 63 | mock_zip.return_value.__enter__.return_value.extractall = mock_zip_open 64 | 65 | extract_archive(target_file, destination) 66 | 67 | mock_zip.assert_called_once_with(target_file, "r") 68 | mock_zip_open.assert_called_once() 69 | mock_unlink.assert_called_once() 70 | 71 | 72 | def test_remove_version(temp_project_version): 73 | docs = temp_project_version("project", "1.0") 74 | remove_docs("project", "1.0", docat.DOCAT_UPLOAD_FOLDER) 75 | 76 | assert docs.exists() 77 | assert not (docs / "project").exists() 78 | 79 | 80 | def test_remove_symlink_version(temp_project_version): 81 | project = "project" 82 | docs = temp_project_version(project, "1.0") 83 | symlink_to_latest = docs / project / "latest" 84 | assert symlink_to_latest.is_symlink() 85 | 86 | remove_docs(project, "latest", docat.DOCAT_UPLOAD_FOLDER) 87 | 88 | assert not symlink_to_latest.exists() 89 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .prettierrc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.*.local 31 | -------------------------------------------------------------------------------- /web/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # Ignore artifacts: 3 | build 4 | coverage 5 | -------------------------------------------------------------------------------- /web/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # docat web 2 | 3 | ## Project setup 4 | 5 | ```sh 6 | yarn install [--pure-lockfile] 7 | ``` 8 | 9 | ### Compiles and hot-reloads for development 10 | 11 | The script for `yarn start` automatically sets `VITE_DOCAT_VERSION` to display the current version in the footer, 12 | so you can just run: 13 | 14 | ```sh 15 | yarn start 16 | ``` 17 | 18 | ### Compiles and minifies for production 19 | 20 | To display the current version of docat in the footer, use the following script to set `VITE_DOCAT_VERSION`. 21 | This one liner uses the latest tag, if there is one on the current commit, and the current commit if not. 22 | 23 | ```sh 24 | VITE_DOCAT_VERSION=$(git describe --tags --always) yarn build 25 | ``` 26 | 27 | Otherwise you can just use the following and the footer will show `unknown`. 28 | 29 | ```sh 30 | yarn build 31 | ``` 32 | 33 | ### Lints and fixes files 34 | 35 | ```sh 36 | yarn lint 37 | ``` 38 | 39 | ### Tests 40 | 41 | ```sh 42 | yarn test 43 | ``` 44 | 45 | ### Basic Header Theming 46 | 47 | Not happy with the default Docat logo and header? 48 | Just add your custom html header to the `/var/www/html/config.json` file. 49 | 50 | ```json 51 | { 52 | "headerHTML": "

MyCompany

", 53 | "footerHTML": "Contact Maintainers" 54 | } 55 | ``` 56 | 57 | 58 | ## Development 59 | 60 | ```sh 61 | sudo docker run \ 62 | --detach \ 63 | --volume /path/to/doc:/var/docat/doc/ \ 64 | --publish 8000:80 \ 65 | docat 66 | ``` 67 | 68 | ## Errors 69 | 70 | If you get a 403 response when trying to read a version, 71 | try changing the permissions of your docs folder on your host. 72 | 73 | ```sh 74 | sudo chmod 777 /path/to/doc -r 75 | ``` 76 | -------------------------------------------------------------------------------- /web/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import pluginReact from 'eslint-plugin-react' 3 | import pluginPrettier from 'eslint-plugin-prettier' 4 | import pluginTypescriptEslint from '@typescript-eslint/eslint-plugin' 5 | import parserTypescriptEslint from '@typescript-eslint/parser' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default [ 9 | globalIgnores(['vite-env.d.ts', 'vite.config.ts', 'dist/**/*']), 10 | js.configs.recommended, 11 | { 12 | files: ['*.ts', '*.tsx'], 13 | languageOptions: { 14 | parser: parserTypescriptEslint, 15 | parserOptions: { 16 | project: './tsconfig.json' 17 | } 18 | }, 19 | plugins: { 20 | '@typescript-eslint': pluginTypescriptEslint 21 | }, 22 | rules: { 23 | ...pluginTypescriptEslint.configs['recommended'].rules, 24 | ...pluginTypescriptEslint.configs['recommended-requiring-type-checking'] 25 | .rules, 26 | '@typescript-eslint/space-before-function-paren': 'off', 27 | '@typescript-eslint/no-extra-semi': 'off' 28 | } 29 | }, 30 | { 31 | files: ['*.js', '*.jsx', '*.ts', '*.tsx'], 32 | plugins: { 33 | react: pluginReact, 34 | prettier: pluginPrettier 35 | }, 36 | languageOptions: { 37 | ecmaVersion: 'latest', 38 | globals: { 39 | window: 'readonly', 40 | document: 'readonly', 41 | navigator: 'readonly' 42 | } 43 | }, 44 | rules: { 45 | ...pluginReact.configs.recommended.rules, 46 | ...pluginPrettier.configs.recommended.rules 47 | }, 48 | settings: { 49 | react: { 50 | version: 'detect' 51 | } 52 | } 53 | } 54 | ] 55 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docat-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@emotion/react": "^11.11.3", 8 | "@emotion/styled": "^11.10.6", 9 | "@mui/icons-material": "^6.1.3", 10 | "@mui/material": "^6.1.3", 11 | "@types/jest": "^29.5.11", 12 | "@types/lodash": "^4.14.202", 13 | "@types/node": "^22.7.5", 14 | "@types/react": "^18.2.45", 15 | "@types/react-dom": "^18.2.18", 16 | "@typescript-eslint/parser": "^8.8.1", 17 | "@vitejs/plugin-react-swc": "^3.6.0", 18 | "eslint": "^9.12.0", 19 | "eslint-config-react-app": "^7.0.1", 20 | "fuse.js": "^7.0.0", 21 | "jsdom": "^25.0.1", 22 | "lodash": "^4.17.21", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-markdown": "^9.0.1", 26 | "react-router": "^6.21.1", 27 | "react-router-dom": "^6.21.1", 28 | "semver": "^7.5.4", 29 | "typescript": "*", 30 | "vite": "^5.4.8", 31 | "vite-plugin-svgr": "^4.2.0", 32 | "vitest": "^2.1.2", 33 | "web-vitals": "^4.2.3" 34 | }, 35 | "scripts": { 36 | "start": "VITE_DOCAT_VERSION=$(git describe --tags --always) vite", 37 | "build": "tsc && vite build", 38 | "preview": "vite preview", 39 | "test": "vitest --watch=false", 40 | "lint": "eslint" 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | }, 54 | "devDependencies": { 55 | "@typescript-eslint/eslint-plugin": "^8.8.1", 56 | "eslint-config-prettier": "^9.1.0", 57 | "eslint-config-standard-with-typescript": "^43.0.1", 58 | "eslint-plugin-import": "^2.29.1", 59 | "eslint-plugin-n": "^17.11.1", 60 | "eslint-plugin-prettier": "^5.1.3", 61 | "eslint-plugin-promise": "^7.1.0", 62 | "eslint-plugin-react": "^7.32.1", 63 | "prettier": "^3.2.4" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docat-org/docat/9837dc0d3f0db303a0706dfcc5b2a3533bc45407/web/public/favicon.ico -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, RouterProvider } from 'react-router-dom' 2 | import { ConfigDataProvider } from './data-providers/ConfigDataProvider' 3 | import { MessageBannerProvider } from './data-providers/MessageBannerProvider' 4 | import { ProjectDataProvider } from './data-providers/ProjectDataProvider' 5 | import { SearchProvider } from './data-providers/SearchProvider' 6 | import { StatsDataProvider } from './data-providers/StatsDataProvider' 7 | import Claim from './pages/Claim' 8 | import Delete from './pages/Delete' 9 | import Docs from './pages/Docs' 10 | import Help from './pages/Help' 11 | import Home from './pages/Home' 12 | import NotFound from './pages/NotFound' 13 | import Upload from './pages/Upload' 14 | 15 | function App(): JSX.Element { 16 | const router = createBrowserRouter([ 17 | { 18 | path: '/', 19 | errorElement: , 20 | children: [ 21 | { 22 | path: '', 23 | element: 24 | }, 25 | { 26 | path: 'upload', 27 | element: 28 | }, 29 | { 30 | path: 'claim', 31 | element: 32 | }, 33 | { 34 | path: 'delete', 35 | element: 36 | }, 37 | { 38 | path: 'help', 39 | element: 40 | }, 41 | { 42 | path: ':project', 43 | children: [ 44 | { 45 | path: '', 46 | element: 47 | }, 48 | { 49 | path: ':version', 50 | children: [ 51 | { 52 | path: '', 53 | element: 54 | }, 55 | { 56 | path: '*', 57 | element: 58 | } 59 | ] 60 | } 61 | ] 62 | } 63 | ] 64 | } 65 | ]) 66 | 67 | return ( 68 |
69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
81 | ) 82 | } 83 | 84 | export default App 85 | -------------------------------------------------------------------------------- /web/src/assets/getting-started.md: -------------------------------------------------------------------------------- 1 | ../../../doc/getting-started.md -------------------------------------------------------------------------------- /web/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/docat-org/docat/9837dc0d3f0db303a0706dfcc5b2a3533bc45407/web/src/assets/logo.png -------------------------------------------------------------------------------- /web/src/components/DataSelect.tsx: -------------------------------------------------------------------------------- 1 | import { FormGroup, MenuItem, TextField } from '@mui/material' 2 | import React, { useState } from 'react' 3 | 4 | interface Props { 5 | emptyMessage: string 6 | errorMsg?: string 7 | value?: string 8 | label: string 9 | values: string[] 10 | onChange: (value: string) => void 11 | } 12 | 13 | export default function DataSelect(props: Props): JSX.Element { 14 | const [selectedValue, setSelectedValue] = useState( 15 | props.value ?? 'none' 16 | ) 17 | 18 | // clear field if selected value is not in options 19 | if (selectedValue !== 'none' && !props.values.includes(selectedValue)) { 20 | setSelectedValue('none') 21 | } 22 | 23 | return ( 24 | 25 | { 27 | setSelectedValue(e.target.value) 28 | props.onChange(e.target.value) 29 | }} 30 | value={props.values.length > 0 ? selectedValue : 'none'} 31 | label={props.label} 32 | error={props.errorMsg !== undefined && props.errorMsg !== ''} 33 | helperText={props.errorMsg} 34 | select 35 | > 36 | 37 | {props.emptyMessage} 38 | 39 | 40 | {props.values.map((value) => { 41 | return ( 42 | 43 | {value} 44 | 45 | ) 46 | })} 47 | 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /web/src/components/DocumentControlButtons.tsx: -------------------------------------------------------------------------------- 1 | import { Home, Share } from '@mui/icons-material' 2 | import { 3 | Checkbox, 4 | FormControl, 5 | FormControlLabel, 6 | FormGroup, 7 | MenuItem, 8 | Modal, 9 | Select, 10 | Tooltip 11 | } from '@mui/material' 12 | import { useState } from 'react' 13 | import { Link } from 'react-router-dom' 14 | import type ProjectDetails from '../models/ProjectDetails' 15 | 16 | import styles from './../style/components/DocumentControlButtons.module.css' 17 | 18 | interface Props { 19 | version: string 20 | versions: ProjectDetails[] 21 | onVersionChange: (version: string) => void 22 | } 23 | 24 | export default function DocumentControlButtons(props: Props): JSX.Element { 25 | const buttonStyle = { width: '25px', height: '25px' } 26 | 27 | const [shareModalOpen, setShareModalOpen] = useState(false) 28 | const [shareModalUseLatest, setShareModalUseLatest] = useState(false) 29 | const [shareModalHideUi, setShareModalHideUi] = useState(false) 30 | 31 | // Cannot copy when page is served over HTTP 32 | const canCopy = navigator.clipboard !== undefined 33 | 34 | const getShareUrl = (): string => { 35 | // adapt the current URL so we can leave Docs.tsx's state as refs 36 | // (which means if the page was passed down as a prop it wouldn't update correctly) 37 | 38 | let url = window.location.href 39 | 40 | if (shareModalUseLatest) { 41 | url = url.replace(props.version, 'latest') 42 | } 43 | 44 | if (shareModalHideUi) { 45 | const urlObject = new URL(url) 46 | urlObject.search = 'hide-ui' 47 | url = urlObject.toString() 48 | } 49 | 50 | return url 51 | } 52 | 53 | return ( 54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 91 | 92 | 93 | 94 | 102 | 103 | 104 | { 107 | setShareModalOpen(false) 108 | }} 109 | aria-labelledby="share-modal-title" 110 | aria-describedby="share-modal-description" 111 | > 112 |
113 |
114 |

{getShareUrl()}

115 | {canCopy && ( 116 |
117 | 127 |
128 | )} 129 |
130 | 131 | 132 | { 137 | setShareModalHideUi(e.target.checked) 138 | }} 139 | /> 140 | } 141 | label="Hide Version Selector" 142 | className={styles['share-modal-label']} 143 | /> 144 | { 149 | setShareModalUseLatest(e.target.checked) 150 | }} 151 | /> 152 | } 153 | label="Always use latest version" 154 | className={styles['share-modal-label']} 155 | /> 156 | 157 | 158 | 166 |
167 |
168 |
169 | ) 170 | } 171 | -------------------------------------------------------------------------------- /web/src/components/FavoriteStar.tsx: -------------------------------------------------------------------------------- 1 | import { Star, StarOutline } from '@mui/icons-material' 2 | import { useState } from 'react' 3 | import ProjectRepository from '../repositories/ProjectRepository' 4 | 5 | interface Props { 6 | projectName: string 7 | onFavoriteChanged: () => void 8 | } 9 | 10 | export default function FavoriteStar(props: Props): JSX.Element { 11 | const [isFavorite, setIsFavorite] = useState( 12 | ProjectRepository.isFavorite(props.projectName) 13 | ) 14 | 15 | const toggleFavorite = (): void => { 16 | const newIsFavorite = !isFavorite 17 | ProjectRepository.setFavorite(props.projectName, newIsFavorite) 18 | setIsFavorite(newIsFavorite) 19 | 20 | props.onFavoriteChanged() 21 | } 22 | 23 | const StarType = isFavorite ? Star : StarOutline 24 | 25 | return ( 26 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /web/src/components/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import { InputLabel } from '@mui/material' 2 | import React, { useRef, useState } from 'react' 3 | 4 | import styles from './../style/components/FileInput.module.css' 5 | 6 | interface Props { 7 | label: string 8 | okTypes: string[] 9 | file: File | undefined 10 | onChange: (file: File | undefined) => void 11 | isValid: (file: File) => boolean 12 | } 13 | 14 | export default function FileInput(props: Props): JSX.Element { 15 | const [fileName, setFileName] = useState( 16 | props.file?.name !== undefined ? props.file.name : '' 17 | ) 18 | const [dragActive, setDragActive] = useState(false) 19 | const inputRef = useRef(null) 20 | 21 | /** 22 | * Checks if a file was selected and if it is valid 23 | * before it is selected. 24 | * @param files FileList from the event 25 | */ 26 | const updateFileIfValid = (files: FileList | null): void => { 27 | if (files == null || files.length < 1 || files[0] == null) { 28 | return 29 | } 30 | 31 | const file = files[0] 32 | if (!props.isValid(file)) { 33 | return 34 | } 35 | 36 | setFileName(file.name) 37 | props.onChange(file) 38 | } 39 | 40 | /** 41 | * This updates the file upload container to show a custom style when 42 | * the user is dragging a file into or out of the container. 43 | * @param e drag enter event 44 | */ 45 | const handleDragEvents = (e: React.DragEvent): void => { 46 | e.preventDefault() 47 | e.stopPropagation() 48 | 49 | if (e.type === 'dragenter' || e.type === 'dragover') { 50 | setDragActive(true) 51 | } else if (e.type === 'dragleave') { 52 | setDragActive(false) 53 | } 54 | } 55 | 56 | /** 57 | * Handles the drop event when the user drops a file into the container. 58 | * @param e DragEvent 59 | */ 60 | const handleDrop = (e: React.DragEvent): void => { 61 | e.preventDefault() 62 | e.stopPropagation() 63 | setDragActive(false) 64 | 65 | if (e.dataTransfer?.files[0] == null) { 66 | return 67 | } 68 | 69 | updateFileIfValid(e.dataTransfer.files) 70 | } 71 | 72 | /** 73 | * Handles the file input via the file browser. 74 | * @param e change event 75 | */ 76 | const handleSelect = (e: React.ChangeEvent): void => { 77 | e.preventDefault() 78 | 79 | updateFileIfValid(e.target.files) 80 | } 81 | 82 | /** 83 | * This triggers the input when the container is clicked. 84 | */ 85 | const onButtonClick = (): void => { 86 | if (inputRef?.current != null) { 87 | // @ts-expect-error - the ref is not null, therefore the button should be able to be clicked 88 | inputRef.current.click() // eslint-disable-line @typescript-eslint/no-unsafe-call 89 | } 90 | } 91 | 92 | return ( 93 |
94 | {!dragActive && ( 95 | 96 | {props.label} 97 | 98 | )} 99 | 100 |
109 | 117 | 118 | {fileName !== '' && ( 119 | <> 120 |

{fileName}

121 |

-

122 | 123 | )} 124 | 125 |

Drag zip file here or

126 | 127 | 130 | 131 | {dragActive && ( 132 |
139 | )} 140 |
141 |
142 | ) 143 | } 144 | -------------------------------------------------------------------------------- /web/src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material' 2 | import { useState } from 'react' 3 | import { Link } from 'react-router-dom' 4 | import { useConfig } from '../data-providers/ConfigDataProvider' 5 | import styles from './../style/components/Footer.module.css' 6 | 7 | export default function Footer(): JSX.Element { 8 | 9 | const defaultFooter = ( 10 | <> 11 | ) 12 | 13 | const [footer, setFooter] = useState(defaultFooter) 14 | const config = useConfig() 15 | 16 | // set custom header if found in config 17 | if (config.footerHTML != null && footer === defaultFooter) { 18 | setFooter(
) 19 | } 20 | 21 | return ( 22 |
23 | 24 | HELP 25 | 26 | 27 | 28 | {footer} 29 | 30 | 31 |
32 | 33 | VERSION{' '} 34 | {import.meta.env.VITE_DOCAT_VERSION ?? 'unknown'} 35 | 36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /web/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Link } from 'react-router-dom' 3 | 4 | import { useConfig } from '../data-providers/ConfigDataProvider' 5 | 6 | import docatLogo from '../assets/logo.png' 7 | import styles from './../style/components/Header.module.css' 8 | 9 | 10 | export default function Header(): JSX.Element { 11 | const defaultHeader = ( 12 | <> 13 | docat logo 14 |

DOCAT

15 | 16 | ) 17 | 18 | const [header, setHeader] = useState(defaultHeader) 19 | const config = useConfig() 20 | 21 | // set custom header if found in config 22 | if (config.headerHTML != null && header === defaultHeader) { 23 | setHeader(
) 24 | } 25 | 26 | return ( 27 |
28 | {header} 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /web/src/components/IFrame.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import { uniqueId } from 'lodash' 3 | 4 | import styles from '../style/components/IFrame.module.css' 5 | interface Props { 6 | src: string 7 | onPageChanged: (page: string, hash: string, title?: string) => void 8 | onHashChanged: (hash: string) => void 9 | onTitleChanged: (title: string) => void 10 | onNotFound: () => void 11 | } 12 | 13 | export default function IFrame(props: Props): JSX.Element { 14 | const iFrameRef = useRef(null) 15 | 16 | const onIframeLoad = (): void => { 17 | if (iFrameRef.current === null) { 18 | console.error('iFrameRef is null') 19 | return 20 | } 21 | 22 | // remove the event listeners to prevent memory leaks 23 | iFrameRef.current.contentWindow?.removeEventListener( 24 | 'hashchange', 25 | hashChangeEventListener 26 | ) 27 | iFrameRef.current.contentWindow?.removeEventListener( 28 | 'titlechange', 29 | titleChangeEventListener 30 | ) 31 | 32 | const url = iFrameRef.current?.contentDocument?.location.href 33 | if (url == null) { 34 | console.warn('IFrame onload event triggered, but url is null') 35 | return 36 | } 37 | 38 | // make all external links in iframe open in new tab 39 | // and make internal links replace the iframe url so that change 40 | // doesn't show up in the page history (we'd need to click back twice) 41 | iFrameRef.current.contentDocument 42 | ?.querySelectorAll('a') 43 | .forEach((a: HTMLAnchorElement) => { 44 | if (!a.href.startsWith(window.location.origin)) { 45 | a.setAttribute('target', '_blank') 46 | return 47 | } 48 | 49 | const href = a.getAttribute('href') ?? '' 50 | if (href.trim() === '') { 51 | // ignore empty links, may be handled with js internally. 52 | // Will inevitably cause the user to have to click back 53 | // multiple times to get back to the previous page. 54 | return 55 | } 56 | 57 | // From here: https://www.ozzu.com/questions/358584/how-do-you-ignore-iframes-javascript-history 58 | a.onclick = () => { 59 | iFrameRef.current?.contentWindow?.location.replace(a.href) 60 | return false 61 | } 62 | }) 63 | 64 | // React to page 404ing 65 | void (async (): Promise => { 66 | const response = await fetch(url, { method: 'HEAD' }) 67 | if (response.status === 404) { 68 | props.onNotFound() 69 | } 70 | })() 71 | 72 | // Add the event listener again 73 | iFrameRef.current.contentWindow?.addEventListener( 74 | 'hashchange', 75 | hashChangeEventListener 76 | ) 77 | iFrameRef.current.contentWindow?.addEventListener( 78 | 'titlechange', 79 | titleChangeEventListener 80 | ) 81 | 82 | const parts = url.split('/doc/').slice(1).join('/doc/').split('/') 83 | const urlPageAndHash = parts.slice(2).join('/') 84 | const hashIndex = urlPageAndHash.includes('#') 85 | ? urlPageAndHash.indexOf('#') 86 | : urlPageAndHash.length 87 | const urlPage = urlPageAndHash.slice(0, hashIndex) 88 | const urlHash = urlPageAndHash.slice(hashIndex) 89 | const title = iFrameRef.current?.contentDocument?.title 90 | 91 | props.onPageChanged(urlPage, urlHash, title) 92 | } 93 | 94 | const hashChangeEventListener = (): void => { 95 | if (iFrameRef.current === null) { 96 | console.error('hashChangeEvent from iframe but iFrameRef is null') 97 | return 98 | } 99 | 100 | const url = iFrameRef.current?.contentDocument?.location.href 101 | if (url == null) { 102 | return 103 | } 104 | 105 | let hash = url.split('#')[1] 106 | if (hash !== null) { 107 | hash = `#${hash}` 108 | } else { 109 | hash = '' 110 | } 111 | 112 | props.onHashChanged(hash) 113 | } 114 | 115 | const titleChangeEventListener = (): void => { 116 | if (iFrameRef.current === null) { 117 | console.error('titleChangeEvent from iframe but iFrameRef is null') 118 | return 119 | } 120 | 121 | const title = iFrameRef.current?.contentDocument?.title 122 | if (title == null) { 123 | return 124 | } 125 | 126 | props.onTitleChanged(title) 127 | } 128 | 129 | return ( 130 |