├── .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 | 
2 |
3 | **Host your docs. Simple. Versioned. Fancy.**
4 |
5 | [](https://github.com/docat-org/docat/actions)
6 | [](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 |
11 | We're sorry docat web doesn't work properly without JavaScript enabled. Please enable it to continue.
12 |
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 | {
75 | props.onVersionChange(e.target.value)
76 | }}
77 | value={
78 | props.versions.find((v) => v.name === props.version) !== undefined
79 | ? props.version
80 | : ''
81 | }
82 | >
83 | {props.versions
84 | .filter((v) => !v.hidden || v.name === props.version)
85 | .map((v) => (
86 |
87 | {v.name + (v.tags.length > 0 ? ` (${v.tags.join(', ')})` : '')}
88 |
89 | ))}
90 |
91 |
92 |
93 |
94 | {
97 | setShareModalOpen(true)
98 | }}
99 | >
100 |
101 |
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 | {
120 | void (async () => {
121 | await navigator.clipboard.writeText(getShareUrl())
122 | })()
123 | }}
124 | >
125 | Copy
126 |
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 |
{
161 | setShareModalOpen(false)
162 | }}
163 | >
164 | Close
165 |
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 |
128 | click to browse.
129 |
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 |
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 |
138 | )
139 | }
140 |
--------------------------------------------------------------------------------
/web/src/components/InfoBanner.tsx:
--------------------------------------------------------------------------------
1 | import { Alert, Snackbar } from '@mui/material'
2 | import { uniqueId } from 'lodash'
3 | import React, { useEffect, useState } from 'react'
4 | import { type Message } from '../data-providers/MessageBannerProvider'
5 |
6 | interface Props {
7 | message: Message
8 | }
9 |
10 | export default function Banner(props: Props): JSX.Element {
11 | const [show, setShow] = useState(false)
12 |
13 | useEffect(() => {
14 | setShow(true)
15 | }, [props.message])
16 |
17 | return (
18 | {
23 | setShow(false)
24 | }}
25 | >
26 | {
28 | setShow(false)
29 | }}
30 | severity={props.message.type}
31 | sx={{ width: '100%' }}
32 | >
33 | {props.message.content}
34 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/web/src/components/NavigationTitle.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowBackIos } from '@mui/icons-material'
2 | import { Link } from 'react-router-dom'
3 |
4 | import styles from './../style/components/NavigationTitle.module.css'
5 |
6 | interface Props {
7 | title: string
8 | backLink?: string
9 | description?: string | JSX.Element
10 | }
11 |
12 | export default function NavigationTitle(props: Props): JSX.Element {
13 | return (
14 |
15 |
16 |
20 |
21 |
22 |
{props.title}
23 |
24 |
25 |
{props.description}
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/web/src/components/PageLayout.tsx:
--------------------------------------------------------------------------------
1 | import styles from './../style/components/PageLayout.module.css'
2 | import Footer from './Footer'
3 | import Header from './Header'
4 | import NavigationTitle from './NavigationTitle'
5 |
6 | interface Props {
7 | title: string
8 | description?: string | JSX.Element
9 | showSearchBar?: boolean
10 | children: JSX.Element | JSX.Element[]
11 | }
12 |
13 | export default function PageLayout(props: Props): JSX.Element {
14 | return (
15 | <>
16 |
17 |
18 |
19 | {props.children}
20 |
21 |
22 | >
23 | )
24 | }
25 |
--------------------------------------------------------------------------------
/web/src/components/Project.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom'
2 | import { type Project as ProjectType } from '../models/ProjectsResponse'
3 | import ProjectRepository from '../repositories/ProjectRepository'
4 | import styles from './../style/components/Project.module.css'
5 |
6 | import { Box, Tooltip, Typography } from '@mui/material'
7 | import FavoriteStar from './FavoriteStar'
8 |
9 | interface Props {
10 | project: ProjectType
11 | onFavoriteChanged: () => void
12 | }
13 |
14 | function timeSince(date: Date) {
15 | const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
16 | let interval = seconds / 31536000;
17 |
18 | if (interval > 1) {
19 | return Math.floor(interval) + " years";
20 | }
21 | interval = seconds / 2592000;
22 | if (interval > 1) {
23 | return Math.floor(interval) + " months";
24 | }
25 | interval = seconds / 86400;
26 | if (interval > 1) {
27 | return Math.floor(interval) + " days";
28 | }
29 | interval = seconds / 3600;
30 | if (interval > 1) {
31 | return Math.floor(interval) + " hours";
32 | }
33 | interval = seconds / 60;
34 | if (interval > 1) {
35 | return Math.floor(interval) + " minutes";
36 | }
37 | return Math.floor(seconds) + " seconds";
38 | }
39 |
40 | export default function Project(props: Props): JSX.Element {
41 | const latestVersion = ProjectRepository.getLatestVersion(props.project.versions)
42 |
43 | return (
44 |
45 |
46 | {props.project.logo ?
47 | <>
48 |
49 |
54 |
55 | > : <>>
56 | }
57 |
58 |
59 |
60 |
61 | {props.project.name}{' '}
62 |
63 | {latestVersion.name}
64 |
65 |
66 |
67 |
68 |
69 |
75 | {timeSince(new Date(latestVersion.timestamp))} ago
76 |
77 |
78 |
79 |
80 |
81 | {props.project.versions.length === 1
82 | ? `${props.project.versions.length} version`
83 | : `${props.project.versions.length} versions`}
84 | {props.project.storage}
85 |
86 |
87 |
91 |
92 |
93 | )
94 | }
95 |
--------------------------------------------------------------------------------
/web/src/components/ProjectList.tsx:
--------------------------------------------------------------------------------
1 | import Project from './Project'
2 |
3 | import { type Project as ProjectType } from '../models/ProjectsResponse'
4 | import styles from './../style/components/ProjectList.module.css'
5 |
6 | interface Props {
7 | projects: ProjectType[]
8 | onFavoriteChanged: () => void
9 | }
10 |
11 | export default function ProjectList(props: Props): JSX.Element {
12 | if (props.projects.length === 0) {
13 | return <>>
14 | }
15 |
16 | return (
17 |
18 | {props.projects.map((project) => (
19 |
{
23 | props.onFavoriteChanged()
24 | }}
25 | />
26 | ))}
27 |
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/web/src/components/SearchBar.tsx:
--------------------------------------------------------------------------------
1 | import SearchIcon from '@mui/icons-material/Search';
2 | import StarIcon from '@mui/icons-material/Star';
3 | import StarBorderIcon from '@mui/icons-material/StarBorder';
4 | import { Divider, IconButton, InputBase, Paper, Tooltip } from '@mui/material';
5 | import React, { useEffect, useState } from 'react';
6 | import { useSearchParams } from 'react-router-dom';
7 | import { useSearch } from '../data-providers/SearchProvider';
8 |
9 |
10 | interface Props {
11 | showFavourites: boolean
12 | onShowFavourites: (all: boolean) => void
13 | }
14 |
15 | export default function SearchBar(props: Props): JSX.Element {
16 | const [showFavourites, setShowFavourites] = useState(true);
17 | const [searchParams, setSearchParams] = useSearchParams();
18 |
19 | const { query, setQuery } = useSearch()
20 | const [searchQuery, setSearchQuery] = React.useState(query)
21 |
22 |
23 | const updateSearch = (q: string) => {
24 | setSearchQuery(q)
25 | setQuery(q)
26 |
27 | if (q) {
28 | setSearchParams({q})
29 | } else {
30 | setSearchParams({})
31 | }
32 | }
33 |
34 | useEffect(() => {
35 | const q = searchParams.get("q")
36 | if (q) {
37 | updateSearch(q)
38 | }
39 | setShowFavourites(props.showFavourites)
40 | }, [props.showFavourites]);
41 |
42 | const onFavourites = (show: boolean): void => {
43 | setSearchParams({})
44 | setSearchQuery("")
45 | setQuery("")
46 |
47 | setShowFavourites(show)
48 | props.onShowFavourites(!show)
49 | }
50 |
51 | const onSearch = (e: React.ChangeEvent): void => {
52 | setShowFavourites(false)
53 | updateSearch(e.target.value)
54 | }
55 |
56 | return (
57 |
67 | {
74 | if (e.key === 'Enter') {
75 | e.preventDefault()
76 | setQuery(searchQuery)
77 | }
78 | }}
79 |
80 | />
81 |
82 |
83 |
84 |
85 |
86 | onFavourites(!showFavourites)} sx={{ p: '10px' }} aria-label="directions">
87 | { showFavourites ? : }
88 |
89 |
90 |
91 | )
92 | }
93 |
--------------------------------------------------------------------------------
/web/src/components/StyledForm.tsx:
--------------------------------------------------------------------------------
1 | import styles from './../style/components/StyledForm.module.css'
2 |
3 | interface Props {
4 | children: JSX.Element[]
5 | }
6 |
7 | export default function StyledForm(props: Props): JSX.Element {
8 | if (props.children.length === 0) {
9 | return <>>
10 | }
11 |
12 | return {props.children}
13 | }
14 |
--------------------------------------------------------------------------------
/web/src/data-providers/ConfigDataProvider.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return */
2 | /*
3 | We need any, because we don't know the type of the children,
4 | and we need the return those children again which is an "unsafe return"
5 | */
6 |
7 | import { createContext, useContext, useEffect, useState } from 'react'
8 |
9 | export interface Config {
10 | headerHTML?: string
11 | footerHTML?: string
12 | }
13 |
14 | const Context = createContext({})
15 |
16 | /**
17 | * Provides the config from the backend for the whole application,
18 | * so it can be used in every component without it being reloaded the whole time.
19 | */
20 | export const ConfigDataProvider = ({ children }: any): JSX.Element => {
21 | const [config, setConfig] = useState({})
22 |
23 | useEffect(() => {
24 | void (async () => {
25 | try {
26 | const res = await fetch('/doc/config.json')
27 | const data = (await res.json()) as Config
28 | setConfig(data)
29 | } catch (err) {
30 | console.error(err)
31 | }
32 | })()
33 | }, [])
34 |
35 | return {children}
36 | }
37 |
38 | export const useConfig = (): Config => useContext(Context)
39 |
--------------------------------------------------------------------------------
/web/src/data-providers/MessageBannerProvider.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
2 | /*
3 | We need any, because we don't know the type of the children,
4 | and we need the return those children again which is an "unsafe return"
5 | */
6 |
7 | import React, { useState, useCallback, useContext } from 'react'
8 | import Banner from '../components/InfoBanner'
9 |
10 | export interface Message {
11 | content: string | JSX.Element | undefined
12 | type: 'success' | 'info' | 'warning' | 'error'
13 | showMs: number | null // null = infinite
14 | }
15 |
16 | interface MessageBannerState {
17 | showMessage: (message: Message) => void
18 | clearMessages: () => void
19 | }
20 |
21 | export const Context = React.createContext({
22 | showMessage: (): void => {
23 | console.warn('MessageBannerProvider not initialized')
24 | },
25 | clearMessages: (): void => {
26 | console.warn('MessageBannerProvider not initialized')
27 | }
28 | })
29 |
30 | export function MessageBannerProvider({ children }: any): JSX.Element {
31 | // We need to store the last timeout, so we can clear when a new message is shown
32 | const [lastTimeout, setLastTimeout] = useState()
33 | const [message, setMessage] = useState({
34 | content: undefined,
35 | type: 'success',
36 | showMs: 6000
37 | })
38 |
39 | const showMessage = useCallback((message: Message) => {
40 | if (lastTimeout !== undefined) {
41 | clearTimeout(lastTimeout)
42 | }
43 |
44 | setMessage(message)
45 |
46 | if (message.showMs === null) {
47 | // don't hide message
48 | return
49 | }
50 |
51 | // Hide message after 6 seconds
52 | const newTimeout = setTimeout(() => {
53 | setMessage({
54 | content: undefined,
55 | type: 'success',
56 | showMs: 6000
57 | })
58 | }, message.showMs)
59 |
60 | setLastTimeout(newTimeout)
61 | }, [])
62 |
63 | const clearMessages = useCallback(() => {
64 | if (lastTimeout !== undefined) {
65 | clearTimeout(lastTimeout)
66 | }
67 |
68 | setMessage({
69 | content: undefined,
70 | type: 'success',
71 | showMs: 6000
72 | })
73 | }, [])
74 |
75 | return (
76 |
77 |
78 | {children}
79 |
80 | )
81 | }
82 |
83 | export const useMessageBanner = (): MessageBannerState => useContext(Context)
84 |
--------------------------------------------------------------------------------
/web/src/data-providers/ProjectDataProvider.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
2 | /*
3 | We need any, because we don't know the type of the children,
4 | and we need the return those children again which is an "unsafe return"
5 | */
6 |
7 | import React, { createContext, useContext, useEffect, useState } from 'react'
8 | import { type Project } from '../models/ProjectsResponse'
9 | import type ProjectsResponse from '../models/ProjectsResponse'
10 | import { useMessageBanner } from './MessageBannerProvider'
11 |
12 | interface ProjectState {
13 | projects: Project[] | null
14 | loadingFailed: boolean
15 | reload: () => void
16 | }
17 |
18 | const Context = createContext({
19 | projects: null,
20 | loadingFailed: false,
21 | reload: (): void => {
22 | console.warn('ProjectDataProvider not initialized')
23 | }
24 | })
25 |
26 | /**
27 | * Provides the projects for the whole application,
28 | * so that it can be used in every component without it being reloaded
29 | * the whole time or having to be passed down.
30 | *
31 | * If reloading is required, call the reload function.
32 | */
33 | export function ProjectDataProvider({ children }: any): JSX.Element {
34 | const { showMessage } = useMessageBanner()
35 |
36 | const loadData = (): void => {
37 | void (async (): Promise => {
38 | try {
39 | const response = await fetch('/api/projects?include_hidden=true')
40 |
41 | if (!response.ok) {
42 | throw new Error(
43 | `Failed to load projects, status code: ${response.status}`
44 | )
45 | }
46 |
47 | const data: ProjectsResponse = await response.json()
48 | setState({
49 | projects: data.projects,
50 | loadingFailed: false,
51 | reload: loadData
52 | })
53 | } catch (e) {
54 | console.error(e)
55 |
56 | showMessage({
57 | content: 'Failed to load projects',
58 | type: 'error',
59 | showMs: 6000
60 | })
61 |
62 | setState({
63 | projects: null,
64 | loadingFailed: true,
65 | reload: loadData
66 | })
67 | }
68 | })()
69 | }
70 |
71 | const [state, setState] = useState({
72 | projects: null,
73 | loadingFailed: false,
74 | reload: loadData
75 | })
76 |
77 | useEffect(() => {
78 | loadData()
79 | }, [])
80 |
81 | return {children}
82 | }
83 |
84 | export const useProjects = (): ProjectState => useContext(Context)
85 |
--------------------------------------------------------------------------------
/web/src/data-providers/SearchProvider.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
2 | /*
3 | We need any, because we don't know the type of the children,
4 | and we need the return those children again which is an "unsafe return"
5 | */
6 |
7 | import React, { createContext, useContext, useEffect, useState } from 'react'
8 | import { type Project } from '../models/ProjectsResponse'
9 | import { useProjects } from './ProjectDataProvider'
10 | import Fuse from 'fuse.js'
11 |
12 | interface SearchState {
13 | filteredProjects: Project[] | null
14 | query: string
15 | setQuery: (query: string) => void
16 | }
17 |
18 | const Context = createContext({
19 | filteredProjects: null,
20 | query: '',
21 | setQuery: (): void => {
22 | console.warn('SearchDataProvider not initialized')
23 | }
24 | })
25 |
26 | export function SearchProvider({ children }: any): JSX.Element {
27 | const { projects } = useProjects()
28 |
29 | const filterProjects = (query: string): Project[] | null => {
30 | if (projects == null) {
31 | return null
32 | }
33 |
34 | if (query.trim() === '') {
35 | return projects
36 | }
37 |
38 | const fuse = new Fuse(projects, {
39 | keys: ['name'],
40 | includeScore: true
41 | })
42 |
43 | // sort by match score
44 | return fuse
45 | .search(query)
46 | .sort((x, y) => (x.score ?? 0) - (y.score ?? 0))
47 | .map((result) => result.item)
48 | }
49 |
50 | const setQuery = (query: string): void => {
51 | setState({
52 | query,
53 | filteredProjects: filterProjects(query),
54 | setQuery
55 | })
56 | }
57 |
58 | const [state, setState] = useState({
59 | filteredProjects: null,
60 | query: '',
61 | setQuery
62 | })
63 |
64 | useEffect(() => {
65 | setState({
66 | query: '',
67 | filteredProjects: filterProjects(''),
68 | setQuery
69 | })
70 | }, [projects])
71 |
72 | return {children}
73 | }
74 |
75 | export const useSearch = (): SearchState => useContext(Context)
76 |
--------------------------------------------------------------------------------
/web/src/data-providers/StatsDataProvider.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
2 | /*
3 | We need any, because we don't know the type of the children,
4 | and we need the return those children again which is an "unsafe return"
5 | */
6 |
7 | import { createContext, useContext, useEffect, useState } from 'react'
8 | import { useMessageBanner } from './MessageBannerProvider'
9 |
10 |
11 | type Stats = {
12 | n_projects: number
13 | n_versions: number
14 | storage: string
15 | }
16 |
17 | interface StatsState {
18 | stats: Stats | null
19 | loadingFailed: boolean
20 | reload: () => void
21 | }
22 |
23 | const Context = createContext({
24 | stats: null,
25 | loadingFailed: false,
26 | reload: (): void => {
27 | console.warn('StatsProvider not initialized')
28 | }
29 | })
30 |
31 | /**
32 | * Provides the stats of the docat instance
33 | * If reloading is required, call the reload function.
34 | */
35 | export function StatsDataProvider({ children }: any): JSX.Element {
36 | const { showMessage } = useMessageBanner()
37 |
38 | const loadData = (): void => {
39 | void (async (): Promise => {
40 | try {
41 | const response = await fetch('/api/stats')
42 |
43 | if (!response.ok) {
44 | throw new Error(
45 | `Failed to load stats, status code: ${response.status}`
46 | )
47 | }
48 |
49 | const data: Stats = await response.json()
50 | setState({
51 | stats: data,
52 | loadingFailed: false,
53 | reload: loadData
54 | })
55 | } catch (e) {
56 | console.error(e)
57 |
58 | showMessage({
59 | content: 'Failed to load stats',
60 | type: 'error',
61 | showMs: 6000
62 | })
63 |
64 | setState({
65 | stats: null,
66 | loadingFailed: true,
67 | reload: loadData
68 | })
69 | }
70 | })()
71 | }
72 |
73 | const [state, setState] = useState({
74 | stats: null,
75 | loadingFailed: false,
76 | reload: loadData
77 | })
78 |
79 | useEffect(() => {
80 | loadData()
81 | }, [])
82 |
83 | return {children}
84 | }
85 |
86 | export const useStats = (): StatsState => useContext(Context)
87 |
--------------------------------------------------------------------------------
/web/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | font-family: "Roboto", Helvetica, Arial, sans-serif;
5 | -webkit-font-smoothing: antialiased;
6 | -moz-osx-font-smoothing: grayscale;
7 |
8 | --primary-foreground: #383838;
9 | --secondary-foreground: #e8e8e8;
10 | --button-primary: #2c3e50;
11 | --icons: #505050;
12 | }
13 |
14 | h1 {
15 | font-size: 30px;
16 | font-weight: 300;
17 | color: var(--primary-foreground);
18 | }
19 |
20 | a,
21 | u {
22 | text-decoration: none;
23 | color: black;
24 | }
25 |
26 | code {
27 | font-family: "Consolas", "Liberation Mono", Menlo, Courier, monospace;
28 | }
29 |
30 | .loading-spinner {
31 | --spinner-size: 40px;
32 |
33 | display: inline-block;
34 | width: var(--spinner-size);
35 | height: var(--spinner-size);
36 | margin-bottom: 5vh;
37 |
38 | position: relative;
39 | left: calc(50% - var(--spinner-size) / 2 - 8px);
40 | }
41 |
42 | .loading-spinner:after {
43 | content: " ";
44 | display: block;
45 | width: var(--spinner-size);
46 | height: var(--spinner-size);
47 | margin: 8px;
48 | border-radius: 50%;
49 | border: 6px solid var(--button-primary);
50 | border-color: var(--button-primary) transparent var(--button-primary)
51 | transparent;
52 | animation: loading-spinner 1.2s linear infinite;
53 | }
54 |
55 | @keyframes loading-spinner {
56 | 0% {
57 | transform: rotate(0deg);
58 | }
59 | 100% {
60 | transform: rotate(360deg);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/web/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import './index.css'
4 | import App from './App'
5 |
6 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
7 | root.render(
8 |
9 |
10 |
11 | )
12 |
--------------------------------------------------------------------------------
/web/src/models/ProjectDetails.ts:
--------------------------------------------------------------------------------
1 | export default class ProjectDetails {
2 | name: string
3 | hidden: boolean
4 | timestamp: Date
5 | tags: string[]
6 |
7 | constructor(name: string, tags: string[], hidden: boolean, timestamp: Date) {
8 | this.name = name
9 | this.tags = tags
10 | this.hidden = hidden
11 | this.timestamp = timestamp
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/web/src/models/ProjectsResponse.ts:
--------------------------------------------------------------------------------
1 | import type ProjectDetails from './ProjectDetails'
2 |
3 | export interface Project {
4 | name: string
5 | logo: boolean
6 | storage: string
7 | versions: ProjectDetails[]
8 | }
9 |
10 | export default interface ProjectsResponse {
11 | projects: Project[]
12 | }
13 |
--------------------------------------------------------------------------------
/web/src/pages/Claim.tsx:
--------------------------------------------------------------------------------
1 | import { TextField } from '@mui/material'
2 | import React, { useState } from 'react'
3 | import DataSelect from '../components/DataSelect'
4 | import PageLayout from '../components/PageLayout'
5 | import StyledForm from '../components/StyledForm'
6 | import { useMessageBanner } from '../data-providers/MessageBannerProvider'
7 | import { useProjects } from '../data-providers/ProjectDataProvider'
8 | import ProjectRepository from '../repositories/ProjectRepository'
9 |
10 | export default function Claim(): JSX.Element {
11 | const { projects, loadingFailed } = useProjects()
12 |
13 | const { showMessage } = useMessageBanner()
14 | const [project, setProject] = useState('none')
15 | const [token, setToken] = useState('')
16 |
17 | const [projectMissing, setProjectMissing] = useState(null)
18 |
19 | document.title = 'Claim Token | docat'
20 |
21 | const claim = async (): Promise => {
22 | if (project == null || project === '' || project === 'none') {
23 | setProjectMissing(true)
24 | return
25 | }
26 |
27 | try {
28 | const response = await ProjectRepository.claim(project)
29 | setToken(response.token)
30 | } catch (e) {
31 | console.error(e)
32 | showMessage({
33 | content: (e as { message: string }).message,
34 | type: 'error',
35 | showMs: 6000
36 | })
37 | }
38 | }
39 |
40 | /**
41 | * Returns loaded project names for DataSelect
42 | * @returns project names as string[] or an empty array
43 | */
44 | const getProjects = (): string[] => {
45 | if (loadingFailed || projects == null) {
46 | return []
47 | }
48 |
49 | return projects.map((project) => project.name)
50 | }
51 |
52 | const onProjectSelect = (p: string): void => {
53 | if (p == null || p === '' || p === 'none') {
54 | setProjectMissing(true)
55 | } else {
56 | setProjectMissing(false)
57 | }
58 |
59 | setProject(p)
60 | setToken('')
61 | }
62 |
63 | return (
64 |
68 |
69 |
79 |
80 | {token !== '' ? (
81 |
89 | {token}
90 |
91 | ) : (
92 | <>>
93 | )}
94 |
95 | {
99 | ;(async () => {
100 | await claim()
101 | })().catch((e) => {
102 | console.error(e)
103 | })
104 | }}
105 | >
106 | Claim
107 |
108 |
109 |
110 | )
111 | }
112 |
--------------------------------------------------------------------------------
/web/src/pages/Delete.tsx:
--------------------------------------------------------------------------------
1 | import { TextField } from '@mui/material'
2 | import React, { useEffect, useState } from 'react'
3 | import DataSelect from '../components/DataSelect'
4 | import ProjectRepository from '../repositories/ProjectRepository'
5 | import StyledForm from '../components/StyledForm'
6 | import PageLayout from '../components/PageLayout'
7 | import { useProjects } from '../data-providers/ProjectDataProvider'
8 | import type ProjectDetails from '../models/ProjectDetails'
9 | import { useMessageBanner } from '../data-providers/MessageBannerProvider'
10 |
11 | interface Validation {
12 | projectMissing?: boolean
13 | versionMissing?: boolean
14 | tokenMissing?: boolean
15 | }
16 |
17 | export default function Delete(): JSX.Element {
18 | const { showMessage } = useMessageBanner()
19 | const [project, setProject] = useState('none')
20 | const [version, setVersion] = useState('none')
21 | const [token, setToken] = useState('')
22 | const { projects, loadingFailed, reload } = useProjects()
23 | const [versions, setVersions] = useState([])
24 | const [validation, setValidation] = useState({})
25 |
26 | document.title = 'Delete Documentation | docat'
27 |
28 | useEffect(() => {
29 | if (project === '' || project === 'none') {
30 | setVersions([])
31 | return
32 | }
33 |
34 | setVersions(projects?.find((p) => p.name === project)?.versions ?? [])
35 | }, [project])
36 |
37 | const validate = (
38 | field: 'project' | 'version' | 'token',
39 | value: string
40 | ): boolean => {
41 | const valid = value !== 'none' && value !== ''
42 | setValidation({ ...validation, [`${field}Missing`]: !valid })
43 | return valid
44 | }
45 |
46 | const deleteDocumentation = (): void => {
47 | void (async () => {
48 | if (!validate('project', project)) return
49 | if (!validate('version', version)) return
50 | if (!validate('token', token)) return
51 |
52 | try {
53 | await ProjectRepository.deleteDoc(project, version, token)
54 |
55 | showMessage({
56 | type: 'success',
57 | content: `Documentation for ${project} (${version}) deleted successfully.`,
58 | showMs: 6000
59 | })
60 | setProject('none')
61 | setVersion('none')
62 | setToken('')
63 | reload()
64 | } catch (e) {
65 | console.error(e)
66 |
67 | showMessage({
68 | type: 'error',
69 | content: (e as { message: string }).message,
70 | showMs: 6000
71 | })
72 | }
73 | })()
74 | }
75 |
76 | /**
77 | * Returns loaded project names for DataSelect
78 | * @returns string[] or an empty array
79 | */
80 | const getProjects = (): string[] => {
81 | if (loadingFailed || projects == null) {
82 | return []
83 | }
84 |
85 | return projects.map((project) => project.name)
86 | }
87 |
88 | /**
89 | * Returns loaded Versions for DataSelect
90 | * @returns string[] or an empty array
91 | */
92 | const getVersions = (): string[] => {
93 | if (project === '' || project === 'none') {
94 | return []
95 | }
96 |
97 | return versions.map((v) => v.name)
98 | }
99 |
100 | return (
101 |
102 |
103 | {
108 | setProject(project)
109 | setVersion('none')
110 | validate('project', project)
111 | }}
112 | value={project ?? 'none'}
113 | errorMsg={
114 | validation.projectMissing === true
115 | ? 'Please select a Project'
116 | : undefined
117 | }
118 | />
119 | {
124 | setVersion(version)
125 | validate('version', version)
126 | }}
127 | value={version ?? 'none'}
128 | errorMsg={
129 | validation.versionMissing === true
130 | ? 'Please select a Version'
131 | : undefined
132 | }
133 | />
134 |
135 | {
140 | setToken(e.target.value)
141 | validate('token', e.target.value)
142 | }}
143 | error={validation.tokenMissing}
144 | helperText={
145 | validation.tokenMissing === true
146 | ? 'Please enter a Token'
147 | : undefined
148 | }
149 | >
150 | {token}
151 |
152 |
153 |
154 | Delete
155 |
156 |
157 |
158 | )
159 | }
160 |
--------------------------------------------------------------------------------
/web/src/pages/Docs.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useState, useRef } from 'react'
2 | import ProjectRepository from '../repositories/ProjectRepository'
3 | import type ProjectDetails from '../models/ProjectDetails'
4 | import LoadingPage from './LoadingPage'
5 | import NotFound from './NotFound'
6 | import DocumentControlButtons from '../components/DocumentControlButtons'
7 | import IFrame from '../components/IFrame'
8 | import { useLocation, useParams, useSearchParams } from 'react-router-dom'
9 | import { useMessageBanner } from '../data-providers/MessageBannerProvider'
10 | import IframeNotFound from './IframePageNotFound'
11 |
12 | export default function Docs(): JSX.Element {
13 | const params = useParams()
14 | const [searchParams] = useSearchParams()
15 | const location = useLocation()
16 | const { showMessage, clearMessages } = useMessageBanner()
17 |
18 | const [iframePageNotFound, setIframePageNotFound] = useState(false)
19 | const [versions, setVersions] = useState([])
20 | const [loadingFailed, setLoadingFailed] = useState(false)
21 |
22 | const project = useRef(params.project ?? '')
23 | const page = useRef(params['*'] ?? '')
24 | const hash = useRef(location.hash)
25 |
26 | const [version, setVersion] = useState(params.version ?? 'latest')
27 | const [hideUi, setHideUi] = useState(searchParams.get('hide-ui') === '' || searchParams.get('hide-ui') === 'true')
28 | const [iframeUpdateTrigger, setIframeUpdateTrigger] = useState(0)
29 |
30 | // This provides the url for the iframe.
31 | // It is always the same, except when the version changes,
32 | // as this memo will trigger a re-render of the iframe, which
33 | // is not needed when only the page or hash changes, because
34 | // the iframe keeps track of that itself.
35 | const iFrameSrc = useMemo(() => {
36 | return ProjectRepository.getProjectDocsURL(
37 | project.current,
38 | version,
39 | page.current,
40 | hash.current
41 | )
42 | }, [version, iframeUpdateTrigger])
43 |
44 | useEffect(() => {
45 | if (project.current === '') {
46 | return
47 | }
48 |
49 | void (async (): Promise => {
50 | try {
51 | let allVersions = await ProjectRepository.getVersions(project.current)
52 | if (allVersions.length === 0) {
53 | setLoadingFailed(true)
54 | return
55 | }
56 |
57 | allVersions = allVersions.sort((a, b) =>
58 | ProjectRepository.compareVersions(a, b)
59 | )
60 | setVersions(allVersions)
61 |
62 | const latestVersion =
63 | ProjectRepository.getLatestVersion(allVersions).name
64 | if (version === 'latest') {
65 | if (latestVersion === 'latest') {
66 | return
67 | }
68 | setVersion(latestVersion)
69 | return
70 | }
71 |
72 | // custom version -> check if it exists
73 | // if it does. do nothing, as it should be set already
74 | const versionsAndTags = allVersions
75 | .map((v) => [v.name, ...v.tags])
76 | .flat()
77 | if (versionsAndTags.includes(version)) {
78 | return
79 | }
80 |
81 | // version does not exist -> fail
82 | setLoadingFailed(true)
83 | console.error(`Version '${version}' doesn't exist`)
84 | } catch (e) {
85 | console.error(e)
86 | setLoadingFailed(true)
87 | }
88 | })()
89 | }, [project])
90 |
91 | /** Encodes the url for the current page.
92 | * @example
93 | * getUrl('project', 'version', 'path/to/page.html', '#hash', false) -> '/project/version/path/to/page.html#hash'
94 | */
95 | const getUrl = (
96 | project: string,
97 | version: string,
98 | page: string,
99 | hash: string,
100 | hideUi: boolean
101 | ): string => {
102 | return `/${project}/${version}/${encodeURI(page)}${hash}${hideUi ? '?hide-ui' : ''}`
103 | }
104 |
105 | const updateUrl = (newVersion: string, hideUi: boolean): void => {
106 | const url = getUrl(
107 | project.current,
108 | newVersion,
109 | page.current,
110 | hash.current,
111 | hideUi
112 | )
113 | window.history.pushState(null, '', url)
114 | }
115 |
116 | const updateTitle = (newTitle: string): void => {
117 | document.title = newTitle
118 | }
119 |
120 | // Keep compatibility with encoded page path
121 | useEffect(() => {
122 | updateUrl(version, hideUi)
123 | }, [])
124 |
125 | const iFramePageChanged = (urlPage: string, urlHash: string, title?: string): void => {
126 | if (title != null && title !== document.title) {
127 | updateTitle(title)
128 | }
129 | if (urlPage === page.current) {
130 | return
131 | }
132 | page.current = urlPage
133 | hash.current = urlHash
134 | updateUrl(version, hideUi)
135 | }
136 |
137 | const iFrameHashChanged = (newHash: string): void => {
138 | if (newHash === hash.current) {
139 | return
140 | }
141 | hash.current = newHash
142 | updateUrl(version, hideUi)
143 | }
144 |
145 | const iFrameNotFound = (): void => {
146 | setIframePageNotFound(true)
147 | }
148 |
149 | const onVersionChanged = (newVersion: string): void => {
150 | if (newVersion === version) {
151 | return
152 | }
153 | setVersion(newVersion)
154 | updateUrl(newVersion, hideUi)
155 | }
156 |
157 | useEffect(() => {
158 | const urlProject = params.project ?? ''
159 | const urlVersion = params.version ?? 'latest'
160 | const urlPage = params['*'] ?? ''
161 | const urlHash = location.hash
162 | const urlHideUi = searchParams.get('hide-ui') === '' || searchParams.get('hide-ui') === 'true'
163 |
164 | // update the state to the url params on first loadon
165 | if (urlProject !== project.current) {
166 | setVersions([])
167 | project.current = urlProject
168 | }
169 |
170 | if (urlVersion !== version) {
171 | setVersion(urlVersion)
172 | }
173 |
174 | if (urlHideUi !== hideUi) {
175 | setHideUi(urlHideUi)
176 | }
177 |
178 | if (urlPage !== page.current) {
179 | page.current = urlPage
180 | setIframeUpdateTrigger((v) => v + 1)
181 | }
182 | if (urlHash !== hash.current) {
183 | hash.current = urlHash
184 | setIframeUpdateTrigger((v) => v + 1)
185 | }
186 |
187 | setIframePageNotFound(false)
188 | }, [location])
189 |
190 | useEffect(() => {
191 | // check every time the version changes whether the version
192 | // is the latest version and if not, show a banner
193 | if (versions.length === 0) {
194 | return
195 | }
196 |
197 | const latestVersion = ProjectRepository.getLatestVersion(versions).name
198 | if (version === latestVersion) {
199 | clearMessages()
200 | return
201 | }
202 |
203 | showMessage({
204 | content: 'You are viewing an outdated version of the documentation.',
205 | type: 'warning',
206 | showMs: null
207 | })
208 | }, [version, versions])
209 |
210 | if (loadingFailed || project.current === '') {
211 | return
212 | }
213 |
214 | if (iframePageNotFound) {
215 | return (
216 |
221 | )
222 | }
223 |
224 | if (versions.length === 0) {
225 | return
226 | }
227 |
228 | return (
229 | <>
230 |
237 | {!hideUi && (
238 |
243 | )}
244 | >
245 | )
246 | }
247 |
--------------------------------------------------------------------------------
/web/src/pages/Help.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 | import ReactMarkdown from 'react-markdown'
3 |
4 | // @ts-expect-error ts can't read symbols from a md file
5 | import gettingStarted from './../assets/getting-started.md'
6 |
7 | import Footer from '../components/Footer'
8 | import Header from '../components/Header'
9 | import LoadingPage from './LoadingPage'
10 |
11 | import styles from './../style/pages/Help.module.css'
12 |
13 | export default function Help(): JSX.Element {
14 | document.title = 'Help | docat'
15 |
16 | const [content, setContent] = useState('')
17 | const [loading, setLoading] = useState(true)
18 |
19 | /**
20 | * Replaces the links to "http://localhost:3000" with the current url of the page
21 | * @param text the contents of the markdown file
22 | * @returns the contents of the markdown file with the links replaced
23 | */
24 | const replaceLinks = (text: string): string => {
25 | const protocol = document.location.protocol
26 | const host = document.location.hostname
27 | const port =
28 | document.location.port !== '' ? `:${document.location.port}` : ''
29 |
30 | const currentUrl = `${protocol}//${host}${port}`
31 |
32 | return text.replaceAll('http://localhost:8000', currentUrl)
33 | }
34 |
35 | // Load the markdown file
36 | useEffect(() => {
37 | void (async (): Promise => {
38 | try {
39 | // the import "gettingStarted" is just a path to the md file,
40 | // so we need to fetch the contents of the file manually
41 |
42 | const response = await fetch(gettingStarted as RequestInfo)
43 | const text = await response.text()
44 | const content = replaceLinks(text)
45 | setContent(content)
46 | } catch (e) {
47 | console.error(e)
48 | } finally {
49 | setLoading(false)
50 | }
51 | })()
52 | }, [])
53 |
54 | if (loading) {
55 | return
56 | }
57 |
58 | return (
59 | <>
60 |
61 |
62 | {content}
63 |
64 |
65 | >
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/web/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useNavigate } from 'react-router';
3 |
4 | import { Delete, ErrorOutline, FileUpload, KeyboardArrowDown, Lock } from '@mui/icons-material';
5 | import { useProjects } from '../data-providers/ProjectDataProvider';
6 | import { useSearch } from '../data-providers/SearchProvider';
7 | import { type Project } from '../models/ProjectsResponse';
8 |
9 | import Footer from '../components/Footer';
10 | import Header from '../components/Header';
11 | import ProjectList from '../components/ProjectList';
12 | import ProjectRepository from '../repositories/ProjectRepository';
13 | import LoadingPage from './LoadingPage';
14 |
15 | import { Box, Button, IconButton, Tooltip, Typography } from '@mui/material';
16 | import { Link } from 'react-router-dom';
17 | import SearchBar from '../components/SearchBar';
18 | import { useStats } from '../data-providers/StatsDataProvider';
19 | import styles from './../style/pages/Home.module.css';
20 |
21 |
22 | export default function Home(): JSX.Element {
23 | const navigate = useNavigate()
24 | const { loadingFailed } = useProjects()
25 | const { stats, loadingFailed: statsLoadingFailed } = useStats()
26 | const { filteredProjects: projects, query } = useSearch()
27 | const [showAll, setShowAll] = useState(false);
28 | const [favoriteProjects, setFavoriteProjects] = useState([])
29 |
30 | document.title = 'Home | docat'
31 |
32 | // Keep compatibility with hash-based URI
33 | if (location.hash.startsWith('#/')) {
34 | navigate(location.hash.replace('#', ''), { replace: true })
35 | }
36 |
37 | const updateFavorites = (): void => {
38 | if (projects == null) return
39 |
40 | setFavoriteProjects(
41 | projects.filter((project) => ProjectRepository.isFavorite(project.name))
42 | )
43 | }
44 |
45 | const onShowFavourites = (all: boolean): void => {
46 | setShowAll(all);
47 | }
48 |
49 | useEffect(() => {
50 | updateFavorites()
51 | }, [projects])
52 |
53 | if (loadingFailed || statsLoadingFailed) {
54 | return (
55 |
56 |
57 |
58 |
59 |
Failed to load projects
60 |
61 |
62 |
63 | )
64 | }
65 |
66 | if (projects == null || stats == null) {
67 | return
68 | }
69 |
70 | return (
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
87 |
88 |
95 |
96 |
97 |
98 |
99 |
100 |
104 |
105 |
106 |
107 |
108 |
109 |
113 |
114 |
115 |
116 |
117 |
118 |
122 |
123 |
124 |
125 |
126 |
127 |
128 | { projects.length === 0 ?
129 | <>{ query !== "" ?
130 |
131 | Couldn't find any docs
132 | :
133 |
134 | Looks like you don't have any docs yet.
135 | onShowFavourites(true)}>
136 | Get started now!
137 |
138 |
139 | }> :
140 | <>
141 | { (query || showAll) ?
142 | {
145 | updateFavorites()
146 | }}
147 | />
148 | :
149 | <>
150 | FAVOURITES
151 | { (favoriteProjects.length === 0) ?
152 |
153 | No docs favourited at the moment, search for docs or
154 | onShowFavourites(true)}>
155 | Show all docs.
156 |
157 |
158 | :
159 | <>
160 | {
163 | updateFavorites()
164 | }}
165 | />
166 |
167 |
170 | onShowFavourites(true)} >
171 | SHOW ALL DOCS
172 |
173 |
174 |
175 | >
176 | }
177 | >
178 | }
179 | >
180 | }
181 |
182 |
194 | INSTANCE STATS
195 |
196 |
197 | #
198 | DOCS
199 | {stats.n_projects}
200 |
201 |
202 | #
203 | VERSIONS
204 | {stats.n_versions}
205 |
206 |
207 | STORAGE
208 | {stats.storage}
209 |
210 |
211 |
212 |
213 | )
214 | }
215 |
--------------------------------------------------------------------------------
/web/src/pages/IframePageNotFound.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from 'react-router-dom'
3 | import Footer from '../components/Footer'
4 | import Header from '../components/Header'
5 | import styles from './../style/pages/IframePageNotFound.module.css'
6 |
7 | interface Props {
8 | project: string
9 | version: string
10 | hideUi: boolean
11 | }
12 |
13 | export default function IframeNotFound(props: Props): JSX.Element {
14 | const link = `/${props.project}/${props.version}${props.hideUi ? '?hide-ui' : ''}`
15 |
16 | return (
17 |
18 |
19 |
20 |
21 | 404 - Not Found
22 |
23 |
24 | Sorry, the page you were looking for was not found in this version.
25 |
26 |
27 | Back to Project Home
28 |
29 |
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/web/src/pages/LoadingPage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Footer from '../components/Footer'
3 | import Header from '../components/Header'
4 |
5 | export default function LoadingPage(): JSX.Element {
6 | return (
7 | <>
8 |
9 |
10 |
11 | >
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/web/src/pages/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom'
2 | import Footer from '../components/Footer'
3 | import Header from '../components/Header'
4 | import styles from './../style/pages/NotFound.module.css'
5 | import React from 'react'
6 |
7 | export default function NotFound(): JSX.Element {
8 | return (
9 |
10 |
11 |
12 |
404 - Not Found
13 |
14 | Sorry, the page you were looking for was not found.
15 |
16 |
17 | Home
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/web/src/pages/Upload.tsx:
--------------------------------------------------------------------------------
1 | import { TextField } from '@mui/material'
2 | import React, { useState } from 'react'
3 |
4 | import FileInput from '../components/FileInput'
5 | import PageLayout from '../components/PageLayout'
6 | import StyledForm from '../components/StyledForm'
7 | import { useMessageBanner } from '../data-providers/MessageBannerProvider'
8 | import { useProjects } from '../data-providers/ProjectDataProvider'
9 | import ProjectRepository from '../repositories/ProjectRepository'
10 | import LoadingPage from './LoadingPage'
11 |
12 | import styles from '../style/pages/Upload.module.css'
13 |
14 | interface Validation {
15 | projectMsg?: string
16 | versionMsg?: string
17 | fileMsg?: string
18 | }
19 |
20 | const okFileTypes = [
21 | 'application/zip',
22 | 'zip',
23 | 'application/octet-stream',
24 | 'application/x-zip',
25 | 'application/x-zip-compressed'
26 | ]
27 |
28 | export default function Upload(): JSX.Element {
29 | document.title = 'Upload | docat'
30 |
31 | const { reload: reloadProjects } = useProjects()
32 | const { showMessage } = useMessageBanner()
33 |
34 | const [project, setProject] = useState('')
35 | const [version, setVersion] = useState('')
36 | const [file, setFile] = useState(undefined)
37 | const [isUploading, setIsUploading] = useState(false)
38 | const [validation, setValidation] = useState({})
39 |
40 | const validateInput = (inputName: string, value: string): boolean => {
41 | const validationProp = `${inputName}Msg` as keyof typeof validation
42 |
43 | if (value.trim().length > 0) {
44 | setValidation({
45 | ...validation,
46 | [validationProp]: undefined
47 | })
48 | return true
49 | }
50 |
51 | const input = inputName.charAt(0).toUpperCase() + inputName.slice(1)
52 | const validationMsg = `${input} is required`
53 |
54 | setValidation({
55 | ...validation,
56 | [validationProp]: validationMsg
57 | })
58 | return false
59 | }
60 |
61 | const validateFile = (file: File | undefined): boolean => {
62 | if (file == null || file.name == null) {
63 | setValidation({
64 | ...validation,
65 | fileMsg: 'File is required'
66 | })
67 | return false
68 | }
69 |
70 | if (file.type == null) {
71 | setValidation({
72 | ...validation,
73 | fileMsg: 'Could not determine file type'
74 | })
75 | return false
76 | }
77 |
78 | if (okFileTypes.find((x) => x === file.type) === undefined) {
79 | setValidation({
80 | ...validation,
81 | fileMsg: 'This file type is not allowed'
82 | })
83 | return false
84 | }
85 |
86 | setValidation({
87 | ...validation,
88 | fileMsg: undefined
89 | })
90 | return true
91 | }
92 |
93 | const upload = (): void => {
94 | void (async () => {
95 | if (!validateInput('project', project)) return
96 | if (!validateInput('version', version)) return
97 | if (!validateFile(file) || file === undefined) return
98 |
99 | setIsUploading(true)
100 | const formData = new FormData()
101 | formData.append('file', file)
102 |
103 | const { success, message } = await ProjectRepository.upload(
104 | project,
105 | version,
106 | formData
107 | )
108 |
109 | if (!success) {
110 | console.error(message)
111 | showMessage({
112 | type: 'error',
113 | content: message,
114 | showMs: 6000
115 | })
116 | setIsUploading(false)
117 | return
118 | }
119 |
120 | // reset the form
121 | setProject('')
122 | setVersion('')
123 | setFile(undefined)
124 | setValidation({})
125 |
126 | showMessage({
127 | type: 'success',
128 | content: message,
129 | showMs: 6000
130 | })
131 |
132 | reloadProjects()
133 | setIsUploading(false)
134 | })()
135 | }
136 |
137 | if (isUploading) {
138 | return
139 | }
140 |
141 | const description = (
142 |
143 | If you want to automate the upload of your documentation consider using{' '}
144 | curl
to post it to the server. There are some examples in the{' '}
145 |
150 | docat repository
151 |
152 | .
153 |
154 | )
155 |
156 | return (
157 |
158 |
159 | {
165 | const project = e.target.value
166 | setProject(project)
167 | validateInput('project', project)
168 | }}
169 | error={validation.projectMsg !== undefined}
170 | helperText={validation.projectMsg}
171 | >
172 | {project}
173 |
174 |
175 | {
181 | const version = e.target.value
182 | setVersion(version)
183 | validateInput('version', version)
184 | }}
185 | error={validation.versionMsg !== undefined}
186 | helperText={validation.versionMsg}
187 | >
188 | {version}
189 |
190 |
191 | {
195 | setFile(file)
196 | }}
197 | okTypes={okFileTypes}
198 | isValid={validateFile}
199 | >
200 |
201 | {validation.fileMsg}
202 |
203 |
204 |
205 | Upload
206 |
207 |
208 |
209 | )
210 | }
211 |
--------------------------------------------------------------------------------
/web/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | import 'react-scripts'
2 |
--------------------------------------------------------------------------------
/web/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportCallback } from "web-vitals";
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportCallback) => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import("web-vitals").then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
6 | onCLS(onPerfEntry);
7 | onINP(onPerfEntry);
8 | onFCP(onPerfEntry);
9 | onLCP(onPerfEntry);
10 | onTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/web/src/repositories/ProjectRepository.ts:
--------------------------------------------------------------------------------
1 | import semver from 'semver'
2 | import type ProjectDetails from '../models/ProjectDetails'
3 | import { type Project } from '../models/ProjectsResponse'
4 |
5 | const RESOURCE = 'doc'
6 |
7 | function dateTimeReviver(key: string, value: any) {
8 | if (key === 'timestamp') {
9 | return new Date(value)
10 | }
11 | return value;
12 | }
13 |
14 | function filterHiddenVersions(allProjects: Project[]): Project[] {
15 | // create deep-copy first
16 | const projects = JSON.parse(JSON.stringify(allProjects), dateTimeReviver) as Project[]
17 |
18 | projects.forEach((p) => {
19 | p.versions = p.versions.filter((v) => !v.hidden)
20 | })
21 |
22 | return projects.filter((p) => p.versions.length > 0)
23 | }
24 |
25 | /**
26 | * Returns a list of all versions of a project.
27 | * @param {string} projectName Name of the project
28 | */
29 | async function getVersions(projectName: string): Promise {
30 | const res = await fetch(`/api/projects/${projectName}?include_hidden=true`)
31 |
32 | if (!res.ok) {
33 | console.error(((await res.json()) as { message: string }).message)
34 | return []
35 | }
36 |
37 | const json = (await res.json()) as {
38 | versions: ProjectDetails[]
39 | }
40 |
41 | return json.versions
42 | }
43 |
44 | /**
45 | * Returns the latest version of a project.
46 | * Order of precedence: latest, latest tag, latest version
47 | * @param versions all versions of a project
48 | */
49 | function getLatestVersion(versions: ProjectDetails[]): ProjectDetails {
50 | const latest = versions.find((v) => v.name.includes('latest'))
51 | if (latest != null) {
52 | return latest
53 | }
54 |
55 | const latestTag = versions.find((v) => v.tags.includes('latest'))
56 | if (latestTag != null) {
57 | return latestTag
58 | }
59 |
60 | const sortedVersions = versions.sort((a, b) => compareVersions(a, b))
61 |
62 | return sortedVersions[sortedVersions.length - 1]
63 | }
64 |
65 | /**
66 | * Returns the logo URL of a given project
67 | * @param {string} projectName Name of the project
68 | */
69 | function getProjectLogoURL(projectName: string): string {
70 | return `/${RESOURCE}/${projectName}/logo`
71 | }
72 |
73 | /**
74 | * Returns the project documentation URL
75 | * @param {string} projectName Name of the project
76 | * @param {string} version Version name
77 | * @param {string?} docsPath Path to the documentation page
78 | * @param {string?} hash Hash part of the url (html id)
79 | */
80 | function getProjectDocsURL(
81 | projectName: string,
82 | version: string,
83 | docsPath?: string,
84 | hash?: string
85 | ): string {
86 | return `/${RESOURCE}/${projectName}/${version}/${docsPath ?? ''}${hash ?? ''}`
87 | }
88 |
89 | /**
90 | * Uploads new project documentation
91 | * @param {string} projectName Name of the project
92 | * @param {string} version Name of the version
93 | * @param {FormData} body Data to upload
94 | * @returns {Promise<{ success: boolean, message: string }>} Success status and (error) message
95 | */
96 | async function upload(
97 | projectName: string,
98 | version: string,
99 | body: FormData
100 | ): Promise<{ success: boolean; message: string }> {
101 | try {
102 | const resp = await fetch(`/api/${projectName}/${version}`, {
103 | method: 'POST',
104 | body
105 | })
106 |
107 | if (resp.ok) {
108 | const json = (await resp.json()) as { message: string }
109 | const msg = json.message
110 | return { success: true, message: msg }
111 | }
112 |
113 | switch (resp.status) {
114 | case 401:
115 | return {
116 | success: false,
117 | message: 'Failed to upload documentation: Version already exists'
118 | }
119 | case 504:
120 | return {
121 | success: false,
122 | message: 'Failed to upload documentation: Server unreachable'
123 | }
124 | default:
125 | return {
126 | success: false,
127 | message: `Failed to upload documentation: ${((await resp.json()) as { message: string }).message}`
128 | }
129 | }
130 | } catch (e) {
131 | return {
132 | success: false,
133 | message: `Failed to upload documentation: ${(e as { message: string }).message}`
134 | }
135 | }
136 | }
137 |
138 | /**
139 | * Claim the project token
140 | * @param {string} projectName Name of the project
141 | */
142 | async function claim(projectName: string): Promise<{ token: string }> {
143 | const resp = await fetch(`/api/${projectName}/claim`)
144 |
145 | if (resp.ok) {
146 | const json = (await resp.json()) as { token: string }
147 | return json
148 | }
149 |
150 | switch (resp.status) {
151 | case 504:
152 | throw new Error('Failed to claim project: Server unreachable')
153 | default:
154 | throw new Error(
155 | `Failed to claim project: ${((await resp.json()) as { message: string }).message}`
156 | )
157 | }
158 | }
159 |
160 | /**
161 | * Deletes existing project documentation
162 | * @param {string} projectName Name of the project
163 | * @param {string} version Name of the version
164 | * @param {string} token Token to authenticate
165 | */
166 | async function deleteDoc(
167 | projectName: string,
168 | version: string,
169 | token: string
170 | ): Promise {
171 | const headers = { 'Docat-Api-Key': token }
172 | const resp = await fetch(`/api/${projectName}/${version}`, {
173 | method: 'DELETE',
174 | headers
175 | })
176 |
177 | if (resp.ok) return
178 |
179 | switch (resp.status) {
180 | case 401:
181 | throw new Error('Failed to delete documentation: Invalid token')
182 | case 504:
183 | throw new Error('Failed to delete documentation: Server unreachable')
184 | default:
185 | throw new Error(
186 | `Failed to delete documentation: ${((await resp.json()) as { message: string }).message}`
187 | )
188 | }
189 | }
190 |
191 | /**
192 | * Compare two versions according to semantic version (semver library)
193 | * Will always consider the version latest as higher version
194 | *
195 | * @param {Object} versionA first version to compare
196 | * @param {string} versionA.name version name
197 | * @param {string[] | undefined} versionA.tags optional tags for this vertion
198 | *
199 | * @param {Object} versionB second version to compare
200 | * @param {string} versionB.name version name
201 | * @param {string[] | undefined} versionB.tags optional tags for this vertion
202 | */
203 | function compareVersions(
204 | versionA: { name: string; tags?: string[] },
205 | versionB: { name: string; tags?: string[] }
206 | ): number {
207 | if ((versionA.tags ?? []).includes('latest')) {
208 | return 1
209 | }
210 |
211 | if ((versionB.tags ?? []).includes('latest')) {
212 | return -1
213 | }
214 |
215 | const semverA = semver.coerce(versionA.name)
216 | const semverB = semver.coerce(versionB.name)
217 |
218 | if (semverA == null || semverB == null) {
219 | return versionA.name.localeCompare(versionB.name)
220 | }
221 |
222 | return semver.compare(semverA, semverB)
223 | }
224 |
225 | /**
226 | * Returns boolean indicating if the project name is part of the favorites.
227 | * @param {string} projectName name of the project
228 | * @returns {boolean} - true is project is favorite
229 | */
230 | function isFavorite(projectName: string): boolean {
231 | return localStorage.getItem(projectName) === 'favorite'
232 | }
233 |
234 | /**
235 | * Sets favorite preference on project
236 | * @param {string} projectName
237 | * @param {boolean} shouldBeFavorite
238 | */
239 | function setFavorite(projectName: string, shouldBeFavorite: boolean): void {
240 | if (shouldBeFavorite) {
241 | localStorage.setItem(projectName, 'favorite')
242 | } else {
243 | localStorage.removeItem(projectName)
244 | }
245 | }
246 |
247 | const exp = {
248 | getVersions,
249 | getLatestVersion,
250 | filterHiddenVersions,
251 | getProjectLogoURL,
252 | getProjectDocsURL,
253 | upload,
254 | claim,
255 | deleteDoc,
256 | compareVersions,
257 | isFavorite,
258 | setFavorite
259 | }
260 |
261 | export default exp
262 |
--------------------------------------------------------------------------------
/web/src/style/components/ControlButtons.module.css:
--------------------------------------------------------------------------------
1 | .upload-button,
2 | .claim-button,
3 | .delete-button,
4 | .single-control-button {
5 | width: 50px;
6 | height: 50px;
7 | border-radius: 50%;
8 | background-color: var(--primary-foreground);
9 | color: white;
10 | position: fixed;
11 | bottom: 20px;
12 | border: none;
13 | cursor: pointer;
14 | z-index: 1; /* Without this, the footer is on top of the controls */
15 |
16 | /* Center the icon */
17 | display: flex;
18 | justify-content: center;
19 | align-items: center;
20 | }
21 |
22 | .upload-button {
23 | right: 180px;
24 | }
25 |
26 | .claim-button {
27 | right: 100px;
28 | }
29 |
30 | .delete-button,
31 | .single-control-button {
32 | right: 20px;
33 | }
34 |
--------------------------------------------------------------------------------
/web/src/style/components/DocumentControlButtons.module.css:
--------------------------------------------------------------------------------
1 |
2 | .controls {
3 | position: fixed;
4 | bottom: 32px;
5 | right: 32px;
6 | height: 50px;
7 | display: flex;
8 | }
9 |
10 | .home-button,
11 | .share-button {
12 | height: 50px;
13 | width: 50px;
14 | color: rgba(0, 0, 0, 0.87);
15 | display: flex;
16 | align-items: center;
17 | justify-content: center;
18 | border: none;
19 | }
20 |
21 | .home-button:hover,
22 | .share-button:hover {
23 | background-color: #dbdbdb;
24 | }
25 |
26 | .home-button {
27 | background-color: #efefef;
28 | border-top-left-radius: 0.5rem;
29 | border-bottom-left-radius: 0.5rem;
30 | }
31 |
32 | .share-button {
33 | color: rgba(0, 0, 0, 0.87);
34 | margin-left: 8px;
35 | border-radius: 0.5rem;
36 | cursor: pointer;
37 | }
38 |
39 | .version-select {
40 | background: white;
41 | overflow: hidden;
42 |
43 | padding: 9px;
44 | width: 200px;
45 |
46 | font-size: 1.05em;
47 |
48 | border-left-color: #efefef !important;
49 | border-top-left-radius: 0 !important;
50 | border-bottom-left-radius: 0 !important;
51 |
52 | }
53 |
54 |
55 | .version-select:focus-visible {
56 | outline: none;
57 | }
58 |
59 |
60 | .share-modal {
61 | display:flex;
62 | flex-direction: column;
63 |
64 | position: absolute;
65 | top: 50%;
66 | left: 50%;
67 | transform: translate(-50%, -50%);
68 |
69 | width: 400px;
70 | max-width: 80vw;
71 | height: 200px;
72 | overflow: hidden;
73 |
74 | background-color: #fff;
75 | border: none;
76 | border-radius: 0.5rem;
77 | padding: 1rem;
78 | outline: 0;
79 | }
80 |
81 | .share-modal > * {
82 | margin-bottom: 1rem;
83 | }
84 |
85 | .share-modal-link-container {
86 | display: flex;
87 | }
88 |
89 | .share-modal-link {
90 | padding: 0.5rem 1rem;
91 | border-radius: 0.5rem;
92 | border: 1px solid rgba(0, 0, 0, 0.42);
93 | word-break: break-all;
94 | user-select: all;
95 | -moz-user-select: all;
96 | -webkit-user-select: all;
97 | font-size: small;
98 | width: 100%;
99 | }
100 |
101 | .share-modal-copy-container {
102 | display: flex;
103 | align-items: center;
104 | margin-left: 1rem;
105 | }
106 |
107 | .share-modal-copy {
108 | padding: 0.5rem 1rem;
109 | border: none;
110 | border-radius: 0.5rem;
111 | background-color: var(--primary-foreground);
112 | color: #fff;
113 | cursor: pointer;
114 | }
115 |
116 | .share-modal-close {
117 | position: absolute;
118 | bottom: 0.5rem;
119 | right: 1rem;
120 | border: none;
121 | background-color: transparent;
122 | cursor: pointer;
123 | font-weight: bold;
124 | }
125 |
126 | .share-modal-label span {
127 | font-size: small !important;
128 | }
129 |
130 | @media only screen and (max-width: 380px) {
131 | .controls {
132 | left: 32px;
133 | }
134 |
135 | .version-select {
136 | width: calc(100vw - 100px - 64px)
137 | }
138 |
139 | .share-modal-link-container {
140 | flex-direction: column;
141 | align-items: center;
142 | }
143 |
144 | .share-modal-link {
145 | width: auto;
146 | }
147 |
148 | .share-modal-copy-container {
149 | margin-left: 0;
150 | margin-top: 1rem;
151 | width: 100%;
152 | justify-content: center;
153 | }
154 |
155 | .share-modal-copy {
156 | width: 80%;
157 | }
158 | }
159 |
--------------------------------------------------------------------------------
/web/src/style/components/FileInput.module.css:
--------------------------------------------------------------------------------
1 | .file-upload-container {
2 | display: flex;
3 | flex-direction: column;
4 | max-height: 250px;
5 | }
6 |
7 | .file-drop-zone {
8 | display: flex;
9 | flex-direction: column;
10 | justify-content: center;
11 | align-items: center;
12 | height: max(150px, 25vh);
13 | border-radius: 5px;
14 | border: 1px solid rgba(0, 0, 0, 0.23);
15 | cursor: pointer;
16 | }
17 |
18 | .file-drop-zone > * {
19 | color: grey;
20 | }
21 |
22 | .drag-active {
23 | background-color: var(--secondary-foreground);
24 | }
25 |
26 | .file-input {
27 | display: none;
28 | }
29 |
30 | .file-upload-button {
31 | cursor: pointer;
32 | padding: 0.25rem;
33 | border: none;
34 | font-size: 1em;
35 | background-color: transparent;
36 | }
37 |
38 | .drag-file-element {
39 | position: absolute;
40 | width: 100%;
41 | height: 100%;
42 | border-radius: 5px;
43 | top: 0px;
44 | right: 0px;
45 | bottom: 0px;
46 | left: 0px;
47 | }
48 |
49 | .file-upload-button:hover {
50 | text-decoration-line: underline;
51 | }
52 |
53 | .file-upload-label {
54 | background-color: white;
55 | width: fit-content;
56 | padding: 0 0.45em !important;
57 | font-size: 0.8em !important;
58 | z-index: 1;
59 | top: 10px;
60 | left: 0.75em;
61 | }
62 |
--------------------------------------------------------------------------------
/web/src/style/components/Footer.module.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | margin-top: 32px;
3 | padding: 15px 0 15px 0;
4 | }
5 |
6 | .help-link {
7 | font-weight: 200;
8 | text-decoration: none;
9 | font-size: 20px;
10 | padding-left: 8px;
11 | }
12 |
13 | .version-info {
14 | font-weight: 200;
15 | font-size: 14px;
16 | margin-top: 0.8em;
17 | color: var(--primary-foreground);
18 | padding-left: 8px;
19 | }
20 |
21 | @media only screen and (min-width: 1000px) {
22 | .footer {
23 | padding-left: calc(15% + 16px);
24 | }
25 | }
26 |
27 | @media only screen and (max-width: 1000px) {
28 | .footer {
29 | padding-left: 30px;
30 | }
31 | }
32 |
33 | @media only screen and (max-width: 300px) {
34 | .footer {
35 | padding-left: 10px;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/web/src/style/components/Header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | --header-height: 74px;
3 |
4 | min-width: 230px;
5 | height: var(--header-height);
6 | border-bottom: 1px solid var(--secondary-foreground);
7 | }
8 |
9 | @media only screen and (min-width: 1000px) {
10 | .header {
11 | padding-left: calc(15% + 16px);
12 | }
13 | }
14 |
15 | @media only screen and (max-width: 1000px) {
16 | .header {
17 | padding-left: 30px;
18 | }
19 | }
20 |
21 | @media only screen and (max-width: 300px) {
22 | .header {
23 | padding-left: 10px;
24 | }
25 | }
26 |
27 | img {
28 | height: var(--header-height);
29 | float: left;
30 | }
31 |
32 | h1 {
33 | float: left;
34 | margin-top: calc(var(--header-height) / 2 - 15px);
35 | margin-left: 10px;
36 | font-size: 30px;
37 | font-weight: 00;
38 | }
39 |
--------------------------------------------------------------------------------
/web/src/style/components/IFrame.module.css:
--------------------------------------------------------------------------------
1 | html {
2 | overflow: auto;
3 | }
4 |
5 | html,
6 | body,
7 | iframe {
8 | margin: 0px;
9 | padding: 0px;
10 | height: 100%;
11 | border: none;
12 | }
13 |
14 | iframe {
15 | height: 100vh;
16 | }
17 |
18 | iframe {
19 | position: relative;
20 | display: block;
21 | width: 100%;
22 | border: none;
23 | overflow-y: auto;
24 | overflow-x: hidden;
25 | }
26 |
--------------------------------------------------------------------------------
/web/src/style/components/NavigationTitle.module.css:
--------------------------------------------------------------------------------
1 | .nav-title {
2 | display: flex;
3 | flex-direction: column;
4 | width: 100%;
5 | }
6 |
7 | @media only screen and (min-width: 1400px) {
8 | .nav-title {
9 | width: 50%;
10 | }
11 | }
12 |
13 | .page-header {
14 | display: flex;
15 | flex-direction: row;
16 | align-items: center;
17 | }
18 |
19 | .back-link {
20 | height: 24px;
21 | width: 24px;
22 | }
23 |
24 | .page-title {
25 | padding-left: 15px;
26 | overflow-x: hidden;
27 | text-overflow: ellipsis;
28 | }
29 |
30 | .page-description {
31 | padding: 16px 0 16px 0;
32 | font-size: 0.9em;
33 | }
34 |
35 | .page-description a {
36 | text-decoration: underline;
37 | }
38 |
--------------------------------------------------------------------------------
/web/src/style/components/PageLayout.module.css:
--------------------------------------------------------------------------------
1 | .main {
2 | display: flex;
3 | flex-direction: column;
4 | }
5 |
6 | @media only screen and (min-width: 650px) {
7 | .main {
8 | padding: 1% 0;
9 | margin: 3% 16% 1% 16%;
10 | }
11 | }
12 |
13 | @media only screen and (max-width: 650px) {
14 | .main {
15 | padding: 1% 0;
16 | margin: 3%;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/web/src/style/components/Project.module.css:
--------------------------------------------------------------------------------
1 | .project-card {
2 | max-width: 800px;
3 | margin-left: 24px;
4 | margin-bottom: 8px;
5 | margin-top: 8px;
6 | }
7 |
8 | .secondary-typography {
9 | color: var(--primary-foreground);
10 | opacity: 0.6;
11 | }
12 |
13 | .project-header {
14 | display: flex;
15 | justify-content: space-between;
16 | margin-bottom: 6px;
17 | }
18 |
19 | .project-footer {
20 | display: flex;
21 | justify-content: space-between;
22 | margin-bottom: 6px;
23 | }
24 |
25 | .subhead {
26 | color: var(--primary-foreground);
27 | opacity: 0.54;
28 | font-size: 16px;
29 | }
30 |
31 | .project-card-title {
32 | font-weight: 400;
33 | font-size: 1.1em;
34 | }
35 |
36 | .project-logo {
37 | float: left;
38 | width: 40px;
39 | height: 40px;
40 | border-radius: 50%;
41 | margin-right: 16px;
42 | }
43 |
--------------------------------------------------------------------------------
/web/src/style/components/ProjectList.module.css:
--------------------------------------------------------------------------------
1 | .project-list {
2 | display: grid;
3 | }
4 |
5 | .project-list {
6 | min-width: 250px;
7 | margin-right: 32px;
8 | }
9 |
--------------------------------------------------------------------------------
/web/src/style/components/SearchBar.module.css:
--------------------------------------------------------------------------------
1 | .search-bar {
2 | border: 1px solid #e8e8e8;
3 | float: right;
4 | margin: 8px 16px 0 0;
5 | border: none;
6 | }
7 |
8 | @media only screen and (max-width: 500px) {
9 | .search-bar {
10 | display: none;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/web/src/style/components/StyledForm.module.css:
--------------------------------------------------------------------------------
1 | .form {
2 | display: flex;
3 | flex-direction: column;
4 | }
5 |
6 | .form > div {
7 | margin: 16px 0 0 0;
8 | width: 100%;
9 | min-height: 5em;
10 | }
11 |
12 | @media only screen and (min-width: 1400px) {
13 | .form {
14 | width: 50%;
15 | }
16 | }
17 |
18 | @media only screen and (max-width: 1400px) {
19 | .form {
20 | width: 100%;
21 | }
22 | }
23 |
24 | button[type='submit'] {
25 | margin-top: 16px;
26 | padding: 8px;
27 | width: 100%;
28 | max-width: 175px;
29 | font-size: 1.05em;
30 | border-radius: 8px;
31 | border: none;
32 | background-color: var(--button-primary);
33 | color: white;
34 | cursor: pointer;
35 | }
36 |
37 | button:disabled {
38 | background-color: gray;
39 | cursor: not-allowed;
40 | }
41 |
--------------------------------------------------------------------------------
/web/src/style/pages/Home.module.css:
--------------------------------------------------------------------------------
1 | .loading-error {
2 | color: red;
3 | text-align: center;
4 | padding: 30px 0;
5 | font-size: large;
6 | }
7 |
8 | .no-results {
9 | text-align: center;
10 | font-size: 130%;
11 | margin: 16px;
12 | }
13 |
14 | .divider {
15 | border-bottom: 1px solid var(--secondary-foreground);
16 | margin-bottom: 16px;
17 | padding-bottom: 16px;
18 | }
19 |
20 | .project-overview {
21 | display: flex;
22 | flex-direction: row;
23 | align-items: flex-start;
24 | }
25 |
26 | .card {
27 | border: 1px solid var(--secondary-foreground);
28 | border-radius: 5px;
29 | width: 250px;
30 | padding: 16px;
31 | margin-left: 16px;
32 | }
33 |
34 | .clear {
35 | clear: both;
36 | }
37 |
38 | .card-header {
39 | margin-top: 8px;
40 | }
41 |
42 | @media only screen and (min-width: 1000px) {
43 | .project-overview {
44 | margin: 20px 15%;
45 | width: 70%;
46 | }
47 | }
48 |
49 | @media only screen and (max-width: 1000px) {
50 | .project-overview {
51 | margin: 20px;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/web/src/style/pages/IframePageNotFound.module.css:
--------------------------------------------------------------------------------
1 | .iframe-page-not-found {
2 | height: fit-content;
3 | min-height: 100vh;
4 | display: flex;
5 | flex-direction: column;
6 | justify-content: space-between;
7 | }
8 |
9 | .iframe-page-not-found-container {
10 | display: flex;
11 | flex-direction: column;
12 | justify-content: center;
13 | align-items: center;
14 |
15 | width: 100vw;
16 | }
17 |
18 | .iframe-page-not-found-title {
19 | font-weight: 600;
20 | }
21 |
22 | .iframe-page-not-found-text {
23 | margin-top: 10px;
24 | font-size: 1.2rem;
25 | text-align: center;
26 | }
27 |
28 | .iframe-page-not-found-link {
29 | background-color: var(--button-primary);
30 | color: white;
31 | margin: 15px 0;
32 | padding: 8px 16px;
33 | border-radius: 8px;
34 | }
35 |
--------------------------------------------------------------------------------
/web/src/style/pages/NotFound.module.css:
--------------------------------------------------------------------------------
1 | .not-found {
2 | height: fit-content;
3 | min-height: 100vh;
4 | display: flex;
5 | flex-direction: column;
6 | justify-content: space-between;
7 | }
8 |
9 | .not-found-container {
10 | display: flex;
11 | flex-direction: column;
12 | justify-content: center;
13 | align-items: center;
14 |
15 | width: 100vw;
16 | }
17 |
18 | .not-found-title {
19 | font-weight: 600;
20 | }
21 |
22 | .not-found-text {
23 | margin-top: 10px;
24 | font-size: 1.2rem;
25 | text-align: center;
26 | }
27 |
28 | .not-found-link {
29 | background-color: var(--button-primary);
30 | color: white;
31 | margin: 15px 0;
32 | padding: 8px 16px;
33 | border-radius: 8px;
34 | }
35 |
--------------------------------------------------------------------------------
/web/src/style/pages/Upload.module.css:
--------------------------------------------------------------------------------
1 | .validation-message {
2 | padding-top: 5px;
3 | margin-left: 14px;
4 | font-size: 0.8em;
5 | min-height: 1em;
6 | }
7 |
8 | .red {
9 | color: red;
10 | }
11 |
--------------------------------------------------------------------------------
/web/src/tests/repositories/ProjectRepository.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/require-await, @typescript-eslint/no-floating-promises */
2 | // -> we need any for our mocks, and we need to disable require-await because we need to mock async functions that throw errors
3 |
4 | import ProjectDetails from '../../models/ProjectDetails'
5 | import { type Project } from '../../models/ProjectsResponse'
6 | import ProjectRepository from '../../repositories/ProjectRepository'
7 | const mockFetchData = (fetchData: any): void => {
8 | global.fetch = vi.fn().mockImplementation(
9 | async () =>
10 | await Promise.resolve({
11 | ok: true,
12 | json: async () => await Promise.resolve(fetchData)
13 | })
14 | )
15 | }
16 |
17 | const mockFetchError = (errorMsg = 'Error'): void => {
18 | global.fetch = vi.fn().mockImplementation(
19 | async () =>
20 | await Promise.resolve({
21 | ok: false,
22 | json: async () => await Promise.resolve({ message: errorMsg })
23 | })
24 | )
25 | }
26 |
27 | const mockFetchStatus = (status: number, message?: string): void => {
28 | global.fetch = vi.fn().mockImplementation(
29 | async () =>
30 | await Promise.resolve({
31 | ok: false,
32 | status,
33 | json: async () => await Promise.resolve({ message: message ?? 'Error' })
34 | })
35 | )
36 | }
37 |
38 | describe('get versions', () => {
39 | test('should return versions', async () => {
40 | const projectName = 'test'
41 | const versions = ['1.0.0', '2.0.0']
42 | const responseData = versions.map(
43 | (version) => new ProjectDetails(version, ['tag'], false, new Date())
44 | )
45 |
46 | mockFetchData({ versions: responseData })
47 |
48 | const result = await ProjectRepository.getVersions(projectName)
49 |
50 | expect(result).toEqual(responseData)
51 | })
52 |
53 | test('should return empty array on error and log error', async () => {
54 | const projectName = 'test'
55 |
56 | mockFetchError('Test Error')
57 | console.error = vi.fn()
58 |
59 | const result = await ProjectRepository.getVersions(projectName)
60 |
61 | expect(result).toEqual([])
62 | expect(console.error).toBeCalledWith('Test Error')
63 | })
64 | })
65 |
66 | describe('get project logo url', () => {
67 | test('should return the correct url', () => {
68 | const projectName = 'test-project'
69 |
70 | const result = ProjectRepository.getProjectLogoURL(projectName)
71 |
72 | expect(result).toEqual(`/doc/${projectName}/logo`)
73 | })
74 | })
75 |
76 | describe('get project docs url', () => {
77 | test('should return the correct url without path', () => {
78 | const projectName = 'test-project'
79 | const version = '1.0.0'
80 |
81 | const result = ProjectRepository.getProjectDocsURL(projectName, version)
82 |
83 | expect(result).toEqual(`/doc/${projectName}/${version}/`)
84 | })
85 |
86 | test('should return the correct url with path', () => {
87 | const projectName = 'test-project'
88 | const version = '1.0.0'
89 | const path = 'path/to/file'
90 |
91 | const result = ProjectRepository.getProjectDocsURL(
92 | projectName,
93 | version,
94 | path
95 | )
96 |
97 | expect(result).toEqual(`/doc/${projectName}/${version}/${path}`)
98 | })
99 | })
100 |
101 | describe('upload', () => {
102 | test('should post file', async () => {
103 | const project = 'test-project'
104 | const version = '1.0.0'
105 |
106 | mockFetchData({ message: 'Documentation was uploaded successfully' })
107 |
108 | const body = new FormData()
109 | body.append('file', new Blob([''], { type: 'text/plain' }))
110 |
111 | const { success, message } = await ProjectRepository.upload(
112 | project,
113 | version,
114 | body
115 | )
116 |
117 | expect(global.fetch).toHaveBeenCalledTimes(1)
118 | expect(global.fetch).toHaveBeenCalledWith(`/api/${project}/${version}`, {
119 | body,
120 | method: 'POST'
121 | })
122 |
123 | expect(success).toEqual(true)
124 | expect(message).toEqual('Documentation was uploaded successfully')
125 | })
126 |
127 | test('should throw version already exists on 401 status code', async () => {
128 | const project = 'test-project'
129 | const version = '1.0.0'
130 |
131 | mockFetchStatus(401)
132 |
133 | const body = new FormData()
134 | body.append('file', new Blob([''], { type: 'text/plain' }))
135 |
136 | const { success, message } = await ProjectRepository.upload(
137 | project,
138 | version,
139 | body
140 | )
141 |
142 | expect(success).toEqual(false)
143 | expect(message).toEqual(
144 | 'Failed to upload documentation: Version already exists'
145 | )
146 | })
147 |
148 | test('should throw server unreachable on 504 status code', async () => {
149 | const project = 'test-project'
150 | const version = '1.0.0'
151 |
152 | mockFetchStatus(504)
153 |
154 | const body = new FormData()
155 | body.append('file', new Blob([''], { type: 'text/plain' }))
156 |
157 | const { success, message } = await ProjectRepository.upload(
158 | project,
159 | version,
160 | body
161 | )
162 |
163 | expect(success).toEqual(false)
164 | expect(message).toEqual(
165 | 'Failed to upload documentation: Server unreachable'
166 | )
167 | })
168 |
169 | test('should throw error on other status code', async () => {
170 | const project = 'test-project'
171 | const version = '1.0.0'
172 |
173 | mockFetchStatus(500, 'Test Error')
174 |
175 | const body = new FormData()
176 | body.append('file', new Blob([''], { type: 'text/plain' }))
177 |
178 | const { success, message } = await ProjectRepository.upload(
179 | project,
180 | version,
181 | body
182 | )
183 |
184 | expect(success).toEqual(false)
185 | expect(message).toEqual('Failed to upload documentation: Test Error')
186 | })
187 | })
188 |
189 | describe('claim project', () => {
190 | test('should call claim api with project name', async () => {
191 | const project = 'test-project'
192 |
193 | mockFetchData({ token: 'test-token' })
194 |
195 | const respToken = await ProjectRepository.claim(project)
196 |
197 | expect(global.fetch).toHaveBeenCalledTimes(1)
198 | expect(global.fetch).toHaveBeenCalledWith(`/api/${project}/claim`)
199 | expect(respToken.token).toEqual('test-token')
200 | })
201 |
202 | test('should throw error when project already claimed', async () => {
203 | const project = 'test-project'
204 |
205 | mockFetchStatus(409, `Project ${project} is already claimed!`)
206 |
207 | expect(ProjectRepository.claim(project)).rejects.toThrow(
208 | `Project ${project} is already claimed!`
209 | )
210 | })
211 |
212 | test('should throw server unreachable on 504 status code', async () => {
213 | const project = 'test-project'
214 |
215 | mockFetchStatus(504)
216 |
217 | expect(ProjectRepository.claim(project)).rejects.toThrow(
218 | 'Failed to claim project: Server unreachable'
219 | )
220 | })
221 | })
222 |
223 | describe('deleteDoc', () => {
224 | test('should call delete api with project name and version', async () => {
225 | const project = 'test-project'
226 | const version = '1.0.0'
227 | const token = 'test-token'
228 |
229 | mockFetchData({})
230 |
231 | await ProjectRepository.deleteDoc(project, version, token)
232 |
233 | expect(global.fetch).toHaveBeenCalledTimes(1)
234 | expect(global.fetch).toHaveBeenCalledWith(`/api/${project}/${version}`, {
235 | method: 'DELETE',
236 | headers: { 'Docat-Api-Key': token }
237 | })
238 | })
239 |
240 | test('should throw invalid token on 401 status code', async () => {
241 | const project = 'test-project'
242 | const version = '1.0.0'
243 | const token = 'test-token'
244 |
245 | mockFetchStatus(401)
246 |
247 | expect(
248 | ProjectRepository.deleteDoc(project, version, token)
249 | ).rejects.toThrow('Failed to delete documentation: Invalid token')
250 | })
251 |
252 | test('should throw server unreachable on 504 status code', async () => {
253 | const project = 'test-project'
254 | const version = '1.0.0'
255 | const token = 'test-token'
256 |
257 | mockFetchStatus(504)
258 |
259 | expect(
260 | ProjectRepository.deleteDoc(project, version, token)
261 | ).rejects.toThrow('Failed to delete documentation: Server unreachable')
262 | })
263 |
264 | test('should throw error on other status code', async () => {
265 | const project = 'test-project'
266 | const version = '1.0.0'
267 | const token = 'test-token'
268 | const error = 'Test Error'
269 |
270 | mockFetchStatus(500, error)
271 |
272 | expect(
273 | ProjectRepository.deleteDoc(project, version, token)
274 | ).rejects.toThrow(`Failed to delete documentation: ${error}`)
275 | })
276 | })
277 |
278 | describe('compare versions', () => {
279 | test('should sort doc versions as semantic versions', async () => {
280 | expect(
281 | ProjectRepository.compareVersions({ name: '0.0.0' }, { name: '0.0.1' })
282 | ).toBeLessThan(0)
283 | expect(
284 | ProjectRepository.compareVersions({ name: 'a' }, { name: 'b' })
285 | ).toBeLessThan(0)
286 | expect(
287 | ProjectRepository.compareVersions(
288 | { name: 'z' },
289 | { name: '', tags: ['latest'] }
290 | )
291 | ).toBeLessThan(0)
292 | expect(
293 | ProjectRepository.compareVersions({ name: '0.0.10' }, { name: '0.1.1' })
294 | ).toBeLessThan(0)
295 | expect(
296 | ProjectRepository.compareVersions({ name: '0.0.1' }, { name: '0.0.22' })
297 | ).toBeLessThan(0)
298 | expect(
299 | ProjectRepository.compareVersions({ name: '0.0.2' }, { name: '0.0.22' })
300 | ).toBeLessThan(0)
301 | expect(
302 | ProjectRepository.compareVersions({ name: '0.0.22' }, { name: '0.0.2' })
303 | ).toBeGreaterThan(0)
304 | expect(
305 | ProjectRepository.compareVersions({ name: '0.0.3' }, { name: '0.0.22' })
306 | ).toBeLessThan(0)
307 | expect(
308 | ProjectRepository.compareVersions({ name: '0.0.2a' }, { name: '0.0.10' })
309 | ).toBeLessThan(0)
310 | expect(
311 | ProjectRepository.compareVersions({ name: '1.2.0' }, { name: '1.0' })
312 | ).toBeGreaterThan(0)
313 | expect(
314 | ProjectRepository.compareVersions({ name: '1.2' }, { name: '2.0.0' })
315 | ).toBeLessThan(0)
316 | })
317 | })
318 |
319 | describe('favories', () => {
320 | test('should add and remove favourite projects correctly', () => {
321 | const project = 'test-project'
322 |
323 | expect(ProjectRepository.isFavorite(project)).toBe(false)
324 |
325 | ProjectRepository.setFavorite(project, false)
326 | expect(ProjectRepository.isFavorite(project)).toBe(false)
327 |
328 | ProjectRepository.setFavorite(project, true)
329 | expect(ProjectRepository.isFavorite(project)).toBe(true)
330 |
331 | ProjectRepository.setFavorite(project, false)
332 | expect(ProjectRepository.isFavorite(project)).toBe(false)
333 | })
334 | })
335 |
336 | describe('filterHiddenVersions', () => {
337 | test('should remove hidden versions', () => {
338 | const shownVersion: ProjectDetails = {
339 | name: 'v-2',
340 | tags: ['stable'],
341 | hidden: false,
342 | timestamp: new Date()
343 | }
344 |
345 | const hiddenVersion: ProjectDetails = {
346 | name: 'v-1',
347 | tags: ['latest'],
348 | hidden: true,
349 | timestamp: new Date()
350 | }
351 |
352 | const allProjects: Project[] = [
353 | {
354 | name: 'test-project-1',
355 | storage: "1 MB",
356 | versions: [shownVersion, hiddenVersion],
357 | logo: false
358 | }
359 | ]
360 |
361 | const shownProjects: Project[] = [
362 | {
363 | name: 'test-project-1',
364 | storage: "1 MB",
365 | versions: [shownVersion],
366 | logo: false
367 | }
368 | ]
369 |
370 | const result = ProjectRepository.filterHiddenVersions(allProjects)
371 | expect(result).toStrictEqual(shownProjects)
372 | })
373 | test('should remove the whole project if no shown versions are present', () => {
374 | const allProjects: Project[] = [
375 | {
376 | name: 'test-project-1',
377 | storage: "1 MB",
378 | versions: [
379 | {
380 | name: 'v-1',
381 | tags: ['latest'],
382 | hidden: true,
383 | timestamp: new Date()
384 | }
385 | ],
386 | logo: true
387 | }
388 | ]
389 |
390 | const result = ProjectRepository.filterHiddenVersions(allProjects)
391 | expect(result).toStrictEqual([])
392 | })
393 | })
394 |
395 | describe('getLatestVersion', () => {
396 | test('should return latest version by name', () => {
397 | const versions: ProjectDetails[] = [
398 | {
399 | name: '1.0.0',
400 | hidden: false,
401 | tags: [],
402 | timestamp: new Date()
403 | },
404 | {
405 | name: '2.0.0',
406 | hidden: false,
407 | tags: [],
408 | timestamp: new Date()
409 | }
410 | ]
411 |
412 | const latestVersion = ProjectRepository.getLatestVersion(versions)
413 | expect(latestVersion).toStrictEqual(versions[1])
414 | })
415 |
416 | test('should return version with latest in name', () => {
417 | const versions: ProjectDetails[] = [
418 | {
419 | name: '1.0.0',
420 | hidden: false,
421 | tags: [],
422 | timestamp: new Date()
423 | },
424 | {
425 | name: 'latest',
426 | hidden: false,
427 | tags: [],
428 | timestamp: new Date()
429 | }
430 | ]
431 |
432 | const latestVersion = ProjectRepository.getLatestVersion(versions)
433 | expect(latestVersion).toStrictEqual(versions[1])
434 | })
435 |
436 | test('should return version with latest tag', () => {
437 | const versions: ProjectDetails[] = [
438 | {
439 | name: '1.0.0',
440 | hidden: false,
441 | tags: ['latest'],
442 | timestamp: new Date()
443 | },
444 | {
445 | name: '2.0.0',
446 | hidden: false,
447 | tags: [],
448 | timestamp: new Date()
449 | }
450 | ]
451 |
452 | const latestVersion = ProjectRepository.getLatestVersion(versions)
453 | expect(latestVersion).toStrictEqual(versions[0])
454 | })
455 |
456 | test('should prefer version with latest in name over latest tag', () => {
457 | const versions: ProjectDetails[] = [
458 | {
459 | name: 'latest',
460 | hidden: false,
461 | tags: [],
462 | timestamp: new Date()
463 | },
464 | {
465 | name: '1.0.0',
466 | hidden: false,
467 | tags: ['latest'],
468 | timestamp: new Date()
469 | }
470 | ]
471 |
472 | const latestVersion = ProjectRepository.getLatestVersion(versions)
473 | expect(latestVersion).toStrictEqual(versions[0])
474 | })
475 | })
476 |
--------------------------------------------------------------------------------
/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": false,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx",
22 | "types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals"]
23 | },
24 | "include": [
25 | "src"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/web/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | interface ImportMetaEnv {
4 | readonly VITE_DOCAT_VERSION: string
5 | }
6 |
7 | interface ImportMeta {
8 | readonly env: ImportMetaEnv
9 | }
10 |
--------------------------------------------------------------------------------
/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, loadEnv } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig(({ mode }) => {
6 | const env = loadEnv(mode, process.cwd())
7 | return {
8 | base: '/',
9 | plugins: [react()],
10 | assetsInclude: ['**/*.md'],
11 | server: {
12 | port: 8080,
13 | proxy: {
14 | '/api': {
15 | target: 'http://localhost:' + `${env.VITE_PROXY_PORT ?? '5000'}`,
16 | changeOrigin: true,
17 | secure: false
18 | },
19 | '/doc': {
20 | target: 'http://localhost:' + `${env.VITE_PROXY_PORT ?? '5000'}`,
21 | changeOrigin: true,
22 | secure: false
23 | }
24 | }
25 | },
26 | test: {
27 | globals: true,
28 | environment: 'jsdom',
29 | css: true,
30 | reporters: ['verbose'],
31 | coverage: {
32 | reporter: ['text', 'json', 'html'],
33 | include: ['src/**/*'],
34 | exclude: []
35 | }
36 | }
37 | }
38 | })
39 |
--------------------------------------------------------------------------------