From 5b1a2b20de5abb79948297b062da8c2429c62aee Mon Sep 17 00:00:00 2001 From: Samuel Paccoud - DINUM Date: Tue, 9 Jan 2024 15:30:36 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(project)=20Django=20boilerplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a boilerplate inspired by https://github.com/numerique-gouv/impress. The code has been cleaned to remove unnecessary Impress logic and dependencies. Changes made: - Removed Minio, WebRTC, and create bucket from the stack. - Removed the Next.js frontend (it will be replaced by Vite). - Cleaned up impress-specific backend logics. The whole stack remains functional: - All tests pass. - Linter checks pass. - Agent Connexion sources are already set-up. Why clear out the code? To adhere to the KISS principle, we aim to maintain a minimalist codebase. Cloning Impress allowed us to quickly inherit its code quality tools and deployment configurations for staging, pre-production, and production environments. What’s broken? - The tsclient is not functional anymore. - Some make commands need to be fixed. - Helm sources are outdated. - Naming across the project sources are inconsistent (impress, visio, etc.) - CI is not configured properly. This list might be incomplete. Let's grind it. --- .dockerignore | 36 + .github/ISSUE_TEMPLATE.md | 6 + .github/ISSUE_TEMPLATE/Bug_report.md | 28 + .github/ISSUE_TEMPLATE/Feature_request.md | 23 + .github/ISSUE_TEMPLATE/Support_question.md | 22 + .github/PULL_REQUEST_TEMPLATE.md | 11 + .github/workflows/deploy.yml | 52 + .github/workflows/docker-hub.yml | 185 ++ .github/workflows/impress-frontend.yml | 171 ++ .github/workflows/impress.yml | 272 ++ .gitignore | 81 + .gitlint | 78 + .gitmodules | 3 + CHANGELOG.md | 52 + Dockerfile | 150 ++ LICENSE | 2 +- Makefile | 323 +++ README.md | 82 + UPGRADE.md | 17 + bin/Tiltfile | 68 + bin/_config.sh | 157 ++ bin/compose | 6 + bin/manage | 6 + bin/pylint | 38 + bin/pytest | 8 + bin/start-kind.sh | 103 + bin/state | 25 + bin/terraform | 26 + bin/update_openapi_schema | 12 + bin/updatekeys.sh | 3 + crowdin/config.yml | 29 + docker-compose.yml | 150 ++ docker/auth/realm.json | 2266 +++++++++++++++++ docker/files/etc/nginx/conf.d/default.conf | 13 + docker/files/usr/local/bin/entrypoint | 35 + .../files/usr/local/etc/gunicorn/impress.py | 16 + docs/tsclient.md | 25 + env.d/development/common.dist | 41 + env.d/development/common.e2e.dist | 3 + env.d/development/crowdin.dist | 3 + env.d/development/kc_postgresql.dist | 11 + env.d/development/postgresql.dist | 11 + gitlint/gitlint_emoji.py | 37 + renovate.json | 26 + scripts/install-hooks.sh | 30 + scripts/update-git-submodule.sh | 4 + scripts/updatekeys.sh | 3 + secrets | 1 + src/backend/.pylintrc | 472 ++++ src/backend/MANIFEST.in | 3 + src/backend/__init__.py | 0 src/backend/core/__init__.py | 0 src/backend/core/admin.py | 64 + src/backend/core/api/__init__.py | 39 + src/backend/core/api/permissions.py | 36 + src/backend/core/api/serializers.py | 13 + src/backend/core/api/viewsets.py | 142 ++ src/backend/core/apps.py | 11 + src/backend/core/authentication/__init__.py | 0 src/backend/core/authentication/backends.py | 100 + src/backend/core/authentication/urls.py | 18 + src/backend/core/authentication/views.py | 137 + src/backend/core/enums.py | 15 + src/backend/core/factories.py | 25 + src/backend/core/migrations/0001_initial.py | 166 ++ .../0002_create_pg_trgm_extension.py | 14 + src/backend/core/migrations/__init__.py | 0 src/backend/core/models.py | 143 ++ .../static/images/logo-suite-numerique.png | Bin 0 -> 13584 bytes src/backend/core/static/images/logo.png | Bin 0 -> 1269 bytes .../static/images/mail-header-background.png | Bin 0 -> 41074 bytes .../templates/core/generate_document.html | 14 + src/backend/core/templatetags/__init__.py | 0 src/backend/core/templatetags/extra_tags.py | 58 + src/backend/core/tests/__init__.py | 0 .../core/tests/authentication/__init__.py | 0 .../tests/authentication/test_backends.py | 101 + .../core/tests/authentication/test_urls.py | 10 + .../core/tests/authentication/test_views.py | 231 ++ src/backend/core/tests/conftest.py | 15 + src/backend/core/tests/swagger/__init__.py | 0 .../core/tests/swagger/test_openapi_schema.py | 41 + src/backend/core/tests/test_api_users.py | 417 +++ src/backend/core/tests/test_models_users.py | 45 + src/backend/core/urls.py | 24 + src/backend/demo/__init__.py | 0 src/backend/demo/data/template/code.txt | 10 + src/backend/demo/data/template/css.txt | 18 + src/backend/demo/defaults.py | 5 + src/backend/demo/management/__init__.py | 0 .../demo/management/commands/__init__.py | 0 .../demo/management/commands/create_demo.py | 154 ++ .../management/commands/createsuperuser.py | 46 + src/backend/demo/tests/__init__.py | 0 .../demo/tests/test_commands_create_demo.py | 18 + src/backend/impress/__init__.py | 0 src/backend/impress/celery_app.py | 22 + src/backend/impress/settings.py | 590 +++++ src/backend/impress/urls.py | 48 + src/backend/impress/wsgi.py | 17 + .../locale/en_US/LC_MESSAGES/django.po | 208 ++ .../locale/fr_FR/LC_MESSAGES/django.po | 208 ++ src/backend/manage.py | 14 + src/backend/pyproject.toml | 142 ++ src/backend/setup.py | 7 + src/helm/env.d/dev/secrets.enc.yaml | 60 + src/helm/env.d/dev/values.impress.yaml.gotmpl | 105 + src/helm/env.d/preprod/secrets.enc.yaml | 1 + .../env.d/preprod/values.impress.yaml.gotmpl | 147 ++ src/helm/env.d/production/secrets.enc.yaml | 1 + .../production/values.impress.yaml.gotmpl | 147 ++ src/helm/env.d/staging/secrets.enc.yaml | 1 + .../env.d/staging/values.impress.yaml.gotmpl | 147 ++ src/helm/extra/Chart.yaml | 5 + src/helm/extra/templates/keydb.yaml | 7 + src/helm/extra/templates/postgresql.yaml | 7 + src/helm/extra/templates/s3.yaml | 8 + src/helm/extra/templates/secrets.yaml | 10 + src/helm/helmfile.yaml | 82 + src/helm/impress/Chart.yaml | 4 + src/helm/impress/README.md | 128 + src/helm/impress/generate-readme.sh | 10 + src/helm/impress/templates/_helpers.tpl | 184 ++ .../impress/templates/backend_deployment.yaml | 136 + src/helm/impress/templates/backend_job.yaml | 121 + .../backend_job_createsuperuser.yaml | 121 + src/helm/impress/templates/backend_svc.yaml | 21 + .../templates/frontend_deployment.yaml | 136 + src/helm/impress/templates/frontend_svc.yaml | 21 + src/helm/impress/templates/ingress.yaml | 118 + src/helm/impress/templates/ingress_admin.yaml | 98 + src/helm/impress/templates/ingress_ws.yaml | 72 + .../impress/templates/webrtc_deployment.yaml | 136 + src/helm/impress/templates/webrtc_svc.yaml | 21 + src/helm/impress/values.yaml | 385 +++ src/mail/bin/html-to-plain-text | 22 + src/mail/bin/mjml-to-html | 9 + src/mail/html-to-text.config.json | 11 + src/mail/mjml/invitation.mjml | 54 + src/mail/mjml/partial/footer.mjml | 9 + src/mail/mjml/partial/header.mjml | 46 + src/mail/package.json | 22 + src/mail/yarn.lock | 1292 ++++++++++ src/tsclient/package.json | 25 + .../generate_api_client_local.sh | 8 + src/tsclient/yarn.lock | 120 + 146 files changed, 12668 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/ISSUE_TEMPLATE/Bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/Feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/Support_question.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/docker-hub.yml create mode 100644 .github/workflows/impress-frontend.yml create mode 100644 .github/workflows/impress.yml create mode 100644 .gitignore create mode 100644 .gitlint create mode 100644 .gitmodules create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 UPGRADE.md create mode 100644 bin/Tiltfile create mode 100644 bin/_config.sh create mode 100755 bin/compose create mode 100755 bin/manage create mode 100755 bin/pylint create mode 100755 bin/pytest create mode 100644 bin/start-kind.sh create mode 100755 bin/state create mode 100755 bin/terraform create mode 100755 bin/update_openapi_schema create mode 100644 bin/updatekeys.sh create mode 100644 crowdin/config.yml create mode 100644 docker-compose.yml create mode 100644 docker/auth/realm.json create mode 100644 docker/files/etc/nginx/conf.d/default.conf create mode 100755 docker/files/usr/local/bin/entrypoint create mode 100644 docker/files/usr/local/etc/gunicorn/impress.py create mode 100644 docs/tsclient.md create mode 100644 env.d/development/common.dist create mode 100644 env.d/development/common.e2e.dist create mode 100644 env.d/development/crowdin.dist create mode 100644 env.d/development/kc_postgresql.dist create mode 100644 env.d/development/postgresql.dist create mode 100644 gitlint/gitlint_emoji.py create mode 100644 renovate.json create mode 100755 scripts/install-hooks.sh create mode 100755 scripts/update-git-submodule.sh create mode 100755 scripts/updatekeys.sh create mode 160000 secrets create mode 100644 src/backend/.pylintrc create mode 100644 src/backend/MANIFEST.in create mode 100644 src/backend/__init__.py create mode 100644 src/backend/core/__init__.py create mode 100644 src/backend/core/admin.py create mode 100644 src/backend/core/api/__init__.py create mode 100644 src/backend/core/api/permissions.py create mode 100644 src/backend/core/api/serializers.py create mode 100644 src/backend/core/api/viewsets.py create mode 100644 src/backend/core/apps.py create mode 100644 src/backend/core/authentication/__init__.py create mode 100644 src/backend/core/authentication/backends.py create mode 100644 src/backend/core/authentication/urls.py create mode 100644 src/backend/core/authentication/views.py create mode 100644 src/backend/core/enums.py create mode 100644 src/backend/core/factories.py create mode 100644 src/backend/core/migrations/0001_initial.py create mode 100644 src/backend/core/migrations/0002_create_pg_trgm_extension.py create mode 100644 src/backend/core/migrations/__init__.py create mode 100644 src/backend/core/models.py create mode 100644 src/backend/core/static/images/logo-suite-numerique.png create mode 100644 src/backend/core/static/images/logo.png create mode 100644 src/backend/core/static/images/mail-header-background.png create mode 100644 src/backend/core/templates/core/generate_document.html create mode 100644 src/backend/core/templatetags/__init__.py create mode 100644 src/backend/core/templatetags/extra_tags.py create mode 100644 src/backend/core/tests/__init__.py create mode 100644 src/backend/core/tests/authentication/__init__.py create mode 100644 src/backend/core/tests/authentication/test_backends.py create mode 100644 src/backend/core/tests/authentication/test_urls.py create mode 100644 src/backend/core/tests/authentication/test_views.py create mode 100644 src/backend/core/tests/conftest.py create mode 100644 src/backend/core/tests/swagger/__init__.py create mode 100644 src/backend/core/tests/swagger/test_openapi_schema.py create mode 100644 src/backend/core/tests/test_api_users.py create mode 100644 src/backend/core/tests/test_models_users.py create mode 100644 src/backend/core/urls.py create mode 100644 src/backend/demo/__init__.py create mode 100644 src/backend/demo/data/template/code.txt create mode 100644 src/backend/demo/data/template/css.txt create mode 100644 src/backend/demo/defaults.py create mode 100644 src/backend/demo/management/__init__.py create mode 100644 src/backend/demo/management/commands/__init__.py create mode 100644 src/backend/demo/management/commands/create_demo.py create mode 100644 src/backend/demo/management/commands/createsuperuser.py create mode 100644 src/backend/demo/tests/__init__.py create mode 100644 src/backend/demo/tests/test_commands_create_demo.py create mode 100644 src/backend/impress/__init__.py create mode 100644 src/backend/impress/celery_app.py create mode 100755 src/backend/impress/settings.py create mode 100644 src/backend/impress/urls.py create mode 100644 src/backend/impress/wsgi.py create mode 100644 src/backend/locale/en_US/LC_MESSAGES/django.po create mode 100644 src/backend/locale/fr_FR/LC_MESSAGES/django.po create mode 100644 src/backend/manage.py create mode 100644 src/backend/pyproject.toml create mode 100644 src/backend/setup.py create mode 100644 src/helm/env.d/dev/secrets.enc.yaml create mode 100644 src/helm/env.d/dev/values.impress.yaml.gotmpl create mode 120000 src/helm/env.d/preprod/secrets.enc.yaml create mode 100644 src/helm/env.d/preprod/values.impress.yaml.gotmpl create mode 120000 src/helm/env.d/production/secrets.enc.yaml create mode 100644 src/helm/env.d/production/values.impress.yaml.gotmpl create mode 120000 src/helm/env.d/staging/secrets.enc.yaml create mode 100644 src/helm/env.d/staging/values.impress.yaml.gotmpl create mode 100644 src/helm/extra/Chart.yaml create mode 100644 src/helm/extra/templates/keydb.yaml create mode 100644 src/helm/extra/templates/postgresql.yaml create mode 100644 src/helm/extra/templates/s3.yaml create mode 100644 src/helm/extra/templates/secrets.yaml create mode 100644 src/helm/helmfile.yaml create mode 100644 src/helm/impress/Chart.yaml create mode 100644 src/helm/impress/README.md create mode 100644 src/helm/impress/generate-readme.sh create mode 100644 src/helm/impress/templates/_helpers.tpl create mode 100644 src/helm/impress/templates/backend_deployment.yaml create mode 100644 src/helm/impress/templates/backend_job.yaml create mode 100644 src/helm/impress/templates/backend_job_createsuperuser.yaml create mode 100644 src/helm/impress/templates/backend_svc.yaml create mode 100644 src/helm/impress/templates/frontend_deployment.yaml create mode 100644 src/helm/impress/templates/frontend_svc.yaml create mode 100644 src/helm/impress/templates/ingress.yaml create mode 100644 src/helm/impress/templates/ingress_admin.yaml create mode 100644 src/helm/impress/templates/ingress_ws.yaml create mode 100644 src/helm/impress/templates/webrtc_deployment.yaml create mode 100644 src/helm/impress/templates/webrtc_svc.yaml create mode 100644 src/helm/impress/values.yaml create mode 100755 src/mail/bin/html-to-plain-text create mode 100755 src/mail/bin/mjml-to-html create mode 100644 src/mail/html-to-text.config.json create mode 100644 src/mail/mjml/invitation.mjml create mode 100644 src/mail/mjml/partial/footer.mjml create mode 100644 src/mail/mjml/partial/header.mjml create mode 100644 src/mail/package.json create mode 100644 src/mail/yarn.lock create mode 100644 src/tsclient/package.json create mode 100755 src/tsclient/scripts/openapi-typescript-codegen/generate_api_client_local.sh create mode 100644 src/tsclient/yarn.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..fe9c3334 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Python +__pycache__ +*.pyc +**/__pycache__ +**/*.pyc +venv +.venv + +# System-specific files +.DS_Store +**/.DS_Store + +# Docker +docker compose.* +env.d + +# Docs +docs +*.md +*.log + +# Development/test cache & configurations +data +.cache +.circleci +.git +.vscode +.iml +.idea +db.sqlite3 +.mypy_cache +.pylint.d +.pytest_cache + +# Frontend +node_modules diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..24e79d04 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,6 @@ + diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md new file mode 100644 index 00000000..b324d37c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -0,0 +1,28 @@ +--- +name: 🐛 Bug Report +about: If something is not working as expected 🤔. + +--- + +## Bug Report + +**Problematic behavior** +A clear and concise description of the behavior. + +**Expected behavior/code** +A clear and concise description of what you expected to happen (or code). + +**Steps to Reproduce** +1. Do this... +2. Then this... +3. And then the bug happens! + +**Environment** +- Impress version: +- Platform: + +**Possible Solution** + + +**Additional context/Screenshots** +Add any other context about the problem here. If applicable, add screenshots to help explain. diff --git a/.github/ISSUE_TEMPLATE/Feature_request.md b/.github/ISSUE_TEMPLATE/Feature_request.md new file mode 100644 index 00000000..51d31708 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Feature_request.md @@ -0,0 +1,23 @@ +--- +name: ✨ Feature Request +about: I have a suggestion (and may want to build it 💪)! + +--- + +## Feature Request + +**Is your feature request related to a problem or unsupported use case? Please describe.** +A clear and concise description of what the problem is. For example: I need to do some task and I have an issue... + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. Add any considered drawbacks. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Discovery, Documentation, Adoption, Migration Strategy** +If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable). +Maybe a screenshot or design? + +**Do you want to work on it through a Pull Request?** + diff --git a/.github/ISSUE_TEMPLATE/Support_question.md b/.github/ISSUE_TEMPLATE/Support_question.md new file mode 100644 index 00000000..14bb7caf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/Support_question.md @@ -0,0 +1,22 @@ +--- +name: 🤗 Support Question +about: If you have a question 💬, or something was not clear from the docs! + +--- + + + +--- + +Please make sure you have read our [main Readme](https://github.com/numerique-gouv/impress). + +Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/impress/issues). + +If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌 + +**Topic** +What's the general area of your question: for example, docker setup, database schema, search functionality,... + +**Question** +Try to be as specific as possible so we can help you as best we can. Please be patient 🙏 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..85cfbe6e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,11 @@ +## Purpose + +Description... + + +## Proposal + +Description... + +- [] item 1... +- [] item 2... diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..287db564 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,52 @@ +name: Deploy + +on: + push: + tags: + - 'preprod' + - 'production' + + +jobs: + notify-argocd: + runs-on: ubuntu-latest + steps: + - + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "impress,secrets" + - + name: Checkout repository + uses: actions/checkout@v2 + with: + submodules: recursive + token: ${{ steps.app-token.outputs.token }} + - + name: Load sops secrets + uses: rouja/actions-sops@main + with: + secret-file: secrets/numerique-gouv/impress/secrets.enc.env + age-key: ${{ secrets.SOPS_PRIVATE }} + - + name: Call argocd github webhook + run: | + data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}' + sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}') + curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL + sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}') + curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL + + start-test-on-preprod: + needs: + - notify-argocd + runs-on: ubuntu-latest + if: startsWith(github.event.ref, 'refs/tags/preprod') + steps: + - + name: Debug + run: | + echo "Start test when preprod is ready" diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml new file mode 100644 index 00000000..32f6ee9c --- /dev/null +++ b/.github/workflows/docker-hub.yml @@ -0,0 +1,185 @@ +name: Docker Hub Workflow + +on: + workflow_dispatch: + push: + branches: + - 'main' + tags: + - 'v*' + pull_request: + branches: + - 'main' + +env: + DOCKER_USER: 1001:127 + +jobs: + build-and-push-backend: + runs-on: ubuntu-latest + steps: + - + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "impress,secrets" + - + name: Checkout repository + uses: actions/checkout@v2 + with: + submodules: recursive + token: ${{ steps.app-token.outputs.token }} + - + name: Load sops secrets + uses: rouja/actions-sops@main + with: + secret-file: secrets/numerique-gouv/impress/secrets.enc.env + age-key: ${{ secrets.SOPS_PRIVATE }} + - + name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: lasuite/impress-backend + - + name: Login to DockerHub + if: github.event_name != 'pull_request' + run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin + - + name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + target: backend-production + build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + build-and-push-frontend: + runs-on: ubuntu-latest + steps: + - + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "impress,secrets" + - + name: Checkout repository + uses: actions/checkout@v2 + with: + submodules: recursive + token: ${{ steps.app-token.outputs.token }} + - + name: Load sops secrets + uses: rouja/actions-sops@main + with: + secret-file: secrets/numerique-gouv/impress/secrets.enc.env + age-key: ${{ secrets.SOPS_PRIVATE }} + - + name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: lasuite/impress-frontend + - + name: Login to DockerHub + if: github.event_name != 'pull_request' + run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin + - + name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./src/frontend/Dockerfile + target: frontend-production + build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + build-and-push-y-webrtc-signaling: + runs-on: ubuntu-latest + steps: + - + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "impress,secrets" + - + name: Checkout repository + uses: actions/checkout@v2 + with: + submodules: recursive + token: ${{ steps.app-token.outputs.token }} + - + name: Load sops secrets + uses: rouja/actions-sops@main + with: + secret-file: secrets/numerique-gouv/impress/secrets.enc.env + age-key: ${{ secrets.SOPS_PRIVATE }} + - + name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: lasuite/impress-y-webrtc-signaling + - + name: Login to DockerHub + if: github.event_name != 'pull_request' + run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin + - + name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./src/frontend/Dockerfile + target: y-webrtc-signaling + build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + notify-argocd: + needs: + - build-and-push-frontend + - build-and-push-backend + runs-on: ubuntu-latest + if: | + github.event_name != 'pull_request' + steps: + - + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "impress,secrets" + - + name: Checkout repository + uses: actions/checkout@v2 + with: + submodules: recursive + token: ${{ steps.app-token.outputs.token }} + - + name: Load sops secrets + uses: rouja/actions-sops@main + with: + secret-file: secrets/numerique-gouv/impress/secrets.enc.env + age-key: ${{ secrets.SOPS_PRIVATE }} + - + name: Call argocd github webhook + run: | + data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}' + sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}') + curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL diff --git a/.github/workflows/impress-frontend.yml b/.github/workflows/impress-frontend.yml new file mode 100644 index 00000000..e3cea1b4 --- /dev/null +++ b/.github/workflows/impress-frontend.yml @@ -0,0 +1,171 @@ +name: impress Workflow + +on: + push: + branches: + - main + pull_request: + branches: + - "*" + +jobs: + install-front: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18.x" + + - name: Restore the frontend cache + uses: actions/cache@v4 + id: front-node_modules + with: + path: "src/frontend/**/node_modules" + key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} + + - name: Install dependencies + if: steps.front-node_modules.outputs.cache-hit != 'true' + run: cd src/frontend/ && yarn install --frozen-lockfile + + - name: Cache install frontend + if: steps.front-node_modules.outputs.cache-hit != 'true' + uses: actions/cache@v4 + with: + path: "src/frontend/**/node_modules" + key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} + + build-front: + runs-on: ubuntu-latest + needs: install-front + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Restore the frontend cache + uses: actions/cache@v4 + id: front-node_modules + with: + path: "src/frontend/**/node_modules" + key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} + + - name: Build CI App + run: cd src/frontend/ && yarn ci:build + + - name: Cache build frontend + uses: actions/cache@v4 + with: + path: src/frontend/apps/impress/out/ + key: build-front-${{ github.run_id }} + + test-front: + runs-on: ubuntu-latest + needs: install-front + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Restore the frontend cache + uses: actions/cache@v4 + id: front-node_modules + with: + path: "src/frontend/**/node_modules" + key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} + + - name: Test App + run: cd src/frontend/ && yarn app:test + + lint-front: + runs-on: ubuntu-latest + needs: install-front + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Restore the frontend cache + uses: actions/cache@v4 + id: front-node_modules + with: + path: "src/frontend/**/node_modules" + key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} + + - name: Check linting + run: cd src/frontend/ && yarn lint + + test-e2e: + runs-on: ubuntu-latest + needs: build-front + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set services env variables + run: | + make data/media + make create-env-files + cat env.d/development/common.e2e.dist >> env.d/development/common + + - name: Restore the mail templates + uses: actions/cache@v4 + id: mail-templates + with: + path: "src/backend/core/templates/mail" + key: mail-templates-${{ hashFiles('src/mail/mjml') }} + + - name: Restore the frontend cache + uses: actions/cache@v4 + id: front-node_modules + with: + path: "src/frontend/**/node_modules" + key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }} + + - name: Restore the build cache + uses: actions/cache@v4 + id: cache-build + with: + path: src/frontend/apps/impress/out/ + key: build-front-${{ github.run_id }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build the Docker images + uses: docker/bake-action@v4 + with: + targets: | + app-dev + y-webrtc-signaling + load: true + set: | + *.cache-from=type=gha,scope=cached-stage + *.cache-to=type=gha,scope=cached-stage,mode=max + + - name: Start Docker services + run: | + make run + + - name: Apply DRF migrations + run: | + make migrate + + - name: Add dummy data + run: | + make demo FLUSH_ARGS='--no-input' + + - name: Install Playwright Browsers + run: cd src/frontend/apps/e2e && yarn install + + - name: Run e2e tests + run: cd src/frontend/ && yarn e2e:test + + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: src/frontend/apps/e2e/report/ + retention-days: 7 diff --git a/.github/workflows/impress.yml b/.github/workflows/impress.yml new file mode 100644 index 00000000..ad180cc5 --- /dev/null +++ b/.github/workflows/impress.yml @@ -0,0 +1,272 @@ +name: impress Workflow + +on: + push: + branches: + - main + pull_request: + branches: + - "*" + +jobs: + lint-git: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' # Makes sense only for pull requests + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: show + run: git log + - name: Enforce absence of print statements in code + run: | + ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print(" + - name: Check absence of fixup commits + run: | + ! git log | grep 'fixup!' + - name: Install gitlint + run: pip install --user requests gitlint + - name: Lint commit messages added to main + run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD + + check-changelog: + runs-on: ubuntu-latest + if: | + contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false && + github.event_name == 'pull_request' + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 50 + - name: Check that the CHANGELOG has been modified in the current branch + run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md' + + lint-changelog: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Check CHANGELOG max line length + run: | + max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L) + if [ $max_line_length -ge 80 ]; then + echo "ERROR: CHANGELOG has lines longer than 80 characters." + exit 1 + fi + + build-mails: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/mail + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Restore the mail templates + uses: actions/cache@v4 + id: mail-templates + with: + path: "src/backend/core/templates/mail" + key: mail-templates-${{ hashFiles('src/mail/mjml') }} + + - name: Install yarn + if: steps.mail-templates.outputs.cache-hit != 'true' + run: npm install -g yarn + + - name: Install node dependencies + if: steps.mail-templates.outputs.cache-hit != 'true' + run: yarn install --frozen-lockfile + + - name: Build mails + if: steps.mail-templates.outputs.cache-hit != 'true' + run: yarn build + + - name: Cache mail templates + if: steps.mail-templates.outputs.cache-hit != 'true' + uses: actions/cache@v4 + with: + path: "src/backend/core/templates/mail" + key: mail-templates-${{ hashFiles('src/mail/mjml') }} + + lint-back: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/backend + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Install Python + uses: actions/setup-python@v3 + with: + python-version: "3.10" + - name: Install development dependencies + run: pip install --user .[dev] + - name: Check code formatting with ruff + run: ~/.local/bin/ruff format . --diff + - name: Lint code with ruff + run: ~/.local/bin/ruff check . + - name: Lint code with pylint + run: ~/.local/bin/pylint . + + test-back: + runs-on: ubuntu-latest + needs: build-mails + + defaults: + run: + working-directory: src/backend + + services: + postgres: + image: postgres:16 + env: + POSTGRES_DB: impress + POSTGRES_USER: dinum + POSTGRES_PASSWORD: pass + ports: + - 5432:5432 + # needed because the postgres container does not provide a healthcheck + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + + env: + DJANGO_CONFIGURATION: Test + DJANGO_SETTINGS_MODULE: impress.settings + DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly + OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only + DB_HOST: localhost + DB_NAME: impress + DB_USER: dinum + DB_PASSWORD: pass + DB_PORT: 5432 + STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + AWS_S3_ENDPOINT_URL: http://localhost:9000 + AWS_S3_ACCESS_KEY_ID: impress + AWS_S3_SECRET_ACCESS_KEY: password + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Create writable /data + run: | + sudo mkdir -p /data/media && \ + sudo mkdir -p /data/static + + - name: Restore the mail templates + uses: actions/cache@v4 + id: mail-templates + with: + path: "src/backend/core/templates/mail" + key: mail-templates-${{ hashFiles('src/mail/mjml') }} + + - name: Start Minio + run: | + docker pull minio/minio + docker run -d --name minio \ + -p 9000:9000 \ + -e "MINIO_ACCESS_KEY=impress" \ + -e "MINIO_SECRET_KEY=password" \ + -v /data/media:/data \ + minio/minio server --console-address :9001 /data + + - name: Configure MinIO + run: | + MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/') + docker exec ${MINIO} sh -c \ + "mc alias set impress http://localhost:9000 impress password && \ + mc alias ls && \ + mc mb impress/impress-media-storage && \ + mc version enable impress/impress-media-storage" + + - name: Install Python + uses: actions/setup-python@v3 + with: + python-version: "3.10" + + - name: Install development dependencies + run: pip install --user .[dev] + + - name: Install gettext (required to compile messages) + run: | + sudo apt-get update + sudo apt-get install -y gettext + + - name: Generate a MO file from strings extracted from the project + run: python manage.py compilemessages + + - name: Run tests + run: ~/.local/bin/pytest -n 2 + + i18n-crowdin: + runs-on: ubuntu-latest + steps: + - + uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: "infrastructure,secrets" + - + name: Checkout repository + uses: actions/checkout@v2 + with: + submodules: recursive + token: ${{ steps.app-token.outputs.token }} + - + name: Load sops secrets + uses: rouja/actions-sops@main + with: + secret-file: secrets/numerique-gouv/impress/secrets.enc.env + age-key: ${{ secrets.SOPS_PRIVATE }} + + - name: Install gettext (required to make messages) + run: | + sudo apt-get update + sudo apt-get install -y gettext + + - name: Install Python + uses: actions/setup-python@v3 + with: + python-version: "3.10" + + - name: Install development dependencies + working-directory: src/backend + run: pip install --user .[dev] + + - name: Generate the translation base file + run: ~/.local/bin/django-admin makemessages --keep-pot --all + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "18.x" + cache: "yarn" + cache-dependency-path: src/frontend/yarn.lock + + - name: Install dependencies + run: cd src/frontend/ && yarn install --frozen-lockfile + + - name: Extract the frontend translation + run: make frontend-i18n-extract + + - name: Upload files to Crowdin + run: | + docker run \ + --rm \ + -e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \ + -e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \ + -e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \ + -v "${{ github.workspace }}:/app" \ + crowdin/cli:3.16.0 \ + crowdin upload sources -c /app/crowdin/config.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..24643946 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.DS_Store +.next/ + +# Translations # Translations +*.pot + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +env.d/development/* +!env.d/development/*.dist +env.d/terraform + +# npm +node_modules + +# Mails +src/backend/core/templates/mail/ + +# Typescript client +src/frontend/tsclient + +# Swagger +**/swagger.json + +# Logs +*.log + +# Terraform +.terraform +*.tfstate +*.tfstate.backup + +# Test & lint +.coverage +.pylint.d +.pytest_cache +db.sqlite3 +.mypy_cache + +# Site media +/data/ + +# IDEs +.idea/ +.vscode/ +*.iml +.devcontainer diff --git a/.gitlint b/.gitlint new file mode 100644 index 00000000..f7373b6a --- /dev/null +++ b/.gitlint @@ -0,0 +1,78 @@ +# All these sections are optional, edit this file as you like. +[general] +# Ignore certain rules, you can reference them by their id or by their full name +# ignore=title-trailing-punctuation, T3 + +# verbosity should be a value between 1 and 3, the commandline -v flags take precedence over this +# verbosity = 2 + +# By default gitlint will ignore merge commits. Set to 'false' to disable. +# ignore-merge-commits=true + +# By default gitlint will ignore fixup commits. Set to 'false' to disable. +# ignore-fixup-commits=true + +# By default gitlint will ignore squash commits. Set to 'false' to disable. +# ignore-squash-commits=true + +# Enable debug mode (prints more output). Disabled by default. +# debug=true + +# Set the extra-path where gitlint will search for user defined rules +# See http://jorisroovers.github.io/gitlint/user_defined_rules for details +extra-path=gitlint/ + +# [title-max-length] +# line-length=80 + +[title-must-not-contain-word] +# Comma-separated list of words that should not occur in the title. Matching is case +# insensitive. It's fine if the keyword occurs as part of a larger word (so "WIPING" +# will not cause a violation, but "WIP: my title" will. +words=wip + +#[title-match-regex] +# python like regex (https://docs.python.org/2/library/re.html) that the +# commit-msg title must be matched to. +# Note that the regex can contradict with other rules if not used correctly +# (e.g. title-must-not-contain-word). +#regex= + +# [B1] +# B1 = body-max-line-length +# line-length=120 +# [body-min-length] +# min-length=5 + +# [body-is-missing] +# Whether to ignore this rule on merge commits (which typically only have a title) +# default = True +# ignore-merge-commits=false + +# [body-changed-file-mention] +# List of files that need to be explicitly mentioned in the body when they are changed +# This is useful for when developers often erroneously edit certain files or git submodules. +# By specifying this rule, developers can only change the file when they explicitly reference +# it in the commit message. +# files=gitlint/rules.py,README.md + +# [author-valid-email] +# python like regex (https://docs.python.org/2/library/re.html) that the +# commit author email address should be matched to +# For example, use the following regex if you only want to allow email addresses from foo.com +# regex=[^@]+@foo.com + +[ignore-by-title] +# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper) +# upgrade dependencies +regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$ +ignore=B6,UC1 + +# [ignore-by-body] +# Ignore certain rules for commits of which the body has a line that matches a regex +# E.g. Match bodies that have a line that that contain "release" +# regex=(.*)release(.*) +# +# Ignore certain rules, you can reference them by their id or by their full name +# Use 'all' to ignore all rules +# ignore=T1,body-min-length diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..0846cc27 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "secrets"] + path = secrets + url = ../secrets diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f8f9aa39 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0), +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## Added + +- Manage the document's right (#75) +- Update document (#68) +- Remove document (#68) +- (docker) dockerize dev frontend (#63) +- (backend) list users with email filtering (#79) +- (frontend) add user to a document (#52) +- (frontend) invite user to a document (#52) +- (frontend) manage members (update role / list / remove) (#81) +- ✨(frontend) offline mode (#88) + +## Changed + +- Change most of the occurences from pad to document (#76) +- Change site from Impress to Docs (#76) +- Generate PDF from a modal (#68) +- 🔧 (helm) sticky session by request_uri for signaling server (#78) +- (frontend) change logo (#84) +- (frontend) pdf has title doc (#84) +- ⚡️(e2e) unique login between tests (#80) +- ⚡️(CI) improve e2e job (#86) +- ♻️(frontend) improve the error and message info ui (#93) + +## Fixed + +- Fix the break line when generate PDF (#84) + +## Delete + +- Remove trigger workflow on push tags on CI (#68) + +## [0.1.0] - 2024-05-24 + +## Added + +- Coming Soon page (#67) +- Impress, project to manage your documents easily and collaboratively. + + +[unreleased]: https://github.com/numerique-gouv/impress/compare/v0.1.0...main +[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..fce18ac7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,150 @@ +# Django impress + +# ---- base image to inherit from ---- +FROM python:3.10-slim-bullseye as base + +# Upgrade pip to its latest release to speed up dependencies installation +RUN python -m pip install --upgrade pip + +# Upgrade system packages to install security updates +RUN apt-get update && \ + apt-get -y upgrade && \ + rm -rf /var/lib/apt/lists/* + +# ---- Back-end builder image ---- +FROM base as back-builder + +WORKDIR /builder + +# Copy required python dependencies +COPY ./src/backend /builder + +RUN mkdir /install && \ + pip install --prefix=/install . + + +# ---- mails ---- +FROM node:20 as mail-builder + +COPY ./src/mail /mail/app + +WORKDIR /mail/app + +RUN yarn install --frozen-lockfile && \ + yarn build + + +# ---- static link collector ---- +FROM base as link-collector +ARG IMPRESS_STATIC_ROOT=/data/static + +# Install libpangocairo & rdfind +RUN apt-get update && \ + apt-get install -y \ + libpangocairo-1.0-0 \ + rdfind && \ + rm -rf /var/lib/apt/lists/* + +# Copy installed python dependencies +COPY --from=back-builder /install /usr/local + +# Copy impress application (see .dockerignore) +COPY ./src/backend /app/ + +WORKDIR /app + +# collectstatic +RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \ + python manage.py collectstatic --noinput + +# Replace duplicated file by a symlink to decrease the overall size of the +# final image +RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${IMPRESS_STATIC_ROOT} + +# ---- Core application image ---- +FROM base as core + +ENV PYTHONUNBUFFERED=1 + +# Install required system libs +RUN apt-get update && \ + apt-get install -y \ + gettext \ + libcairo2 \ + libffi-dev \ + libgdk-pixbuf2.0-0 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + shared-mime-info && \ + rm -rf /var/lib/apt/lists/* + +# Copy entrypoint +COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint + +# Give the "root" group the same permissions as the "root" user on /etc/passwd +# to allow a user belonging to the root group to add new users; typically the +# docker user (see entrypoint). +RUN chmod g=u /etc/passwd + +# Copy installed python dependencies +COPY --from=back-builder /install /usr/local + +# Copy impress application (see .dockerignore) +COPY ./src/backend /app/ + +WORKDIR /app + +# We wrap commands run in this container by the following entrypoint that +# creates a user on-the-fly with the container user ID (see USER) and root group +# ID. +ENTRYPOINT [ "/usr/local/bin/entrypoint" ] + +# ---- Development image ---- +FROM core as backend-development + +# Switch back to the root user to install development dependencies +USER root:root + +# Install psql +RUN apt-get update && \ + apt-get install -y postgresql-client && \ + rm -rf /var/lib/apt/lists/* + +# Uninstall impress and re-install it in editable mode along with development +# dependencies +RUN pip uninstall -y impress +RUN pip install -e .[dev] + +# Restore the un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +# Target database host (e.g. database engine following docker compose services +# name) & port +ENV DB_HOST=postgresql \ + DB_PORT=5432 + +# Run django development server +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] + +# ---- Production image ---- +FROM core as backend-production + +ARG IMPRESS_STATIC_ROOT=/data/static + +# Gunicorn +RUN mkdir -p /usr/local/etc/gunicorn +COPY docker/files/usr/local/etc/gunicorn/impress.py /usr/local/etc/gunicorn/impress.py + +# Un-privileged user running the application +ARG DOCKER_USER +USER ${DOCKER_USER} + +# Copy statics +COPY --from=link-collector ${IMPRESS_STATIC_ROOT} ${IMPRESS_STATIC_ROOT} + +# Copy impress mails +COPY --from=mail-builder /mail/backend/core/templates/mail /app/core/templates/mail + +# The default command runs gunicorn WSGI server in impress's main module +CMD ["gunicorn", "-c", "/usr/local/etc/gunicorn/impress.py", "impress.wsgi:application"] diff --git a/LICENSE b/LICENSE index 0f8109d4..d46fd062 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Direction Interministérielle du Numérique du Gouvernement Français +Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..01024461 --- /dev/null +++ b/Makefile @@ -0,0 +1,323 @@ +# /!\ /!\ /!\ /!\ /!\ /!\ /!\ DISCLAIMER /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ +# +# This Makefile is only meant to be used for DEVELOPMENT purpose as we are +# changing the user id that will run in the container. +# +# PLEASE DO NOT USE IT FOR YOUR CI/PRODUCTION/WHATEVER... +# +# /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ /!\ +# +# Note to developers: +# +# While editing this file, please respect the following statements: +# +# 1. Every variable should be defined in the ad hoc VARIABLES section with a +# relevant subsection +# 2. Every new rule should be defined in the ad hoc RULES section with a +# relevant subsection depending on the targeted service +# 3. Rules should be sorted alphabetically within their section +# 4. When a rule has multiple dependencies, you should: +# - duplicate the rule name to add the help string (if required) +# - write one dependency per line to increase readability and diffs +# 5. .PHONY rule statement should be written after the corresponding rule +# ============================================================================== +# VARIABLES + +BOLD := \033[1m +RESET := \033[0m +GREEN := \033[1;32m + + +# -- Database + +DB_HOST = postgresql +DB_PORT = 5432 + +# -- Docker +# Get the current user ID to use for docker run and docker exec commands +DOCKER_UID = $(shell id -u) +DOCKER_GID = $(shell id -g) +DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID) +COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose +COMPOSE_EXEC = $(COMPOSE) exec +COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev +COMPOSE_RUN = $(COMPOSE) run --rm +COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev +COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin +WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s + +# -- Backend +MANAGE = $(COMPOSE_RUN_APP) python manage.py +MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn +TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn + +# -- Frontend +PATH_FRONT = ./src/frontend + +# ============================================================================== +# RULES + +default: help + +data/media: + @mkdir -p data/media + +data/static: + @mkdir -p data/static + +# -- Project + +create-env-files: ## Copy the dist env files to env files +create-env-files: \ + env.d/development/common \ + env.d/development/crowdin \ + env.d/development/postgresql \ + env.d/development/kc_postgresql +.PHONY: create-env-files + +bootstrap: ## Prepare Docker images for the project +bootstrap: \ + data/media \ + data/static \ + create-env-files \ + build \ + migrate \ + demo \ + back-i18n-compile \ + mails-install \ + mails-build +.PHONY: bootstrap + +# -- Docker/compose +build: ## build the app-dev container + @$(COMPOSE) build app-dev --no-cache +.PHONY: build + +down: ## stop and remove containers, networks, images, and volumes + @$(COMPOSE) down +.PHONY: down + +logs: ## display app-dev logs (follow mode) + @$(COMPOSE) logs -f app-dev +.PHONY: logs + +run: ## start the wsgi (production) and development server + @$(COMPOSE) up --force-recreate -d celery-dev + @echo "Wait for postgresql to be up..." + @$(WAIT_DB) +.PHONY: run + +status: ## an alias for "docker compose ps" + @$(COMPOSE) ps +.PHONY: status + +stop: ## stop the development server using Docker + @$(COMPOSE) stop +.PHONY: stop + +# -- Backend + +demo: ## flush db then create a demo for load testing purpose + @$(MAKE) resetdb + @$(MANAGE) create_demo +.PHONY: demo + +# Nota bene: Black should come after isort just in case they don't agree... +lint: ## lint back-end python sources +lint: \ + lint-ruff-format \ + lint-ruff-check \ + lint-pylint +.PHONY: lint + +lint-ruff-format: ## format back-end python sources with ruff + @echo 'lint:ruff-format started…' + @$(COMPOSE_RUN_APP) ruff format . +.PHONY: lint-ruff-format + +lint-ruff-check: ## lint back-end python sources with ruff + @echo 'lint:ruff-check started…' + @$(COMPOSE_RUN_APP) ruff check . --fix +.PHONY: lint-ruff-check + +lint-pylint: ## lint back-end python sources with pylint only on changed files from main + @echo 'lint:pylint started…' + bin/pylint --diff-only=origin/main +.PHONY: lint-pylint + +test: ## run project tests + @$(MAKE) test-back-parallel +.PHONY: test + +test-back: ## run back-end tests + @args="$(filter-out $@,$(MAKECMDGOALS))" && \ + bin/pytest $${args:-${1}} +.PHONY: test-back + +test-back-parallel: ## run all back-end tests in parallel + @args="$(filter-out $@,$(MAKECMDGOALS))" && \ + bin/pytest -n auto $${args:-${1}} +.PHONY: test-back-parallel + +makemigrations: ## run django makemigrations for the impress project. + @echo "$(BOLD)Running makemigrations$(RESET)" + @$(COMPOSE) up -d postgresql + @$(WAIT_DB) + @$(MANAGE) makemigrations +.PHONY: makemigrations + +migrate: ## run django migrations for the impress project. + @echo "$(BOLD)Running migrations$(RESET)" + @$(COMPOSE) up -d postgresql + @$(WAIT_DB) + @$(MANAGE) migrate +.PHONY: migrate + +superuser: ## Create an admin superuser with password "admin" + @echo "$(BOLD)Creating a Django superuser$(RESET)" + @$(MANAGE) createsuperuser --email admin@example.com --password admin +.PHONY: superuser + +back-i18n-compile: ## compile the gettext files + @$(MANAGE) compilemessages --ignore="venv/**/*" +.PHONY: back-i18n-compile + +back-i18n-generate: ## create the .pot files used for i18n + @$(MANAGE) makemessages -a --keep-pot +.PHONY: back-i18n-generate + +shell: ## connect to database shell + @$(MANAGE) shell #_plus +.PHONY: dbshell + +# -- Database + +dbshell: ## connect to database shell + docker compose exec app-dev python manage.py dbshell +.PHONY: dbshell + +resetdb: FLUSH_ARGS ?= +resetdb: ## flush database and create a superuser "admin" + @echo "$(BOLD)Flush database$(RESET)" + @$(MANAGE) flush $(FLUSH_ARGS) + @${MAKE} superuser +.PHONY: resetdb + +env.d/development/common: + cp -n env.d/development/common.dist env.d/development/common + +env.d/development/postgresql: + cp -n env.d/development/postgresql.dist env.d/development/postgresql + +env.d/development/kc_postgresql: + cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql + +# -- Internationalization + +env.d/development/crowdin: + cp -n env.d/development/crowdin.dist env.d/development/crowdin + +crowdin-download: ## Download translated message from crowdin + @$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml +.PHONY: crowdin-download + +crowdin-download-sources: ## Download sources from Crowdin + @$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml +.PHONY: crowdin-download-sources + +crowdin-upload: ## Upload source translations to crowdin + @$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml +.PHONY: crowdin-upload + +i18n-compile: ## compile all translations +i18n-compile: \ + back-i18n-compile \ + frontend-i18n-compile +.PHONY: i18n-compile + +i18n-generate: ## create the .pot files and extract frontend messages +i18n-generate: \ + back-i18n-generate \ + frontend-i18n-generate +.PHONY: i18n-generate + +i18n-download-and-compile: ## download all translated messages and compile them to be used by all applications +i18n-download-and-compile: \ + crowdin-download \ + i18n-compile +.PHONY: i18n-download-and-compile + +i18n-generate-and-upload: ## generate source translations for all applications and upload them to Crowdin +i18n-generate-and-upload: \ + i18n-generate \ + crowdin-upload +.PHONY: i18n-generate-and-upload + + +# -- Mail generator + +mails-build: ## Convert mjml files to html and text + @$(MAIL_YARN) build +.PHONY: mails-build + +mails-build-html-to-plain-text: ## Convert html files to text + @$(MAIL_YARN) build-html-to-plain-text +.PHONY: mails-build-html-to-plain-text + +mails-build-mjml-to-html: ## Convert mjml files to html and text + @$(MAIL_YARN) build-mjml-to-html +.PHONY: mails-build-mjml-to-html + +mails-install: ## install the mail generator + @$(MAIL_YARN) install +.PHONY: mails-install + +# -- TS client generator + +# FIXME : adapt this command +tsclient-install: ## Install the Typescript API client generator + @$(TSCLIENT_YARN) install +.PHONY: tsclient-install + +# FIXME : adapt this command +tsclient: tsclient-install ## Generate a Typescript API client + @$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient +.PHONY: tsclient-install + +# -- Misc +clean: ## restore repository state as it was freshly cloned + git clean -idx +.PHONY: clean + +help: + @echo "$(BOLD)impress Makefile" + @echo "Please use 'make $(BOLD)target$(RESET)' where $(BOLD)target$(RESET) is one of:" + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}' +.PHONY: help + +# FIXME : adapt this command +frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin + cd $(PATH_FRONT) && yarn i18n:extract +.PHONY: frontend-i18n-extract + +# FIXME : adapt this command +frontend-i18n-generate: ## Generate the frontend json files used for crowdin +frontend-i18n-generate: \ + crowdin-download-sources \ + frontend-i18n-extract +.PHONY: frontend-i18n-generate + +# FIXME : adapt this command +frontend-i18n-compile: ## Format the crowin json files used deploy to the apps + cd $(PATH_FRONT) && yarn i18n:deploy +.PHONY: frontend-i18n-compile + +# -- K8S +build-k8s-cluster: ## build the kubernetes cluster using kind + ./bin/start-kind.sh +.PHONY: build-k8s-cluster + +start-tilt: ## start the kubernetes cluster using kind + tilt up -f ./bin/Tiltfile +.PHONY: build-k8s-cluster + diff --git a/README.md b/README.md new file mode 100644 index 00000000..4825c952 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Impress + +Impress prints your markdown to pdf from predefined templates with user and role based access rights. + +Impress is built on top of [Django Rest +Framework](https://www.django-rest-framework.org/) and [Next.js](https://nextjs.org/). + +## Getting started + +### Prerequisite + +Make sure you have a recent version of Docker and [Docker +Compose](https://docs.docker.com/compose/install) installed on your laptop: + +```bash +$ docker -v + Docker version 20.10.2, build 2291f61 + +$ docker compose -v + docker compose version 1.27.4, build 40524192 +``` + +> ⚠️ You may need to run the following commands with `sudo` but this can be +> avoided by assigning your user to the `docker` group. + +### Project bootstrap + +The easiest way to start working on the project is to use GNU Make: + +```bash +$ make bootstrap FLUSH_ARGS='--no-input' +``` + +Then you can access to the project in development mode by going to http://localhost:3000. +You will be prompted to log in, the default credentials are: +```bash +username: impress +password: impress +``` +--- + +This command builds the `app` container, installs dependencies, performs +database migrations and compile translations. It's a good idea to use this +command each time you are pulling code from the project repository to avoid +dependency-related or migration-related issues. + +Your Docker services should now be up and running 🎉 + +[FIXME] Explain how to run the frontend project. + +### Adding content + +You can create a basic demo site by running: + + $ make demo + +Finally, you can check all available Make rules using: + +```bash +$ make help +``` + +### Django admin + +You can access the Django admin site at +[http://localhost:8071/admin](http://localhost:8071/admin). + +You first need to create a superuser account: + +```bash +$ make superuser +``` + +## Contributing + +This project is intended to be community-driven, so please, do not hesitate to +get in touch if you have any question related to our implementation or design +decisions. + +## License + +This work is released under the MIT License (see [LICENSE](./LICENSE)). diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 00000000..a905f77a --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,17 @@ +# Upgrade + +All instructions to upgrade this project from one release to the next will be +documented in this file. Upgrades must be run sequentially, meaning you should +not skip minor/major releases while upgrading (fix releases can be skipped). + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +For most upgrades, you just need to run the django migrations with +the following command inside your docker container: + +`python manage.py migrate` + +(Note : in your development environment, you can `make migrate`.) + +## [Unreleased] diff --git a/bin/Tiltfile b/bin/Tiltfile new file mode 100644 index 00000000..e95e111d --- /dev/null +++ b/bin/Tiltfile @@ -0,0 +1,68 @@ +load('ext://uibutton', 'cmd_button', 'bool_input', 'location') +load('ext://namespace', 'namespace_create', 'namespace_inject') +namespace_create('impress') + +docker_build( + 'localhost:5001/impress-backend:latest', + context='..', + dockerfile='../Dockerfile', + only=['./src/backend', './src/mail', './docker'], + target = 'backend-production', + live_update=[ + sync('../src/backend', '/app'), + run( + 'pip install -r /app/requirements.txt', + trigger=['./api/requirements.txt'] + ) + ] +) + +docker_build( + 'localhost:5001/impress-y-webrtc-signaling:latest', + context='..', + dockerfile='../src/frontend/Dockerfile', + only=['./src/frontend/', './docker/', './dockerignore'], + target = 'y-webrtc-signaling', + live_update=[ + sync('../src/frontend/apps/y-webrtc-signaling/src', '/home/frontend/apps/y-webrtc-signaling/src'), + ] +) + +docker_build( + 'localhost:5001/impress-frontend:latest', + context='..', + dockerfile='../src/frontend/Dockerfile', + only=['./src/frontend', './docker', './dockerignore'], + target = 'impress', + live_update=[ + sync('../src/frontend', '/home/frontend'), + ] +) + +k8s_yaml(local('cd ../src/helm && helmfile -n impress -e dev template .')) + +migration = ''' +set -eu +# get k8s pod name from tilt resource name +POD_NAME="$(tilt get kubernetesdiscovery impress-backend -ojsonpath='{.status.pods[0].name}')" +kubectl -n impress exec "$POD_NAME" -- python manage.py makemigrations +''' +cmd_button('Make migration', + argv=['sh', '-c', migration], + resource='impress-backend', + icon_name='developer_board', + text='Run makemigration', +) + +pod_migrate = ''' +set -eu +# get k8s pod name from tilt resource name +POD_NAME="$(tilt get kubernetesdiscovery impress-backend -ojsonpath='{.status.pods[0].name}')" +kubectl -n impress exec "$POD_NAME" -- python manage.py migrate --no-input +''' +cmd_button('Migrate db', + argv=['sh', '-c', pod_migrate], + resource='impress-backend', + icon_name='developer_board', + text='Run database migration', +) diff --git a/bin/_config.sh b/bin/_config.sh new file mode 100644 index 00000000..fe9eaf89 --- /dev/null +++ b/bin/_config.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash + +set -eo pipefail + +REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)" +UNSET_USER=0 + +TERRAFORM_DIRECTORY="./env.d/terraform" +COMPOSE_FILE="${REPO_DIR}/docker-compose.yml" +COMPOSE_PROJECT="impress" + + +# _set_user: set (or unset) default user id used to run docker commands +# +# usage: _set_user +# +# You can override default user ID (the current host user ID), by defining the +# USER_ID environment variable. +# +# To avoid running docker commands with a custom user, please set the +# $UNSET_USER environment variable to 1. +function _set_user() { + + if [ $UNSET_USER -eq 1 ]; then + USER_ID="" + return + fi + + # USER_ID = USER_ID or `id -u` if USER_ID is not set + USER_ID=${USER_ID:-$(id -u)} + + echo "🙋(user) ID: ${USER_ID}" +} + +# docker_compose: wrap docker compose command +# +# usage: docker_compose [options] [ARGS...] +# +# options: docker compose command options +# ARGS : docker compose command arguments +function _docker_compose() { + + echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'" + docker compose \ + -p "${COMPOSE_PROJECT}" \ + -f "${COMPOSE_FILE}" \ + --project-directory "${REPO_DIR}" \ + "$@" +} + +# _dc_run: wrap docker compose run command +# +# usage: _dc_run [options] [ARGS...] +# +# options: docker compose run command options +# ARGS : docker compose run command arguments +function _dc_run() { + _set_user + + user_args="--user=$USER_ID" + if [ -z $USER_ID ]; then + user_args="" + fi + + _docker_compose run --rm $user_args "$@" +} + +# _dc_exec: wrap docker compose exec command +# +# usage: _dc_exec [options] [ARGS...] +# +# options: docker compose exec command options +# ARGS : docker compose exec command arguments +function _dc_exec() { + _set_user + + echo "🐳(compose) exec command: '\$@'" + + user_args="--user=$USER_ID" + if [ -z $USER_ID ]; then + user_args="" + fi + + _docker_compose exec $user_args "$@" +} + +# _django_manage: wrap django's manage.py command with docker compose +# +# usage : _django_manage [ARGS...] +# +# ARGS : django's manage.py command arguments +function _django_manage() { + _dc_run "app-dev" python manage.py "$@" +} + +# _set_openstack_project: select an OpenStack project from the openrc files defined in the +# terraform directory. +# +# usage: _set_openstack_project +# +# If necessary the script will prompt the user to choose a project from those available +function _set_openstack_project() { + + declare prompt + declare -a projects + declare -i default=1 + declare -i choice=0 + declare -i n_projects + + # List projects by looking in the "./env.d/terraform" directory + # and store them in an array + read -r -a projects <<< "$( + find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d | + sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' | + xargs + )" + nb_projects=${#projects[@]} + + if [[ ${nb_projects} -le 0 ]]; then + echo "There are no OpenStack projects defined..." >&2 + echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \ + "of your project and copy your \"openrc.sh\" file into it." >&2 + exit 10 + fi + + if [[ ${nb_projects} -gt 1 ]]; then + prompt="Select an OpenStack project to target:\\n" + for (( i=0; i&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})") + exit 11 + fi + + if [[ ${choice} -le 0 ]]; then + choice=${default} + fi + fi + + project=${projects[$((choice-1))]} + # Check that the openrc.sh file exists for this project + if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then + (>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.") + exit 12 + fi + + echo "${project}" +} diff --git a/bin/compose b/bin/compose new file mode 100755 index 00000000..1adb3d83 --- /dev/null +++ b/bin/compose @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +# shellcheck source=bin/_config.sh +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_docker_compose "$@" diff --git a/bin/manage b/bin/manage new file mode 100755 index 00000000..b6c82d9a --- /dev/null +++ b/bin/manage @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +# shellcheck source=bin/_config.sh +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_django_manage "$@" diff --git a/bin/pylint b/bin/pylint new file mode 100755 index 00000000..8053c7c2 --- /dev/null +++ b/bin/pylint @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# shellcheck source=bin/_config.sh +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +declare diff_from +declare -a paths +declare -a args + +# Parse options +for arg in "$@" +do + case $arg in + --diff-only=*) + diff_from="${arg#*=}" + shift + ;; + -*) + args+=("$arg") + shift + ;; + *) + paths+=("$arg") + shift + ;; + esac +done + +if [[ -n "${diff_from}" ]]; then + # Run pylint only on modified files located in src/backend + # (excluding deleted files and migration files) + # shellcheck disable=SC2207 + paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$')) +fi + +# Fix docker vs local path when project sources are mounted as a volume +read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")" +_dc_run app-dev pylint "${paths[@]}" "${args[@]}" diff --git a/bin/pytest b/bin/pytest new file mode 100755 index 00000000..8ed0d30f --- /dev/null +++ b/bin/pytest @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +source "$(dirname "${BASH_SOURCE[0]}")/_config.sh" + +_dc_run \ + -e DJANGO_CONFIGURATION=Test \ + app-dev \ + pytest "$@" diff --git a/bin/start-kind.sh b/bin/start-kind.sh new file mode 100644 index 00000000..a77f12f6 --- /dev/null +++ b/bin/start-kind.sh @@ -0,0 +1,103 @@ +#!/bin/sh +set -o errexit + +CURRENT_DIR=$(pwd) + +echo "0. Create ca" +# 0. Create ca +mkcert -install +cd /tmp +mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io" +cd $CURRENT_DIR + +echo "1. Create registry container unless it already exists" +# 1. Create registry container unless it already exists +reg_name='kind-registry' +reg_port='5001' +if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then + docker run \ + -d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \ + registry:2 +fi + +echo "2. Create kind cluster with containerd registry config dir enabled" +# 2. Create kind cluster with containerd registry config dir enabled +# TODO: kind will eventually enable this by default and this patch will +# be unnecessary. +# +# See: +# https://github.com/kubernetes-sigs/kind/issues/2875 +# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration +# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md +cat < /dev/null 2>&1; then + if [ -w /etc/passwd ]; then + echo "${USER_NAME:-default}:x:$(id -u):$(id -g):${USER_NAME:-default} user:${HOME}:/sbin/nologin" >> /etc/passwd + fi +fi + +echo "🐳(entrypoint) running your command: ${*}" +exec "$@" diff --git a/docker/files/usr/local/etc/gunicorn/impress.py b/docker/files/usr/local/etc/gunicorn/impress.py new file mode 100644 index 00000000..b822b768 --- /dev/null +++ b/docker/files/usr/local/etc/gunicorn/impress.py @@ -0,0 +1,16 @@ +# Gunicorn-django settings +bind = ["0.0.0.0:8000"] +name = "impress" +python_path = "/app" + +# Run +graceful_timeout = 90 +timeout = 90 +workers = 3 + +# Logging +# Using '-' for the access log file makes gunicorn log accesses to stdout +accesslog = "-" +# Using '-' for the error log file makes gunicorn log errors to stderr +errorlog = "-" +loglevel = "info" diff --git a/docs/tsclient.md b/docs/tsclient.md new file mode 100644 index 00000000..a7e3c13f --- /dev/null +++ b/docs/tsclient.md @@ -0,0 +1,25 @@ +# Api client TypeScript + +The backend application can automatically create a TypeScript client to be used in frontend +applications. It is used in the impress front application itself. + +This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen) +and impress's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running). + +## Requirements + +We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to +install the backend application. + +## Install openApiClientJs + +```sh +$ cd src/tsclient +$ yarn install +``` + +## Generate the client + +```sh +yarn generate:api:client:local +``` diff --git a/env.d/development/common.dist b/env.d/development/common.dist new file mode 100644 index 00000000..712c4dbd --- /dev/null +++ b/env.d/development/common.dist @@ -0,0 +1,41 @@ +# Django +DJANGO_ALLOWED_HOSTS=* +DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly +DJANGO_SETTINGS_MODULE=impress.settings +DJANGO_SUPERUSER_PASSWORD=admin + +# Python +PYTHONPATH=/app + +# impress settings + +# Mail +DJANGO_EMAIL_HOST="mailcatcher" +DJANGO_EMAIL_PORT=1025 + +# Backend url +IMPRESS_BASE_URL="http://localhost:8072" + +# Media +STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage +AWS_S3_ENDPOINT_URL=http://minio:9000 +AWS_S3_ACCESS_KEY_ID=impress +AWS_S3_SECRET_ACCESS_KEY=password + +# OIDC +OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/certs +OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/impress/protocol/openid-connect/auth +OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/token +OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/impress/protocol/openid-connect/userinfo + +OIDC_RP_CLIENT_ID=impress +OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly +OIDC_RP_SIGN_ALGO=RS256 +OIDC_RP_SCOPES="openid email" + +LOGIN_REDIRECT_URL=http://localhost:3000 +LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000 +LOGOUT_REDIRECT_URL=http://localhost:3000 + +OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"] +OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"} diff --git a/env.d/development/common.e2e.dist b/env.d/development/common.e2e.dist new file mode 100644 index 00000000..b0faa27d --- /dev/null +++ b/env.d/development/common.e2e.dist @@ -0,0 +1,3 @@ +# For the CI job test-e2e +SUSTAINED_THROTTLE_RATES="200/hour" +BURST_THROTTLE_RATES="200/minute" diff --git a/env.d/development/crowdin.dist b/env.d/development/crowdin.dist new file mode 100644 index 00000000..1218e3b7 --- /dev/null +++ b/env.d/development/crowdin.dist @@ -0,0 +1,3 @@ +CROWDIN_API_TOKEN=Your-Api-Token +CROWDIN_PROJECT_ID=Your-Project-Id +CROWDIN_BASE_PATH=/app/src diff --git a/env.d/development/kc_postgresql.dist b/env.d/development/kc_postgresql.dist new file mode 100644 index 00000000..505ae80d --- /dev/null +++ b/env.d/development/kc_postgresql.dist @@ -0,0 +1,11 @@ +# Postgresql db container configuration +POSTGRES_DB=keycloak +POSTGRES_USER=impress +POSTGRES_PASSWORD=pass + +# App database configuration +DB_HOST=kc_postgresql +DB_NAME=keycloak +DB_USER=impress +DB_PASSWORD=pass +DB_PORT=5433 \ No newline at end of file diff --git a/env.d/development/postgresql.dist b/env.d/development/postgresql.dist new file mode 100644 index 00000000..a5817257 --- /dev/null +++ b/env.d/development/postgresql.dist @@ -0,0 +1,11 @@ +# Postgresql db container configuration +POSTGRES_DB=impress +POSTGRES_USER=dinum +POSTGRES_PASSWORD=pass + +# App database configuration +DB_HOST=postgresql +DB_NAME=impress +DB_USER=dinum +DB_PASSWORD=pass +DB_PORT=5432 \ No newline at end of file diff --git a/gitlint/gitlint_emoji.py b/gitlint/gitlint_emoji.py new file mode 100644 index 00000000..59c86eaf --- /dev/null +++ b/gitlint/gitlint_emoji.py @@ -0,0 +1,37 @@ +""" +Gitlint extra rule to validate that the message title is of the form +"() " +""" +from __future__ import unicode_literals + +import re + +import requests + +from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation + + +class GitmojiTitle(LineRule): + """ + This rule will enforce that each commit title is of the form "() " + where gitmoji is an emoji from the list defined in https://gitmoji.carloscuesta.me and + subject should be all lowercase + """ + + id = "UC1" + name = "title-should-have-gitmoji-and-scope" + target = CommitMessageTitle + + def validate(self, title, _commit): + """ + Download the list possible gitmojis from the project's github repository and check that + title contains one of them. + """ + gitmojis = requests.get( + "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json" + ).json()["gitmojis"] + emojis = [item["emoji"] for item in gitmojis] + pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis)) + if not re.search(pattern, title): + violation_msg = 'Title does not match regex "() "' + return [RuleViolation(self.id, violation_msg, title)] diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..3dfa04aa --- /dev/null +++ b/renovate.json @@ -0,0 +1,26 @@ +{ + "extends": ["github>numerique-gouv/renovate-configuration"], + "dependencyDashboard": true, + "labels": ["dependencies", "noChangeLog"], + "packageRules": [ + { + "enabled": false, + "groupName": "ignored python dependencies", + "matchManagers": ["pep621"], + "matchPackageNames": [] + }, + { + "enabled": false, + "groupName": "ignored js dependencies", + "matchManagers": ["npm"], + "matchPackageNames": [ + "node", + "node-fetch", + "i18next-parser", + "eslint", + "react", + "react-dom" + ] + } + ] +} diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 00000000..7d1c790c --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/" +PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit" + +cat <<'EOF' >$PRE_COMMIT_FILE +#!/bin/bash + +# directories containing potential secrets +DIRS="." + +bold=$(tput bold) +normal=$(tput sgr0) + +# allow to read user input, assigns stdin to keyboard +exec ?$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=yes + +# Minimum lines number of a similarity. +# First implementations of CMS wizards have common fields we do not want to factorize for now +min-similarity-lines=35 + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__)|urlpatterns|logger)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + cm, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +method-rgx=([a-z_][a-z0-9_]{2,50}|setUp|set[Uu]pClass|tearDown|tear[Dd]ownClass|assert[A-Z]\w*|maxDiff|test_[a-z0-9_]+)$ + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=0 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=builtins.Exception diff --git a/src/backend/MANIFEST.in b/src/backend/MANIFEST.in new file mode 100644 index 00000000..999508bf --- /dev/null +++ b/src/backend/MANIFEST.in @@ -0,0 +1,3 @@ +include LICENSE +include README.md +recursive-include src/backend/impress *.html *.png *.gif *.css *.ico *.jpg *.jpeg *.po *.mo *.eot *.svg *.ttf *.woff *.woff2 diff --git a/src/backend/__init__.py b/src/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/__init__.py b/src/backend/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py new file mode 100644 index 00000000..24fd9b96 --- /dev/null +++ b/src/backend/core/admin.py @@ -0,0 +1,64 @@ +"""Admin classes and registrations for core app.""" +from django.contrib import admin +from django.contrib.auth import admin as auth_admin +from django.utils.translation import gettext_lazy as _ + +from . import models + + +@admin.register(models.User) +class UserAdmin(auth_admin.UserAdmin): + """Admin class for the User model""" + + fieldsets = ( + ( + None, + { + "fields": ( + "id", + "admin_email", + "password", + ) + }, + ), + (_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_device", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ), + }, + ), + (_("Important dates"), {"fields": ("created_at", "updated_at")}), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), + ) + list_display = ( + "id", + "sub", + "admin_email", + "email", + "is_active", + "is_staff", + "is_superuser", + "is_device", + "created_at", + "updated_at", + ) + list_filter = ("is_staff", "is_superuser", "is_device", "is_active") + ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at") + readonly_fields = ("id", "sub", "email", "created_at", "updated_at") + search_fields = ("id", "sub", "admin_email", "email") diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py new file mode 100644 index 00000000..c8152ab5 --- /dev/null +++ b/src/backend/core/api/__init__.py @@ -0,0 +1,39 @@ +"""Impress core API endpoints""" +from django.conf import settings +from django.core.exceptions import ValidationError + +from rest_framework import exceptions as drf_exceptions +from rest_framework import views as drf_views +from rest_framework.decorators import api_view +from rest_framework.response import Response + + +def exception_handler(exc, context): + """Handle Django ValidationError as an accepted exception. + + For the parameters, see ``exception_handler`` + This code comes from twidi's gist: + https://gist.github.com/twidi/9d55486c36b6a51bdcb05ce3a763e79f + """ + if isinstance(exc, ValidationError): + if hasattr(exc, "message_dict"): + detail = exc.message_dict + elif hasattr(exc, "message"): + detail = exc.message + elif hasattr(exc, "messages"): + detail = exc.messages + + exc = drf_exceptions.ValidationError(detail=detail) + + return drf_views.exception_handler(exc, context) + + +# pylint: disable=unused-argument +@api_view(["GET"]) +def get_frontend_configuration(request): + """Returns the frontend configuration dict as configured in settings.""" + frontend_configuration = { + "LANGUAGE_CODE": settings.LANGUAGE_CODE, + } + frontend_configuration.update(settings.FRONTEND_CONFIGURATION) + return Response(frontend_configuration) diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py new file mode 100644 index 00000000..3df55257 --- /dev/null +++ b/src/backend/core/api/permissions.py @@ -0,0 +1,36 @@ +"""Permission handlers for the impress core app.""" +from rest_framework import permissions + +ACTION_FOR_METHOD_TO_PERMISSION = { + "versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"} +} + + +class IsAuthenticated(permissions.BasePermission): + """ + Allows access only to authenticated users. Alternative method checking the presence + of the auth token to avoid hitting the database. + """ + + def has_permission(self, request, view): + return bool(request.auth) or request.user.is_authenticated + + +class IsAuthenticatedOrSafe(IsAuthenticated): + """Allows access to authenticated users (or anonymous users but only on safe methods).""" + + def has_permission(self, request, view): + if request.method in permissions.SAFE_METHODS: + return True + return super().has_permission(request, view) + + +class IsSelf(IsAuthenticated): + """ + Allows access only to authenticated users. Alternative method checking the presence + of the auth token to avoid hitting the database. + """ + + def has_object_permission(self, request, view, obj): + """Write permissions are only allowed to the user itself.""" + return obj == request.user diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py new file mode 100644 index 00000000..5a7186f9 --- /dev/null +++ b/src/backend/core/api/serializers.py @@ -0,0 +1,13 @@ +"""Client serializers for the impress core app.""" +from rest_framework import serializers + +from core import models + + +class UserSerializer(serializers.ModelSerializer): + """Serialize users.""" + + class Meta: + model = models.User + fields = ["id", "email"] + read_only_fields = ["id", "email"] diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py new file mode 100644 index 00000000..223aa871 --- /dev/null +++ b/src/backend/core/api/viewsets.py @@ -0,0 +1,142 @@ +"""API endpoints""" +from rest_framework import ( + decorators, + mixins, + pagination, + viewsets, +) +from rest_framework import ( + response as drf_response, +) + +from core import models + +from . import permissions, serializers + +# pylint: disable=too-many-ancestors + + +class NestedGenericViewSet(viewsets.GenericViewSet): + """ + A generic Viewset aims to be used in a nested route context. + e.g: `/api/v1.0/resource_1//resource_2//` + + It allows to define all url kwargs and lookup fields to perform the lookup. + """ + + lookup_fields: list[str] = ["pk"] + lookup_url_kwargs: list[str] = [] + + def __getattribute__(self, item): + """ + This method is overridden to allow to get the last lookup field or lookup url kwarg + when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful + to keep compatibility with all methods used by the parent class `GenericViewSet`. + """ + if item in ["lookup_field", "lookup_url_kwarg"]: + return getattr(self, item + "s", [None])[-1] + + return super().__getattribute__(item) + + def get_queryset(self): + """ + Get the list of items for this view. + + `lookup_fields` attribute is enumerated here to perform the nested lookup. + """ + queryset = super().get_queryset() + + # The last lookup field is removed to perform the nested lookup as it corresponds + # to the object pk, it is used within get_object method. + lookup_url_kwargs = ( + self.lookup_url_kwargs[:-1] + if self.lookup_url_kwargs + else self.lookup_fields[:-1] + ) + + filter_kwargs = {} + for index, lookup_url_kwarg in enumerate(lookup_url_kwargs): + if lookup_url_kwarg not in self.kwargs: + raise KeyError( + f"Expected view {self.__class__.__name__} to be called with a URL " + f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or ' + "set the `.lookup_fields` attribute on the view correctly." + ) + + filter_kwargs.update( + {self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]} + ) + + return queryset.filter(**filter_kwargs) + + +class SerializerPerActionMixin: + """ + A mixin to allow to define serializer classes for each action. + + This mixin is useful to avoid to define a serializer class for each action in the + `get_serializer_class` method. + """ + + serializer_classes: dict[str, type] = {} + default_serializer_class: type = None + + def get_serializer_class(self): + """ + Return the serializer class to use depending on the action. + """ + return self.serializer_classes.get(self.action, self.default_serializer_class) + + +class Pagination(pagination.PageNumberPagination): + """Pagination to display no more than 100 objects per page sorted by creation date.""" + + ordering = "-created_on" + max_page_size = 100 + page_size_query_param = "page_size" + + +class UserViewSet( + mixins.UpdateModelMixin, viewsets.GenericViewSet, mixins.ListModelMixin +): + """User ViewSet""" + + permission_classes = [permissions.IsSelf] + queryset = models.User.objects.all() + serializer_class = serializers.UserSerializer + + def get_queryset(self): + """ + Limit listed users by querying the email field with a trigram similarity + search if a query is provided. + Limit listed users by excluding users already in the document if a document_id + is provided. + """ + queryset = self.queryset + + if self.action == "list": + # Exclude all users already in the given document + if document_id := self.request.GET.get("document_id", ""): + queryset = queryset.exclude(documentaccess__document_id=document_id) + + # Filter users by email similarity + if query := self.request.GET.get("q", ""): + queryset = queryset.filter(email__trigram_word_similar=query) + + return queryset + + @decorators.action( + detail=False, + methods=["get"], + url_name="me", + url_path="me", + permission_classes=[permissions.IsAuthenticated], + ) + def get_me(self, request): + """ + Return information on currently logged user + """ + context = {"request": request} + return drf_response.Response( + self.serializer_class(request.user, context=context).data + ) diff --git a/src/backend/core/apps.py b/src/backend/core/apps.py new file mode 100644 index 00000000..bba7de0f --- /dev/null +++ b/src/backend/core/apps.py @@ -0,0 +1,11 @@ +"""Impress Core application""" +# from django.apps import AppConfig +# from django.utils.translation import gettext_lazy as _ + + +# class CoreConfig(AppConfig): +# """Configuration class for the impress core app.""" + +# name = "core" +# app_label = "core" +# verbose_name = _("impress core application") diff --git a/src/backend/core/authentication/__init__.py b/src/backend/core/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/authentication/backends.py b/src/backend/core/authentication/backends.py new file mode 100644 index 00000000..bfa2c107 --- /dev/null +++ b/src/backend/core/authentication/backends.py @@ -0,0 +1,100 @@ +"""Authentication Backends for the Impress core app.""" + +from django.core.exceptions import SuspiciousOperation +from django.utils.translation import gettext_lazy as _ + +import requests +from mozilla_django_oidc.auth import ( + OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend, +) + +from core.models import User + + +class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend): + """Custom OpenID Connect (OIDC) Authentication Backend. + + This class overrides the default OIDC Authentication Backend to accommodate differences + in the User and Identity models, and handles signed and/or encrypted UserInfo response. + """ + + def get_userinfo(self, access_token, id_token, payload): + """Return user details dictionary. + + Parameters: + - access_token (str): The access token. + - id_token (str): The id token (unused). + - payload (dict): The token payload (unused). + + Note: The id_token and payload parameters are unused in this implementation, + but were kept to preserve base method signature. + + Note: It handles signed and/or encrypted UserInfo Response. It is required by + Agent Connect, which follows the OIDC standard. It forces us to override the + base method, which deal with 'application/json' response. + + Returns: + - dict: User details dictionary obtained from the OpenID Connect user endpoint. + """ + + user_response = requests.get( + self.OIDC_OP_USER_ENDPOINT, + headers={"Authorization": f"Bearer {access_token}"}, + verify=self.get_settings("OIDC_VERIFY_SSL", True), + timeout=self.get_settings("OIDC_TIMEOUT", None), + proxies=self.get_settings("OIDC_PROXY", None), + ) + user_response.raise_for_status() + userinfo = self.verify_token(user_response.text) + return userinfo + + def get_or_create_user(self, access_token, id_token, payload): + """Return a User based on userinfo. Get or create a new user if no user matches the Sub. + + Parameters: + - access_token (str): The access token. + - id_token (str): The ID token. + - payload (dict): The user payload. + + Returns: + - User: An existing or newly created User instance. + + Raises: + - Exception: Raised when user creation is not allowed and no existing user is found. + """ + + user_info = self.get_userinfo(access_token, id_token, payload) + sub = user_info.get("sub") + + if sub is None: + raise SuspiciousOperation( + _("User info contained no recognizable user identification") + ) + + try: + user = User.objects.get(sub=sub) + except User.DoesNotExist: + if self.get_settings("OIDC_CREATE_USER", True): + user = self.create_user(user_info) + else: + user = None + + return user + + def create_user(self, claims): + """Return a newly created User instance.""" + + sub = claims.get("sub") + + if sub is None: + raise SuspiciousOperation( + _("Claims contained no recognizable user identification") + ) + + user = User.objects.create( + sub=sub, + email=claims.get("email"), + password="!", # noqa: S106 + ) + + return user diff --git a/src/backend/core/authentication/urls.py b/src/backend/core/authentication/urls.py new file mode 100644 index 00000000..2a66c83d --- /dev/null +++ b/src/backend/core/authentication/urls.py @@ -0,0 +1,18 @@ +"""Authentication URLs for the People core app.""" + +from django.urls import path + +from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls + +from .views import OIDCLogoutCallbackView, OIDCLogoutView + +urlpatterns = [ + # Override the default 'logout/' path from Mozilla Django OIDC with our custom view. + path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"), + path( + "logout-callback/", + OIDCLogoutCallbackView.as_view(), + name="oidc_logout_callback", + ), + *mozzila_oidc_urls, +] diff --git a/src/backend/core/authentication/views.py b/src/backend/core/authentication/views.py new file mode 100644 index 00000000..61fe0acf --- /dev/null +++ b/src/backend/core/authentication/views.py @@ -0,0 +1,137 @@ +"""Authentication Views for the People core app.""" + +from urllib.parse import urlencode + +from django.contrib import auth +from django.core.exceptions import SuspiciousOperation +from django.http import HttpResponseRedirect +from django.urls import reverse +from django.utils import crypto + +from mozilla_django_oidc.utils import ( + absolutify, +) +from mozilla_django_oidc.views import ( + OIDCLogoutView as MozillaOIDCOIDCLogoutView, +) + + +class OIDCLogoutView(MozillaOIDCOIDCLogoutView): + """Custom logout view for handling OpenID Connect (OIDC) logout flow. + + Adds support for handling logout callbacks from the identity provider (OP) + by initiating the logout flow if the user has an active session. + + The Django session is retained during the logout process to persist the 'state' OIDC parameter. + This parameter is crucial for maintaining the integrity of the logout flow between this call + and the subsequent callback. + """ + + @staticmethod + def persist_state(request, state): + """Persist the given 'state' parameter in the session's 'oidc_states' dictionary + + This method is used to store the OIDC state parameter in the session, according to the + structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session' + utility function. + """ + + if "oidc_states" not in request.session or not isinstance( + request.session["oidc_states"], dict + ): + request.session["oidc_states"] = {} + + request.session["oidc_states"][state] = {} + request.session.save() + + def construct_oidc_logout_url(self, request): + """Create the redirect URL for interfacing with the OIDC provider. + + Retrieves the necessary parameters from the session and constructs the URL + required to initiate logout with the OpenID Connect provider. + + If no ID token is found in the session, the logout flow will not be initiated, + and the method will return the default redirect URL. + + The 'state' parameter is generated randomly and persisted in the session to ensure + its integrity during the subsequent callback. + """ + + oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT") + + if not oidc_logout_endpoint: + return self.redirect_url + + reverse_url = reverse("oidc_logout_callback") + id_token = request.session.get("oidc_id_token", None) + + if not id_token: + return self.redirect_url + + query = { + "id_token_hint": id_token, + "state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)), + "post_logout_redirect_uri": absolutify(request, reverse_url), + } + + self.persist_state(request, query["state"]) + + return f"{oidc_logout_endpoint}?{urlencode(query)}" + + def post(self, request): + """Handle user logout. + + If the user is not authenticated, redirects to the default logout URL. + Otherwise, constructs the OIDC logout URL and redirects the user to start + the logout process. + + If the user is redirected to the default logout URL, ensure her Django session + is terminated. + """ + + logout_url = self.redirect_url + + if request.user.is_authenticated: + logout_url = self.construct_oidc_logout_url(request) + + # If the user is not redirected to the OIDC provider, ensure logout + if logout_url == self.redirect_url: + auth.logout(request) + + return HttpResponseRedirect(logout_url) + + +class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView): + """Custom view for handling the logout callback from the OpenID Connect (OIDC) provider. + + Handles the callback after logout from the identity provider (OP). + Verifies the state parameter and performs necessary logout actions. + + The Django session is maintained during the logout process to ensure the integrity + of the logout flow initiated in the previous step. + """ + + http_method_names = ["get"] + + def get(self, request): + """Handle the logout callback. + + If the user is not authenticated, redirects to the default logout URL. + Otherwise, verifies the state parameter and performs necessary logout actions. + """ + + if not request.user.is_authenticated: + return HttpResponseRedirect(self.redirect_url) + + state = request.GET.get("state") + + if state not in request.session.get("oidc_states", {}): + msg = "OIDC callback state not found in session `oidc_states`!" + raise SuspiciousOperation(msg) + + del request.session["oidc_states"][state] + request.session.save() + + auth.logout(request) + + return HttpResponseRedirect(self.redirect_url) diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py new file mode 100644 index 00000000..f4e0e11a --- /dev/null +++ b/src/backend/core/enums.py @@ -0,0 +1,15 @@ +""" +Core application enums declaration +""" +from django.conf import global_settings, settings +from django.utils.translation import gettext_lazy as _ + +# Django sets `LANGUAGES` by default with all supported languages. We can use it for +# the choice of languages which should not be limited to the few languages active in +# the app. +# pylint: disable=no-member +ALL_LANGUAGES = getattr( + settings, + "ALL_LANGUAGES", + [(language, _(name)) for language, name in global_settings.LANGUAGES], +) diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py new file mode 100644 index 00000000..9b1f3186 --- /dev/null +++ b/src/backend/core/factories.py @@ -0,0 +1,25 @@ +# ruff: noqa: S311 +""" +Core application factories +""" +from django.conf import settings +from django.contrib.auth.hashers import make_password + +import factory.fuzzy +from faker import Faker + +from core import models + +fake = Faker() + + +class UserFactory(factory.django.DjangoModelFactory): + """A factory to random users for testing purposes.""" + + class Meta: + model = models.User + + sub = factory.Sequence(lambda n: f"user{n!s}") + email = factory.Faker("email") + language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES]) + password = make_password("password") diff --git a/src/backend/core/migrations/0001_initial.py b/src/backend/core/migrations/0001_initial.py new file mode 100644 index 00000000..38bdb4f3 --- /dev/null +++ b/src/backend/core/migrations/0001_initial.py @@ -0,0 +1,166 @@ +# Generated by Django 5.0.3 on 2024-05-28 20:29 + +import django.contrib.auth.models +import django.core.validators +import django.db.models.deletion +import timezone_field.fields +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('is_public', models.BooleanField(default=False, help_text='Whether this document is public for anyone to use.', verbose_name='public')), + ], + options={ + 'verbose_name': 'Document', + 'verbose_name_plural': 'Documents', + 'db_table': 'impress_document', + 'ordering': ('title',), + }, + ), + migrations.CreateModel( + name='Template', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, verbose_name='description')), + ('code', models.TextField(blank=True, verbose_name='code')), + ('css', models.TextField(blank=True, verbose_name='css')), + ('is_public', models.BooleanField(default=False, help_text='Whether this template is public for anyone to use.', verbose_name='public')), + ], + options={ + 'verbose_name': 'Template', + 'verbose_name_plural': 'Templates', + 'db_table': 'impress_template', + 'ordering': ('title',), + }, + ), + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('sub', models.CharField(blank=True, help_text='Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')), + ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')), + ('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')), + ('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')), + ('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)), + ('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')), + ('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'db_table': 'impress_user', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='DocumentAccess', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('team', models.CharField(blank=True, max_length=100)), + ('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.document')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Document/user relation', + 'verbose_name_plural': 'Document/user relations', + 'db_table': 'impress_document_access', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='Invitation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('email', models.EmailField(max_length=254, verbose_name='email address')), + ('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.document')), + ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Document invitation', + 'verbose_name_plural': 'Document invitations', + 'db_table': 'impress_invitation', + }, + ), + migrations.CreateModel( + name='TemplateAccess', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('team', models.CharField(blank=True, max_length=100)), + ('role', models.CharField(choices=[('reader', 'Reader'), ('editor', 'Editor'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='reader', max_length=20)), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.template')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Template/user relation', + 'verbose_name_plural': 'Template/user relations', + 'db_table': 'impress_template_access', + 'ordering': ('-created_at',), + }, + ), + migrations.AddConstraint( + model_name='documentaccess', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'document'), name='unique_document_user', violation_error_message='This user is already in this document.'), + ), + migrations.AddConstraint( + model_name='documentaccess', + constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'document'), name='unique_document_team', violation_error_message='This team is already in this document.'), + ), + migrations.AddConstraint( + model_name='documentaccess', + constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'), + ), + migrations.AddConstraint( + model_name='invitation', + constraint=models.UniqueConstraint(fields=('email', 'document'), name='email_and_document_unique_together'), + ), + migrations.AddConstraint( + model_name='templateaccess', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'template'), name='unique_template_user', violation_error_message='This user is already in this template.'), + ), + migrations.AddConstraint( + model_name='templateaccess', + constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'template'), name='unique_template_team', violation_error_message='This team is already in this template.'), + ), + migrations.AddConstraint( + model_name='templateaccess', + constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'), + ), + ] diff --git a/src/backend/core/migrations/0002_create_pg_trgm_extension.py b/src/backend/core/migrations/0002_create_pg_trgm_extension.py new file mode 100644 index 00000000..64f58842 --- /dev/null +++ b/src/backend/core/migrations/0002_create_pg_trgm_extension.py @@ -0,0 +1,14 @@ +from django.db import migrations + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.RunSQL( + "CREATE EXTENSION IF NOT EXISTS pg_trgm;", + reverse_sql="DROP EXTENSION IF EXISTS pg_trgm;", + ), + ] diff --git a/src/backend/core/migrations/__init__.py b/src/backend/core/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/models.py b/src/backend/core/models.py new file mode 100644 index 00000000..765280ff --- /dev/null +++ b/src/backend/core/models.py @@ -0,0 +1,143 @@ +""" +Declare and configure the models for the impress core application +""" +import uuid +from logging import getLogger + +from django.conf import settings +from django.contrib.auth import models as auth_models +from django.contrib.auth.base_user import AbstractBaseUser +from django.core import mail, validators +from django.db import models +from django.utils.functional import lazy +from django.utils.translation import gettext_lazy as _ + +from timezone_field import TimeZoneField + +logger = getLogger(__name__) + + +class BaseModel(models.Model): + """ + Serves as an abstract base model for other models, ensuring that records are validated + before saving as Django doesn't do it by default. + + Includes fields common to all models: a UUID primary key and creation/update timestamps. + """ + + id = models.UUIDField( + verbose_name=_("id"), + help_text=_("primary key for the record as UUID"), + primary_key=True, + default=uuid.uuid4, + editable=False, + ) + created_at = models.DateTimeField( + verbose_name=_("created on"), + help_text=_("date and time at which a record was created"), + auto_now_add=True, + editable=False, + ) + updated_at = models.DateTimeField( + verbose_name=_("updated on"), + help_text=_("date and time at which a record was last updated"), + auto_now=True, + editable=False, + ) + + class Meta: + abstract = True + + def save(self, *args, **kwargs): + """Call `full_clean` before saving.""" + self.full_clean() + super().save(*args, **kwargs) + + +class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): + """User model to work with OIDC only authentication.""" + + sub_validator = validators.RegexValidator( + regex=r"^[\w.@+-]+\Z", + message=_( + "Enter a valid sub. This value may contain only letters, " + "numbers, and @/./+/-/_ characters." + ), + ) + + sub = models.CharField( + _("sub"), + help_text=_( + "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only." + ), + max_length=255, + unique=True, + validators=[sub_validator], + blank=True, + null=True, + ) + email = models.EmailField(_("identity email address"), blank=True, null=True) + + # Unlike the "email" field which stores the email coming from the OIDC token, this field + # stores the email used by staff users to login to the admin site + admin_email = models.EmailField( + _("admin email address"), unique=True, blank=True, null=True + ) + + language = models.CharField( + max_length=10, + choices=lazy(lambda: settings.LANGUAGES, tuple)(), + default=settings.LANGUAGE_CODE, + verbose_name=_("language"), + help_text=_("The language in which the user wants to see the interface."), + ) + timezone = TimeZoneField( + choices_display="WITH_GMT_OFFSET", + use_pytz=False, + default=settings.TIME_ZONE, + help_text=_("The timezone in which the user wants to see times."), + ) + is_device = models.BooleanField( + _("device"), + default=False, + help_text=_("Whether the user is a device or a real user."), + ) + is_staff = models.BooleanField( + _("staff status"), + default=False, + help_text=_("Whether the user can log into this admin site."), + ) + is_active = models.BooleanField( + _("active"), + default=True, + help_text=_( + "Whether this user should be treated as active. " + "Unselect this instead of deleting accounts." + ), + ) + + objects = auth_models.UserManager() + + USERNAME_FIELD = "admin_email" + REQUIRED_FIELDS = [] + + class Meta: + db_table = "impress_user" + verbose_name = _("user") + verbose_name_plural = _("users") + + def __str__(self): + return self.email or self.admin_email or str(self.id) + + def email_user(self, subject, message, from_email=None, **kwargs): + """Email this user.""" + if not self.email: + raise ValueError("User has no email address.") + mail.send_mail(subject, message, from_email, [self.email], **kwargs) + + def get_teams(self): + """ + Get list of teams in which the user is, as a list of strings. + Must be cached if retrieved remotely. + """ + return [] diff --git a/src/backend/core/static/images/logo-suite-numerique.png b/src/backend/core/static/images/logo-suite-numerique.png new file mode 100644 index 0000000000000000000000000000000000000000..243c96629ee72f48f0083f4ba6bcea2653c1a9c3 GIT binary patch literal 13584 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 + + + Generate Document + + +

Generate Document

+
+ {% csrf_token %} + {{ form.as_p }} + +
+ + diff --git a/src/backend/core/templatetags/__init__.py b/src/backend/core/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/templatetags/extra_tags.py b/src/backend/core/templatetags/extra_tags.py new file mode 100644 index 00000000..109bd7b0 --- /dev/null +++ b/src/backend/core/templatetags/extra_tags.py @@ -0,0 +1,58 @@ +"""Custom template tags for the core application of People.""" + +import base64 + +from django import template +from django.contrib.staticfiles import finders + +from PIL import ImageFile as PillowImageFile + +register = template.Library() + + +def image_to_base64(file_or_path, close=False): + """ + Return the src string of the base64 encoding of an image represented by its path + or file opened or not. + + Inspired by Django's "get_image_dimensions" + """ + pil_parser = PillowImageFile.Parser() + if hasattr(file_or_path, "read"): + file = file_or_path + if file.closed and hasattr(file, "open"): + file_or_path.open() + file_pos = file.tell() + file.seek(0) + else: + try: + # pylint: disable=consider-using-with + file = open(file_or_path, "rb") + except OSError: + return "" + close = True + + try: + image_data = file.read() + if not image_data: + return "" + pil_parser.feed(image_data) + if pil_parser.image: + mime_type = pil_parser.image.get_format_mimetype() + encoded_string = base64.b64encode(image_data) + return f"data:{mime_type:s};base64, {encoded_string.decode('utf-8'):s}" + return "" + finally: + if close: + file.close() + else: + file.seek(file_pos) + + +@register.simple_tag +def base64_static(path): + """Return a static file into a base64.""" + full_path = finders.find(path) + if full_path: + return image_to_base64(full_path, True) + return "" diff --git a/src/backend/core/tests/__init__.py b/src/backend/core/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/tests/authentication/__init__.py b/src/backend/core/tests/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/tests/authentication/test_backends.py b/src/backend/core/tests/authentication/test_backends.py new file mode 100644 index 00000000..c18635f2 --- /dev/null +++ b/src/backend/core/tests/authentication/test_backends.py @@ -0,0 +1,101 @@ +"""Unit tests for the Authentication Backends.""" + +from django.core.exceptions import SuspiciousOperation + +import pytest + +from core import models +from core.authentication.backends import OIDCAuthenticationBackend +from core.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +def test_authentication_getter_existing_user_no_email( + django_assert_num_queries, monkeypatch +): + """ + If an existing user matches the user's info sub, the user should be returned. + """ + + klass = OIDCAuthenticationBackend() + db_user = UserFactory() + + def get_userinfo_mocked(*args): + return {"sub": db_user.sub} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + with django_assert_num_queries(1): + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user == db_user + + +def test_authentication_getter_new_user_no_email(monkeypatch): + """ + If no user matches the user's info sub, a user should be created. + User's info doesn't contain an email, created user's email should be empty. + """ + klass = OIDCAuthenticationBackend() + + def get_userinfo_mocked(*args): + return {"sub": "123"} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user.sub == "123" + assert user.email is None + assert user.password == "!" + assert models.User.objects.count() == 1 + + +def test_authentication_getter_new_user_with_email(monkeypatch): + """ + If no user matches the user's info sub, a user should be created. + User's email and name should be set on the identity. + The "email" field on the User model should not be set as it is reserved for staff users. + """ + klass = OIDCAuthenticationBackend() + + email = "impress@example.com" + + def get_userinfo_mocked(*args): + return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"} + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + user = klass.get_or_create_user( + access_token="test-token", id_token=None, payload=None + ) + + assert user.sub == "123" + assert user.email == email + assert user.password == "!" + assert models.User.objects.count() == 1 + + +def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkeypatch): + """The user's info doesn't contain a sub.""" + klass = OIDCAuthenticationBackend() + + def get_userinfo_mocked(*args): + return { + "test": "123", + } + + monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked) + + with django_assert_num_queries(0), pytest.raises( + SuspiciousOperation, + match="User info contained no recognizable user identification", + ): + klass.get_or_create_user(access_token="test-token", id_token=None, payload=None) + + assert models.User.objects.exists() is False diff --git a/src/backend/core/tests/authentication/test_urls.py b/src/backend/core/tests/authentication/test_urls.py new file mode 100644 index 00000000..0e20aac4 --- /dev/null +++ b/src/backend/core/tests/authentication/test_urls.py @@ -0,0 +1,10 @@ +"""Unit tests for the Authentication URLs.""" + +from core.authentication.urls import urlpatterns + + +def test_urls_override_default_mozilla_django_oidc(): + """Custom URL patterns should override default ones from Mozilla Django OIDC.""" + + url_names = [u.name for u in urlpatterns] + assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout") diff --git a/src/backend/core/tests/authentication/test_views.py b/src/backend/core/tests/authentication/test_views.py new file mode 100644 index 00000000..b06cc8cc --- /dev/null +++ b/src/backend/core/tests/authentication/test_views.py @@ -0,0 +1,231 @@ +"""Unit tests for the Authentication Views.""" + +from unittest import mock +from urllib.parse import parse_qs, urlparse + +from django.contrib.auth.models import AnonymousUser +from django.contrib.sessions.middleware import SessionMiddleware +from django.core.exceptions import SuspiciousOperation +from django.test import RequestFactory +from django.test.utils import override_settings +from django.urls import reverse +from django.utils import crypto + +import pytest +from rest_framework.test import APIClient + +from core import factories +from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView + +pytestmark = pytest.mark.django_db + + +@override_settings(LOGOUT_REDIRECT_URL="/example-logout") +def test_view_logout_anonymous(): + """Anonymous users calling the logout url, + should be redirected to the specified LOGOUT_REDIRECT_URL.""" + + url = reverse("oidc_logout_custom") + response = APIClient().get(url) + + assert response.status_code == 302 + assert response.url == "/example-logout" + + +@mock.patch.object( + OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout" +) +def test_view_logout(mocked_oidc_logout_url): + """Authenticated users should be redirected to OIDC provider for logout.""" + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + url = reverse("oidc_logout_custom") + response = client.get(url) + + mocked_oidc_logout_url.assert_called_once() + + assert response.status_code == 302 + assert response.url == "/example-logout" + + +@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout") +@mock.patch.object( + OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout" +) +def test_view_logout_no_oidc_provider(mocked_oidc_logout_url): + """Authenticated users should be logged out when no OIDC provider is available.""" + + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + url = reverse("oidc_logout_custom") + + with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout: + response = client.get(url) + mocked_oidc_logout_url.assert_called_once() + mock_logout.assert_called_once() + + assert response.status_code == 302 + assert response.url == "/default-redirect-logout" + + +@override_settings(LOGOUT_REDIRECT_URL="/example-logout") +def test_view_logout_callback_anonymous(): + """Anonymous users calling the logout callback url, + should be redirected to the specified LOGOUT_REDIRECT_URL.""" + + url = reverse("oidc_logout_callback") + response = APIClient().get(url) + + assert response.status_code == 302 + assert response.url == "/example-logout" + + +@pytest.mark.parametrize( + "initial_oidc_states", + [{}, {"other_state": "foo"}], +) +def test_view_logout_persist_state(initial_oidc_states): + """State value should be persisted in session's data.""" + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + if initial_oidc_states: + request.session["oidc_states"] = initial_oidc_states + request.session.save() + + mocked_state = "mock_state" + + OIDCLogoutView().persist_state(request, mocked_state) + + assert "oidc_states" in request.session + assert request.session["oidc_states"] == { + "mock_state": {}, + **initial_oidc_states, + } + + +@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout") +@mock.patch.object(OIDCLogoutView, "persist_state") +@mock.patch.object(crypto, "get_random_string", return_value="mocked_state") +def test_view_logout_construct_oidc_logout_url( + mocked_get_random_string, mocked_persist_state +): + """Should construct the logout URL to initiate the logout flow with the OIDC provider.""" + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + request.session["oidc_id_token"] = "mocked_oidc_id_token" + request.session.save() + + redirect_url = OIDCLogoutView().construct_oidc_logout_url(request) + + mocked_persist_state.assert_called_once() + mocked_get_random_string.assert_called_once() + + params = parse_qs(urlparse(redirect_url).query) + + assert params["id_token_hint"][0] == "mocked_oidc_id_token" + assert params["state"][0] == "mocked_state" + + url = reverse("oidc_logout_callback") + assert url in params["post_logout_redirect_uri"][0] + + +@override_settings(LOGOUT_REDIRECT_URL="/") +def test_view_logout_construct_oidc_logout_url_none_id_token(): + """If no ID token is available in the session, + the user should be redirected to the final URL.""" + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + redirect_url = OIDCLogoutView().construct_oidc_logout_url(request) + + assert redirect_url == "/" + + +@pytest.mark.parametrize( + "initial_state", + [None, {"other_state": "foo"}], +) +def test_view_logout_callback_wrong_state(initial_state): + """Should raise an error if OIDC state doesn't match session data.""" + + user = factories.UserFactory() + + request = RequestFactory().request() + request.user = user + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + if initial_state: + request.session["oidc_states"] = initial_state + request.session.save() + + callback_view = OIDCLogoutCallbackView.as_view() + + with pytest.raises(SuspiciousOperation) as excinfo: + callback_view(request) + + assert ( + str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!" + ) + + +@override_settings(LOGOUT_REDIRECT_URL="/example-logout") +def test_view_logout_callback(): + """If state matches, callback should clear OIDC state and redirects.""" + + user = factories.UserFactory() + + request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"}) + request.user = user + + middleware = SessionMiddleware(get_response=lambda x: x) + middleware.process_request(request) + + mocked_state = "mocked_state" + + request.session["oidc_states"] = {mocked_state: {}} + request.session.save() + + callback_view = OIDCLogoutCallbackView.as_view() + + with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout: + + def clear_user(request): + # Assert state is cleared prior to logout + assert request.session["oidc_states"] == {} + request.user = AnonymousUser() + + mock_logout.side_effect = clear_user + response = callback_view(request) + mock_logout.assert_called_once() + + assert response.status_code == 302 + assert response.url == "/example-logout" diff --git a/src/backend/core/tests/conftest.py b/src/backend/core/tests/conftest.py new file mode 100644 index 00000000..5a6a3e4a --- /dev/null +++ b/src/backend/core/tests/conftest.py @@ -0,0 +1,15 @@ +"""Fixtures for tests in the impress core application""" +from unittest import mock + +import pytest + +USER = "user" +TEAM = "team" +VIA = [USER, TEAM] + + +@pytest.fixture +def mock_user_get_teams(): + """Mock for the "get_teams" method on the User model.""" + with mock.patch("core.models.User.get_teams") as mock_get_teams: + yield mock_get_teams diff --git a/src/backend/core/tests/swagger/__init__.py b/src/backend/core/tests/swagger/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/core/tests/swagger/test_openapi_schema.py b/src/backend/core/tests/swagger/test_openapi_schema.py new file mode 100644 index 00000000..26b58b71 --- /dev/null +++ b/src/backend/core/tests/swagger/test_openapi_schema.py @@ -0,0 +1,41 @@ +""" +Test suite for generated openapi schema. +""" +import json +from io import StringIO + +from django.core.management import call_command +from django.test import Client + +import pytest + +pytestmark = pytest.mark.django_db + + +def test_openapi_client_schema(): + """ + Generated and served OpenAPI client schema should be correct. + """ + # Start by generating the swagger.json file + output = StringIO() + call_command( + "spectacular", + "--api-version", + "v1.0", + "--urlconf", + "core.urls", + "--format", + "openapi-json", + "--file", + "core/tests/swagger/swagger.json", + stdout=output, + ) + assert output.getvalue() == "" + + response = Client().get("/v1.0/swagger.json") + + assert response.status_code == 200 + with open( + "core/tests/swagger/swagger.json", "r", encoding="utf-8" + ) as expected_schema: + assert response.json() == json.load(expected_schema) diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py new file mode 100644 index 00000000..f746dbb4 --- /dev/null +++ b/src/backend/core/tests/test_api_users.py @@ -0,0 +1,417 @@ +""" +Test users API endpoints in the impress core app. +""" +import pytest +from rest_framework.test import APIClient + +from core import factories, models +from core.api import serializers + +pytestmark = pytest.mark.django_db + + +def test_api_users_list_anonymous(): + """Anonymous users should not be allowed to list users.""" + factories.UserFactory() + client = APIClient() + response = client.get("/api/v1.0/users/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_users_list_authenticated(): + """ + Authenticated users should be able to list users. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + factories.UserFactory.create_batch(2) + response = client.get( + "/api/v1.0/users/", + ) + assert response.status_code == 200 + content = response.json() + assert len(content["results"]) == 3 + + +def test_api_users_list_query_email(): + """ + Authenticated users should be able to list users + and filter by email. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + dave = factories.UserFactory(email="david.bowman@work.com") + nicole = factories.UserFactory(email="nicole_foole@work.com") + frank = factories.UserFactory(email="frank_poole@work.com") + factories.UserFactory(email="heywood_floyd@work.com") + + response = client.get( + "/api/v1.0/users/?q=david.bowman@work.com", + ) + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()["results"]] + assert user_ids == [str(dave.id)] + + response = client.get("/api/v1.0/users/?q=oole") + + assert response.status_code == 200 + user_ids = [user["id"] for user in response.json()["results"]] + assert user_ids == [str(nicole.id), str(frank.id)] + + +def test_api_users_retrieve_me_anonymous(): + """Anonymous users should not be allowed to list users.""" + factories.UserFactory.create_batch(2) + client = APIClient() + response = client.get("/api/v1.0/users/me/") + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_users_retrieve_me_authenticated(): + """Authenticated users should be able to retrieve their own user via the "/users/me" path.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + factories.UserFactory.create_batch(2) + response = client.get( + "/api/v1.0/users/me/", + ) + + assert response.status_code == 200 + assert response.json() == { + "id": str(user.id), + "email": user.email, + } + + +def test_api_users_retrieve_anonymous(): + """Anonymous users should not be allowed to retrieve a user.""" + client = APIClient() + user = factories.UserFactory() + response = client.get(f"/api/v1.0/users/{user.id!s}/") + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + +def test_api_users_retrieve_authenticated_self(): + """ + Authenticated users should be allowed to retrieve their own user. + The returned object should not contain the password. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.get( + f"/api/v1.0/users/{user.id!s}/", + ) + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "GET" not allowed.'} + + +def test_api_users_retrieve_authenticated_other(): + """ + Authenticated users should be able to retrieve another user's detail view with + limited information. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + + response = client.get( + f"/api/v1.0/users/{other_user.id!s}/", + ) + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "GET" not allowed.'} + + +def test_api_users_create_anonymous(): + """Anonymous users should not be able to create users via the API.""" + response = APIClient().post( + "/api/v1.0/users/", + { + "language": "fr-fr", + "password": "mypassword", + }, + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + assert models.User.objects.exists() is False + + +def test_api_users_create_authenticated(): + """Authenticated users should not be able to create users via the API.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.post( + "/api/v1.0/users/", + { + "language": "fr-fr", + "password": "mypassword", + }, + format="json", + ) + assert response.status_code == 405 + assert response.json() == {"detail": 'Method "POST" not allowed.'} + assert models.User.objects.exclude(id=user.id).exists() is False + + +def test_api_users_update_anonymous(): + """Anonymous users should not be able to update users via the API.""" + user = factories.UserFactory() + + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + response = APIClient().put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + ) + + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_update_authenticated_self(): + """ + Authenticated users should be able to update their own user but only "language" + and "timezone" fields. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = dict( + serializers.UserSerializer(instance=factories.UserFactory()).data + ) + + response = client.put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + ) + + assert response.status_code == 200 + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + if key in ["language", "timezone"]: + assert value == new_user_values[key] + else: + assert value == old_user_values[key] + + +def test_api_users_update_authenticated_other(): + """Authenticated users should not be allowed to update other users.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + user = factories.UserFactory() + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = serializers.UserSerializer(instance=factories.UserFactory()).data + + response = client.put( + f"/api/v1.0/users/{user.id!s}/", + new_user_values, + format="json", + ) + + assert response.status_code == 403 + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_patch_anonymous(): + """Anonymous users should not be able to patch users via the API.""" + user = factories.UserFactory() + + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = dict( + serializers.UserSerializer(instance=factories.UserFactory()).data + ) + + for key, new_value in new_user_values.items(): + response = APIClient().patch( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + ) + assert response.status_code == 401 + assert response.json() == { + "detail": "Authentication credentials were not provided." + } + + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_patch_authenticated_self(): + """ + Authenticated users should be able to patch their own user but only "language" + and "timezone" fields. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = dict( + serializers.UserSerializer(instance=factories.UserFactory()).data + ) + + for key, new_value in new_user_values.items(): + response = client.patch( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + ) + assert response.status_code == 200 + + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + if key in ["language", "timezone"]: + assert value == new_user_values[key] + else: + assert value == old_user_values[key] + + +def test_api_users_patch_authenticated_other(): + """Authenticated users should not be allowed to patch other users.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + user = factories.UserFactory() + old_user_values = dict(serializers.UserSerializer(instance=user).data) + new_user_values = dict( + serializers.UserSerializer(instance=factories.UserFactory()).data + ) + + for key, new_value in new_user_values.items(): + response = client.put( + f"/api/v1.0/users/{user.id!s}/", + {key: new_value}, + format="json", + ) + assert response.status_code == 403 + + user.refresh_from_db() + user_values = dict(serializers.UserSerializer(instance=user).data) + for key, value in user_values.items(): + assert value == old_user_values[key] + + +def test_api_users_delete_list_anonymous(): + """Anonymous users should not be allowed to delete a list of users.""" + factories.UserFactory.create_batch(2) + + client = APIClient() + response = client.delete("/api/v1.0/users/") + + assert response.status_code == 401 + assert models.User.objects.count() == 2 + + +def test_api_users_delete_list_authenticated(): + """Authenticated users should not be allowed to delete a list of users.""" + factories.UserFactory.create_batch(2) + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.delete( + "/api/v1.0/users/", + ) + + assert response.status_code == 405 + assert models.User.objects.count() == 3 + + +def test_api_users_delete_anonymous(): + """Anonymous users should not be allowed to delete a user.""" + user = factories.UserFactory() + + response = APIClient().delete(f"/api/v1.0/users/{user.id!s}/") + + assert response.status_code == 401 + assert models.User.objects.count() == 1 + + +def test_api_users_delete_authenticated(): + """ + Authenticated users should not be allowed to delete a user other than themselves. + """ + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + other_user = factories.UserFactory() + + response = client.delete( + f"/api/v1.0/users/{other_user.id!s}/", + ) + + assert response.status_code == 405 + assert models.User.objects.count() == 2 + + +def test_api_users_delete_self(): + """Authenticated users should not be able to delete their own user.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.delete( + f"/api/v1.0/users/{user.id!s}/", + ) + + assert response.status_code == 405 + assert models.User.objects.count() == 1 diff --git a/src/backend/core/tests/test_models_users.py b/src/backend/core/tests/test_models_users.py new file mode 100644 index 00000000..f3e808c4 --- /dev/null +++ b/src/backend/core/tests/test_models_users.py @@ -0,0 +1,45 @@ +""" +Unit tests for the User model +""" +from unittest import mock + +from django.core.exceptions import ValidationError + +import pytest + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_models_users_str(): + """The str representation should be the email.""" + user = factories.UserFactory() + assert str(user) == user.email + + +def test_models_users_id_unique(): + """The "id" field should be unique.""" + user = factories.UserFactory() + with pytest.raises(ValidationError, match="User with this Id already exists."): + factories.UserFactory(id=user.id) + + +def test_models_users_send_mail_main_existing(): + """The "email_user' method should send mail to the user's email address.""" + user = factories.UserFactory() + + with mock.patch("django.core.mail.send_mail") as mock_send: + user.email_user("my subject", "my message") + + mock_send.assert_called_once_with("my subject", "my message", None, [user.email]) + + +def test_models_users_send_mail_main_missing(): + """The "email_user' method should fail if the user has no email address.""" + user = factories.UserFactory(email=None) + + with pytest.raises(ValueError) as excinfo: + user.email_user("my subject", "my message") + + assert str(excinfo.value) == "User has no email address." diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py new file mode 100644 index 00000000..2fc50280 --- /dev/null +++ b/src/backend/core/urls.py @@ -0,0 +1,24 @@ +"""URL configuration for the core app.""" +from django.conf import settings +from django.urls import include, path + +from rest_framework.routers import DefaultRouter + +from core.api import viewsets +from core.authentication.urls import urlpatterns as oidc_urls + +# - Main endpoints +router = DefaultRouter() +router.register("users", viewsets.UserViewSet, basename="users") + +urlpatterns = [ + path( + f"api/{settings.API_VERSION}/", + include( + [ + *router.urls, + *oidc_urls, + ] + ), + ), +] diff --git a/src/backend/demo/__init__.py b/src/backend/demo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/demo/data/template/code.txt b/src/backend/demo/data/template/code.txt new file mode 100644 index 00000000..5fa0d27d --- /dev/null +++ b/src/backend/demo/data/template/code.txt @@ -0,0 +1,10 @@ + +
+ +
+
+
{{ body }}
+
+
diff --git a/src/backend/demo/data/template/css.txt b/src/backend/demo/data/template/css.txt new file mode 100644 index 00000000..8856136a --- /dev/null +++ b/src/backend/demo/data/template/css.txt @@ -0,0 +1,18 @@ +body { + background: white; + font-family: arial +} +.header { + display: flex; + justify-content: space-between; +} +.header img { + width: 5cm; + margin-left: -0.4cm; +} +.body{ + margin-top: 1.5rem +} +img { + max-width: 100%; +} diff --git a/src/backend/demo/defaults.py b/src/backend/demo/defaults.py new file mode 100644 index 00000000..5c4a4b45 --- /dev/null +++ b/src/backend/demo/defaults.py @@ -0,0 +1,5 @@ +"""Parameters that define how the demo site will be built.""" + +NB_OBJECTS = { + "users": 100, +} diff --git a/src/backend/demo/management/__init__.py b/src/backend/demo/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/demo/management/commands/__init__.py b/src/backend/demo/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/demo/management/commands/create_demo.py b/src/backend/demo/management/commands/create_demo.py new file mode 100644 index 00000000..d1ed5e21 --- /dev/null +++ b/src/backend/demo/management/commands/create_demo.py @@ -0,0 +1,154 @@ +# ruff: noqa: S311, S106 +"""create_demo management command""" + +import logging +import random +import time +from collections import defaultdict + +from django import db +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + +from faker import Faker + +from core import models + +from demo import defaults + +fake = Faker() + +logger = logging.getLogger("impress.commands.demo.create_demo") + + +def random_true_with_probability(probability): + """return True with the requested probability, False otherwise.""" + return random.random() < probability + + +class BulkQueue: + """A utility class to create Django model instances in bulk by just pushing to a queue.""" + + BATCH_SIZE = 20000 + + def __init__(self, stdout, *args, **kwargs): + """Define the queue as a dict of lists.""" + self.queue = defaultdict(list) + self.stdout = stdout + + def _bulk_create(self, objects): + """Actually create instances in bulk in the database.""" + if not objects: + return + + objects[0]._meta.model.objects.bulk_create(objects, ignore_conflicts=False) # noqa: SLF001 + # In debug mode, Django keeps query cache which creates a memory leak in this case + db.reset_queries() + self.queue[objects[0]._meta.model.__name__] = [] # noqa: SLF001 + + def push(self, obj): + """Add a model instance to queue to that it gets created in bulk.""" + objects = self.queue[obj._meta.model.__name__] # noqa: SLF001 + objects.append(obj) + if len(objects) > self.BATCH_SIZE: + self._bulk_create(objects) + self.stdout.write(".", ending="") + + def flush(self): + """Flush the queue after creating the remaining model instances.""" + for objects in self.queue.values(): + self._bulk_create(objects) + + +class Timeit: + """A utility context manager/method decorator to time execution.""" + + total_time = 0 + + def __init__(self, stdout, sentence=None): + """Set the sentence to be displayed for timing information.""" + self.sentence = sentence + self.start = None + self.stdout = stdout + + def __call__(self, func): + """Behavior on call for use as a method decorator.""" + + def timeit_wrapper(*args, **kwargs): + """wrapper to trigger/stop the timer before/after function call.""" + self.__enter__() + result = func(*args, **kwargs) + self.__exit__(None, None, None) + return result + + return timeit_wrapper + + def __enter__(self): + """Start timer upon entering context manager.""" + self.start = time.perf_counter() + if self.sentence: + self.stdout.write(self.sentence, ending=".") + + def __exit__(self, exc_type, exc_value, exc_tb): + """Stop timer and display result upon leaving context manager.""" + if exc_type is not None: + raise exc_type(exc_value) + end = time.perf_counter() + elapsed_time = end - self.start + if self.sentence: + self.stdout.write(f" Took {elapsed_time:g} seconds") + + self.__class__.total_time += elapsed_time + return elapsed_time + + +def create_demo(stdout): + """ + Create a database with demo data for developers to work in a realistic environment. + The code is engineered to create a huge number of objects fast. + """ + + queue = BulkQueue(stdout) + + with Timeit(stdout, "Creating users"): + for i in range(defaults.NB_OBJECTS["users"]): + queue.push( + models.User( + admin_email=f"user{i:d}@example.com", + email=f"user{i:d}@example.com", + password="!", + is_superuser=False, + is_active=True, + is_staff=False, + language=random.choice(settings.LANGUAGES)[0], + ) + ) + queue.flush() + + +class Command(BaseCommand): + """A management command to create a demo database.""" + + help = __doc__ + + def add_arguments(self, parser): + """Add argument to require forcing execution when not in debug mode.""" + parser.add_argument( + "-f", + "--force", + action="store_true", + default=False, + help="Force command execution despite DEBUG is set to False", + ) + + def handle(self, *args, **options): + """Handling of the management command.""" + if not settings.DEBUG and not options["force"]: + raise CommandError( + ( + "This command is not meant to be used in production environment " + "except you know what you are doing, if so use --force parameter" + ) + ) + + create_demo(self.stdout) diff --git a/src/backend/demo/management/commands/createsuperuser.py b/src/backend/demo/management/commands/createsuperuser.py new file mode 100644 index 00000000..6ac7cf5e --- /dev/null +++ b/src/backend/demo/management/commands/createsuperuser.py @@ -0,0 +1,46 @@ +"""Management user to create a superuser.""" +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +UserModel = get_user_model() + + +class Command(BaseCommand): + """Management command to create a superuser from and email and password.""" + + help = "Create a superuser with an email and a password" + + def add_arguments(self, parser): + """Define required arguments "email" and "password".""" + parser.add_argument( + "--email", + help=("Email for the user."), + ) + parser.add_argument( + "--password", + help="Password for the user.", + ) + + def handle(self, *args, **options): + """ + Given an email and a password, create a superuser or upgrade the existing + user to superuser status. + """ + email = options.get("email") + try: + user = UserModel.objects.get(admin_email=email) + except UserModel.DoesNotExist: + user = UserModel(admin_email=email) + message = "Superuser created successfully." + else: + if user.is_superuser and user.is_staff: + message = "Superuser already exists." + else: + message = "User already existed and was upgraded to superuser." + + user.is_superuser = True + user.is_staff = True + user.set_password(options["password"]) + user.save() + + self.stdout.write(self.style.SUCCESS(message)) diff --git a/src/backend/demo/tests/__init__.py b/src/backend/demo/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/demo/tests/test_commands_create_demo.py b/src/backend/demo/tests/test_commands_create_demo.py new file mode 100644 index 00000000..41401fc1 --- /dev/null +++ b/src/backend/demo/tests/test_commands_create_demo.py @@ -0,0 +1,18 @@ +"""Test the `create_demo` management command""" + +from django.core.management import call_command +from django.test import override_settings + +import pytest + +from core import models + +pytestmark = pytest.mark.django_db + + +@override_settings(DEBUG=True) +def test_commands_create_demo(): + """The create_demo management command should create objects as expected.""" + call_command("create_demo") + + assert models.User.objects.count() == 100 diff --git a/src/backend/impress/__init__.py b/src/backend/impress/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/impress/celery_app.py b/src/backend/impress/celery_app.py new file mode 100644 index 00000000..1cf11953 --- /dev/null +++ b/src/backend/impress/celery_app.py @@ -0,0 +1,22 @@ +"""Impress celery configuration file.""" +import os + +from celery import Celery +from configurations.importer import install + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings") +os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + +install(check_options=True) + +app = Celery("impress") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py new file mode 100755 index 00000000..787f4cb6 --- /dev/null +++ b/src/backend/impress/settings.py @@ -0,0 +1,590 @@ +""" +Django settings for impress project. + +Generated by 'django-admin startproject' using Django 3.1.5. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" +import json +import os + +from django.utils.translation import gettext_lazy as _ + +import sentry_sdk +from configurations import Configuration, values +from sentry_sdk.integrations.django import DjangoIntegration + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA_DIR = os.path.join("/", "data") + + +def get_release(): + """ + Get the current release of the application + + By release, we mean the release from the version.json file à la Mozilla [1] + (if any). If this file has not been found, it defaults to "NA". + + [1] + https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md + """ + # Try to get the current release from the version.json file generated by the + # CI during the Docker image build + try: + with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version: + return json.load(version)["version"] + except FileNotFoundError: + return "NA" # Default: not available + + +class Base(Configuration): + """ + This is the base configuration every configuration (aka environment) should inherit from. It + is recommended to configure third-party applications by creating a configuration mixins in + ./configurations and compose the Base configuration with those mixins. + + It depends on an environment variable that SHOULD be defined: + + * DJANGO_SECRET_KEY + + You may also want to override default configuration by setting the following environment + variables: + + * DJANGO_SENTRY_DSN + * DB_NAME + * DB_HOST + * DB_PASSWORD + * DB_USER + """ + + DEBUG = False + USE_SWAGGER = False + + API_VERSION = "v1.0" + + # Security + ALLOWED_HOSTS = values.ListValue([]) + SECRET_KEY = values.Value(None) + + # Application definition + ROOT_URLCONF = "impress.urls" + WSGI_APPLICATION = "impress.wsgi.application" + + # Database + DATABASES = { + "default": { + "ENGINE": values.Value( + "django.db.backends.postgresql_psycopg2", + environ_name="DB_ENGINE", + environ_prefix=None, + ), + "NAME": values.Value( + "impress", environ_name="DB_NAME", environ_prefix=None + ), + "USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None), + "PASSWORD": values.Value( + "pass", environ_name="DB_PASSWORD", environ_prefix=None + ), + "HOST": values.Value( + "localhost", environ_name="DB_HOST", environ_prefix=None + ), + "PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None), + } + } + DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + + # Static files (CSS, JavaScript, Images) + STATIC_URL = "/static/" + STATIC_ROOT = os.path.join(DATA_DIR, "static") + MEDIA_URL = "/media/" + MEDIA_ROOT = os.path.join(DATA_DIR, "media") + + SITE_ID = 1 + + STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + }, + "staticfiles": { + "BACKEND": values.Value( + "whitenoise.storage.CompressedManifestStaticFilesStorage", + environ_name="STORAGES_STATICFILES_BACKEND", + ), + }, + } + + # Media + AWS_S3_ENDPOINT_URL = values.Value( + environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None + ) + AWS_S3_ACCESS_KEY_ID = values.Value( + environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None + ) + AWS_S3_SECRET_ACCESS_KEY = values.Value( + environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None + ) + AWS_S3_REGION_NAME = values.Value( + environ_name="AWS_S3_REGION_NAME", environ_prefix=None + ) + AWS_STORAGE_BUCKET_NAME = values.Value( + "impress-media-storage", + environ_name="AWS_STORAGE_BUCKET_NAME", + environ_prefix=None, + ) + + S3_VERSIONS_PAGE_SIZE = 50 + + # Internationalization + # https://docs.djangoproject.com/en/3.1/topics/i18n/ + + # Languages + LANGUAGE_CODE = values.Value("en-us") + + DRF_NESTED_MULTIPART_PARSER = { + # output of parser is converted to querydict + # if is set to False, dict python is returned + "querydict": False, + } + + # Careful! Languages should be ordered by priority, as this tuple is used to get + # fallback/default languages throughout the app. + LANGUAGES = values.SingleNestedTupleValue( + ( + ("en-us", _("English")), + ("fr-fr", _("French")), + ) + ) + + LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),) + + TIME_ZONE = "UTC" + USE_I18N = True + USE_TZ = True + + # Templates + TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "templates")], + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.csrf", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.request", + "django.template.context_processors.tz", + ], + "loaders": [ + "django.template.loaders.filesystem.Loader", + "django.template.loaders.app_directories.Loader", + ], + }, + }, + ] + + MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "dockerflow.django.middleware.DockerflowMiddleware", + ] + + AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "core.authentication.backends.OIDCAuthenticationBackend", + ] + + # Django applications from the highest priority to the lowest + INSTALLED_APPS = [ + # impress + "core", + "demo", + "drf_spectacular", + # Third party apps + "corsheaders", + "dockerflow.django", + "rest_framework", + "parler", + "easy_thumbnails", + # Django + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.postgres", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + # OIDC third party + "mozilla_django_oidc", + ] + + # Cache + CACHES = { + "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, + } + + REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "mozilla_django_oidc.contrib.drf.OIDCAuthentication", + "rest_framework.authentication.SessionAuthentication", + ), + "DEFAULT_PARSER_CLASSES": [ + "rest_framework.parsers.JSONParser", + "nested_multipart_parser.drf.DrfNestedParser", + ], + "EXCEPTION_HANDLER": "core.api.exception_handler", + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning", + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + } + + SPECTACULAR_SETTINGS = { + "TITLE": "Impress API", + "DESCRIPTION": "This is the impress API schema.", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "ENABLE_DJANGO_DEPLOY_CHECK": values.BooleanValue( + default=False, + environ_name="SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK", + ), + "COMPONENT_SPLIT_REQUEST": True, + # OTHER SETTINGS + "SWAGGER_UI_DIST": "SIDECAR", # shorthand to use the sidecar instead + "SWAGGER_UI_FAVICON_HREF": "SIDECAR", + "REDOC_DIST": "SIDECAR", + } + + # Mail + EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend") + EMAIL_HOST = values.Value(None) + EMAIL_HOST_USER = values.Value(None) + EMAIL_HOST_PASSWORD = values.Value(None) + EMAIL_PORT = values.PositiveIntegerValue(None) + EMAIL_USE_TLS = values.BooleanValue(False) + EMAIL_FROM = values.Value("from@example.com") + + AUTH_USER_MODEL = "core.User" + INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds + + # CORS + CORS_ALLOW_CREDENTIALS = True + CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True) + CORS_ALLOWED_ORIGINS = values.ListValue([]) + CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([]) + + # Sentry + SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN") + + # Easy thumbnails + THUMBNAIL_EXTENSION = "webp" + THUMBNAIL_TRANSPARENCY_EXTENSION = "webp" + THUMBNAIL_ALIASES = {} + + # Celery + CELERY_BROKER_URL = values.Value("redis://redis:6379/0") + CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}) + + # Session + SESSION_ENGINE = "django.contrib.sessions.backends.cache" + SESSION_CACHE_ALIAS = "default" + SESSION_COOKIE_AGE = 60 * 60 * 12 + + # OIDC - Authorization Code Flow + OIDC_CREATE_USER = values.BooleanValue( + default=True, + environ_name="OIDC_CREATE_USER", + ) + OIDC_RP_SIGN_ALGO = values.Value( + "RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None + ) + OIDC_RP_CLIENT_ID = values.Value( + "impress", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None + ) + OIDC_RP_CLIENT_SECRET = values.Value( + None, + environ_name="OIDC_RP_CLIENT_SECRET", + environ_prefix=None, + ) + OIDC_OP_JWKS_ENDPOINT = values.Value( + environ_name="OIDC_OP_JWKS_ENDPOINT", environ_prefix=None + ) + OIDC_OP_AUTHORIZATION_ENDPOINT = values.Value( + environ_name="OIDC_OP_AUTHORIZATION_ENDPOINT", environ_prefix=None + ) + OIDC_OP_TOKEN_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_TOKEN_ENDPOINT", environ_prefix=None + ) + OIDC_OP_USER_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None + ) + OIDC_OP_LOGOUT_ENDPOINT = values.Value( + None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None + ) + OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue( + {}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None + ) + OIDC_RP_SCOPES = values.Value( + "openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None + ) + LOGIN_REDIRECT_URL = values.Value( + None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None + ) + LOGIN_REDIRECT_URL_FAILURE = values.Value( + None, environ_name="LOGIN_REDIRECT_URL_FAILURE", environ_prefix=None + ) + LOGOUT_REDIRECT_URL = values.Value( + None, environ_name="LOGOUT_REDIRECT_URL", environ_prefix=None + ) + OIDC_USE_NONCE = values.BooleanValue( + default=True, environ_name="OIDC_USE_NONCE", environ_prefix=None + ) + OIDC_REDIRECT_REQUIRE_HTTPS = values.BooleanValue( + default=False, environ_name="OIDC_REDIRECT_REQUIRE_HTTPS", environ_prefix=None + ) + OIDC_REDIRECT_ALLOWED_HOSTS = values.ListValue( + default=[], environ_name="OIDC_REDIRECT_ALLOWED_HOSTS", environ_prefix=None + ) + OIDC_STORE_ID_TOKEN = values.BooleanValue( + default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None + ) + ALLOW_LOGOUT_GET_METHOD = values.BooleanValue( + default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None + ) + + # pylint: disable=invalid-name + @property + def ENVIRONMENT(self): + """Environment in which the application is launched.""" + return self.__class__.__name__.lower() + + # pylint: disable=invalid-name + @property + def RELEASE(self): + """ + Return the release information. + + Delegate to the module function to enable easier testing. + """ + return get_release() + + # pylint: disable=invalid-name + @property + def PARLER_LANGUAGES(self): + """ + Return languages for Parler computed from the LANGUAGES and LANGUAGE_CODE settings. + """ + return { + self.SITE_ID: tuple({"code": code} for code, _name in self.LANGUAGES), + "default": { + "fallbacks": [self.LANGUAGE_CODE], + "hide_untranslated": False, + }, + } + + @classmethod + def post_setup(cls): + """Post setup configuration. + This is the place where you can configure settings that require other + settings to be loaded. + """ + super().post_setup() + + # The SENTRY_DSN setting should be available to activate sentry for an environment + if cls.SENTRY_DSN is not None: + sentry_sdk.init( + dsn=cls.SENTRY_DSN, + environment=cls.__name__.lower(), + release=get_release(), + integrations=[DjangoIntegration()], + ) + with sentry_sdk.configure_scope() as scope: + scope.set_extra("application", "backend") + + +class Build(Base): + """Settings used when the application is built. + + This environment should not be used to run the application. Just to build it with non-blocking + settings. + """ + + SECRET_KEY = values.Value("DummyKey") + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": values.Value( + "whitenoise.storage.CompressedManifestStaticFilesStorage", + environ_name="STORAGES_STATICFILES_BACKEND", + ), + }, + } + + +class Development(Base): + """ + Development environment settings + + We set DEBUG to True and configure the server to respond from all hosts. + """ + + ALLOWED_HOSTS = ["*"] + CORS_ALLOW_ALL_ORIGINS = True + CSRF_TRUSTED_ORIGINS = ["http://localhost:8072", "http://localhost:3000"] + DEBUG = True + + SESSION_COOKIE_NAME = "impress_sessionid" + + USE_SWAGGER = True + + def __init__(self): + # pylint: disable=invalid-name + self.INSTALLED_APPS += ["django_extensions", "drf_spectacular_sidecar"] + + +class Test(Base): + """Test environment settings""" + + LOGGING = values.DictValue( + { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "loggers": { + "impress": { + "handlers": ["console"], + "level": "DEBUG", + }, + }, + } + ) + PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", + ] + USE_SWAGGER = True + + CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True) + + def __init__(self): + # pylint: disable=invalid-name + self.INSTALLED_APPS += ["drf_spectacular_sidecar"] + + +class ContinuousIntegration(Test): + """ + Continuous Integration environment settings + + nota bene: it should inherit from the Test environment. + """ + + +class Production(Base): + """ + Production environment settings + + You must define the ALLOWED_HOSTS environment variable in Production + configuration (and derived configurations): + ALLOWED_HOSTS=["foo.com", "foo.fr"] + """ + + # Security + ALLOWED_HOSTS = values.ListValue(None) + CSRF_TRUSTED_ORIGINS = values.ListValue([]) + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + + # SECURE_PROXY_SSL_HEADER allows to fix the scheme in Django's HttpRequest + # object when your application is behind a reverse proxy. + # + # Keep this SECURE_PROXY_SSL_HEADER configuration only if : + # - your Django app is behind a proxy. + # - your proxy strips the X-Forwarded-Proto header from all incoming requests + # - Your proxy sets the X-Forwarded-Proto header and sends it to Django + # + # In other cases, you should comment the following line to avoid security issues. + # SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + + # Modern browsers require to have the `secure` attribute on cookies with `Samesite=none` + CSRF_COOKIE_SECURE = True + SESSION_COOKIE_SECURE = True + + # Privacy + SECURE_REFERRER_POLICY = "same-origin" + + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": values.Value( + "redis://redis:6379/1", + environ_name="REDIS_URL", + environ_prefix=None, + ), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + }, + } + + +class Feature(Production): + """ + Feature environment settings + + nota bene: it should inherit from the Production environment. + """ + + +class Staging(Production): + """ + Staging environment settings + + nota bene: it should inherit from the Production environment. + """ + + +class PreProduction(Production): + """ + Pre-production environment settings + + nota bene: it should inherit from the Production environment. + """ + + +class Demo(Production): + """ + Demonstration environment settings + + nota bene: it should inherit from the Production environment. + """ + + STORAGES = { + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, + } diff --git a/src/backend/impress/urls.py b/src/backend/impress/urls.py new file mode 100644 index 00000000..5dc490ac --- /dev/null +++ b/src/backend/impress/urls.py @@ -0,0 +1,48 @@ +"""URL configuration for the impress project""" + +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.urls import include, path, re_path + +from drf_spectacular.views import ( + SpectacularJSONAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("core.urls")), +] + +if settings.DEBUG: + urlpatterns = ( + urlpatterns + + staticfiles_urlpatterns() + + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + ) + + +if settings.USE_SWAGGER or settings.DEBUG: + urlpatterns += [ + path( + f"{settings.API_VERSION}/swagger.json", + SpectacularJSONAPIView.as_view( + api_version=settings.API_VERSION, + urlconf="core.urls", + ), + name="client-api-schema", + ), + path( + f"{settings.API_VERSION}//swagger/", + SpectacularSwaggerView.as_view(url_name="client-api-schema"), + name="swagger-ui-schema", + ), + re_path( + f"{settings.API_VERSION}//redoc/", + SpectacularRedocView.as_view(url_name="client-api-schema"), + name="redoc-schema", + ), + ] diff --git a/src/backend/impress/wsgi.py b/src/backend/impress/wsgi.py new file mode 100644 index 00000000..6076021c --- /dev/null +++ b/src/backend/impress/wsgi.py @@ -0,0 +1,17 @@ +""" +WSGI config for the impress project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ +""" + +import os + +from configurations.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings") +os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + +application = get_wsgi_application() diff --git a/src/backend/locale/en_US/LC_MESSAGES/django.po b/src/backend/locale/en_US/LC_MESSAGES/django.po new file mode 100644 index 00000000..1f30e5f9 --- /dev/null +++ b/src/backend/locale/en_US/LC_MESSAGES/django.po @@ -0,0 +1,208 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-04-03 10:31+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: core/admin.py:31 +msgid "Personal info" +msgstr "" + +#: core/admin.py:33 +msgid "Permissions" +msgstr "" + +#: core/admin.py:45 +msgid "Important dates" +msgstr "" + +#: core/api/serializers.py:128 +msgid "Markdown Body" +msgstr "" + +#: core/authentication.py:71 +msgid "User info contained no recognizable user identification" +msgstr "" + +#: core/authentication.py:91 +msgid "Claims contained no recognizable user identification" +msgstr "" + +#: core/models.py:27 +msgid "Member" +msgstr "" + +#: core/models.py:28 +msgid "Administrator" +msgstr "" + +#: core/models.py:29 +msgid "Owner" +msgstr "" + +#: core/models.py:41 +msgid "id" +msgstr "" + +#: core/models.py:42 +msgid "primary key for the record as UUID" +msgstr "" + +#: core/models.py:48 +msgid "created on" +msgstr "" + +#: core/models.py:49 +msgid "date and time at which a record was created" +msgstr "" + +#: core/models.py:54 +msgid "updated on" +msgstr "" + +#: core/models.py:55 +msgid "date and time at which a record was last updated" +msgstr "" + +#: core/models.py:75 +msgid "" +"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" +"_ characters." +msgstr "" + +#: core/models.py:81 +msgid "sub" +msgstr "" + +#: core/models.py:83 +msgid "" +"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ " +"characters only." +msgstr "" + +#: core/models.py:91 +msgid "identity email address" +msgstr "" + +#: core/models.py:96 +msgid "admin email address" +msgstr "" + +#: core/models.py:103 +msgid "language" +msgstr "" + +#: core/models.py:104 +msgid "The language in which the user wants to see the interface." +msgstr "" + +#: core/models.py:110 +msgid "The timezone in which the user wants to see times." +msgstr "" + +#: core/models.py:113 +msgid "device" +msgstr "" + +#: core/models.py:115 +msgid "Whether the user is a device or a real user." +msgstr "" + +#: core/models.py:118 +msgid "staff status" +msgstr "" + +#: core/models.py:120 +msgid "Whether the user can log into this admin site." +msgstr "" + +#: core/models.py:123 +msgid "active" +msgstr "" + +#: core/models.py:126 +msgid "" +"Whether this user should be treated as active. Unselect this instead of " +"deleting accounts." +msgstr "" + +#: core/models.py:138 +msgid "user" +msgstr "" + +#: core/models.py:139 +msgid "users" +msgstr "" + +#: core/models.py:161 +msgid "title" +msgstr "" + +#: core/models.py:162 +msgid "description" +msgstr "" + +#: core/models.py:163 +msgid "code" +msgstr "" + +#: core/models.py:164 +msgid "css" +msgstr "" + +#: core/models.py:166 +msgid "public" +msgstr "" + +#: core/models.py:168 +msgid "Whether this template is public for anyone to use." +msgstr "" + +#: core/models.py:174 +msgid "Template" +msgstr "" + +#: core/models.py:175 +msgid "Templates" +msgstr "" + +#: core/models.py:256 +msgid "Template/user relation" +msgstr "" + +#: core/models.py:257 +msgid "Template/user relations" +msgstr "" + +#: core/models.py:263 +msgid "This user is already in this template." +msgstr "" + +#: core/models.py:269 +msgid "This team is already in this template." +msgstr "" + +#: core/models.py:275 +msgid "Either user or team must be set, not both." +msgstr "" + +#: impress/settings.py:134 +msgid "English" +msgstr "" + +#: impress/settings.py:135 +msgid "French" +msgstr "" diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.po b/src/backend/locale/fr_FR/LC_MESSAGES/django.po new file mode 100644 index 00000000..1f30e5f9 --- /dev/null +++ b/src/backend/locale/fr_FR/LC_MESSAGES/django.po @@ -0,0 +1,208 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-04-03 10:31+0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: core/admin.py:31 +msgid "Personal info" +msgstr "" + +#: core/admin.py:33 +msgid "Permissions" +msgstr "" + +#: core/admin.py:45 +msgid "Important dates" +msgstr "" + +#: core/api/serializers.py:128 +msgid "Markdown Body" +msgstr "" + +#: core/authentication.py:71 +msgid "User info contained no recognizable user identification" +msgstr "" + +#: core/authentication.py:91 +msgid "Claims contained no recognizable user identification" +msgstr "" + +#: core/models.py:27 +msgid "Member" +msgstr "" + +#: core/models.py:28 +msgid "Administrator" +msgstr "" + +#: core/models.py:29 +msgid "Owner" +msgstr "" + +#: core/models.py:41 +msgid "id" +msgstr "" + +#: core/models.py:42 +msgid "primary key for the record as UUID" +msgstr "" + +#: core/models.py:48 +msgid "created on" +msgstr "" + +#: core/models.py:49 +msgid "date and time at which a record was created" +msgstr "" + +#: core/models.py:54 +msgid "updated on" +msgstr "" + +#: core/models.py:55 +msgid "date and time at which a record was last updated" +msgstr "" + +#: core/models.py:75 +msgid "" +"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/" +"_ characters." +msgstr "" + +#: core/models.py:81 +msgid "sub" +msgstr "" + +#: core/models.py:83 +msgid "" +"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ " +"characters only." +msgstr "" + +#: core/models.py:91 +msgid "identity email address" +msgstr "" + +#: core/models.py:96 +msgid "admin email address" +msgstr "" + +#: core/models.py:103 +msgid "language" +msgstr "" + +#: core/models.py:104 +msgid "The language in which the user wants to see the interface." +msgstr "" + +#: core/models.py:110 +msgid "The timezone in which the user wants to see times." +msgstr "" + +#: core/models.py:113 +msgid "device" +msgstr "" + +#: core/models.py:115 +msgid "Whether the user is a device or a real user." +msgstr "" + +#: core/models.py:118 +msgid "staff status" +msgstr "" + +#: core/models.py:120 +msgid "Whether the user can log into this admin site." +msgstr "" + +#: core/models.py:123 +msgid "active" +msgstr "" + +#: core/models.py:126 +msgid "" +"Whether this user should be treated as active. Unselect this instead of " +"deleting accounts." +msgstr "" + +#: core/models.py:138 +msgid "user" +msgstr "" + +#: core/models.py:139 +msgid "users" +msgstr "" + +#: core/models.py:161 +msgid "title" +msgstr "" + +#: core/models.py:162 +msgid "description" +msgstr "" + +#: core/models.py:163 +msgid "code" +msgstr "" + +#: core/models.py:164 +msgid "css" +msgstr "" + +#: core/models.py:166 +msgid "public" +msgstr "" + +#: core/models.py:168 +msgid "Whether this template is public for anyone to use." +msgstr "" + +#: core/models.py:174 +msgid "Template" +msgstr "" + +#: core/models.py:175 +msgid "Templates" +msgstr "" + +#: core/models.py:256 +msgid "Template/user relation" +msgstr "" + +#: core/models.py:257 +msgid "Template/user relations" +msgstr "" + +#: core/models.py:263 +msgid "This user is already in this template." +msgstr "" + +#: core/models.py:269 +msgid "This team is already in this template." +msgstr "" + +#: core/models.py:275 +msgid "Either user or team must be set, not both." +msgstr "" + +#: impress/settings.py:134 +msgid "English" +msgstr "" + +#: impress/settings.py:135 +msgid "French" +msgstr "" diff --git a/src/backend/manage.py b/src/backend/manage.py new file mode 100644 index 00000000..78b98ee5 --- /dev/null +++ b/src/backend/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +""" +impress's sandbox management script. +""" +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "impress.settings") + os.environ.setdefault("DJANGO_CONFIGURATION", "Development") + + from configurations.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml new file mode 100644 index 00000000..a5a87517 --- /dev/null +++ b/src/backend/pyproject.toml @@ -0,0 +1,142 @@ +# +# impress package +# +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "impress" +version = "0.1.0" +authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Framework :: Django", + "Framework :: Django :: 5", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +description = "An application to print markdown to pdf from a set of managed templates." +keywords = ["Django", "Contacts", "Templates", "RBAC"] +license = { file = "LICENSE" } +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "boto3==1.33.6", + "Brotli==1.1.0", + "celery[redis]==5.3.6", + "django-configurations==2.5", + "django-cors-headers==4.3.1", + "django-countries==7.5.1", + "django-parler==2.3", + "redis==5.0.3", + "django-redis==5.4.0", + "django-storages[s3]==1.14.2", + "django-timezone-field>=5.1", + "django==5.0.3", + "djangorestframework==3.14.0", + "drf_spectacular==0.26.5", + "dockerflow==2022.8.0", + "easy_thumbnails==2.8.5", + "factory_boy==3.3.0", + "freezegun==1.5.0", + "gunicorn==22.0.0", + "jsonschema==4.20.0", + "markdown==3.5.1", + "nested-multipart-parser==1.5.0", + "psycopg[binary]==3.1.14", + "PyJWT==2.8.0", + "python-frontmatter==1.0.1", + "requests==2.31.0", + "sentry-sdk==1.38.0", + "url-normalize==1.4.3", + "WeasyPrint>=60.2", + "whitenoise==6.6.0", + "mozilla-django-oidc==4.0.0", +] + +[project.urls] +"Bug Tracker" = "https://github.com/numerique-gouv/impress/issues/new" +"Changelog" = "https://github.com/numerique-gouv/impress/blob/main/CHANGELOG.md" +"Homepage" = "https://github.com/numerique-gouv/impress" +"Repository" = "https://github.com/numerique-gouv/impress" + +[project.optional-dependencies] +dev = [ + "django-extensions==3.2.3", + "drf-spectacular-sidecar==2023.12.1", + "ipdb==0.13.13", + "ipython==8.18.1", + "pyfakefs==5.3.2", + "pylint-django==2.5.5", + "pylint==3.0.3", + "pytest-cov==4.1.0", + "pytest-django==4.7.0", + "pytest==7.4.3", + "pytest-icdiff==0.8", + "pytest-xdist==3.5.0", + "responses==0.24.1", + "ruff==0.1.6", + "types-requests==2.31.0.10", +] + +[tool.setuptools] +packages = { find = { where = ["."], exclude = ["tests"] } } +zip-safe = true + +[tool.distutils.bdist_wheel] +universal = true + +[tool.ruff] +exclude = [ + ".git", + ".venv", + "build", + "venv", + "__pycache__", + "*/migrations/*", +] +ignore= ["DJ001", "PLR2004"] +line-length = 88 + + +[tool.ruff.lint] +select = [ + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "DJ", # flake8-django + "I", # isort + "PLC", # pylint-convention + "PLE", # pylint-error + "PLR", # pylint-refactoring + "PLW", # pylint-warning + "RUF100", # Ruff unused-noqa + "RUF200", # Ruff check pyproject.toml + "S", # flake8-bandit + "SLF", # flake8-self + "T20", # flake8-print +] + +[tool.ruff.lint.isort] +section-order = ["future","standard-library","django","third-party","impress","first-party","local-folder"] +sections = { impress=["core"], django=["django"] } + +[tool.ruff.per-file-ignores] +"**/tests/*" = ["S", "SLF"] + +[tool.pytest.ini_options] +addopts = [ + "-v", + "--cov-report", + "term-missing", + # Allow test files to have the same name in different directories. + "--import-mode=importlib", +] +python_files = [ + "test_*.py", + "tests.py", +] diff --git a/src/backend/setup.py b/src/backend/setup.py new file mode 100644 index 00000000..8dcbc647 --- /dev/null +++ b/src/backend/setup.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +"""Setup file for the impress module. All configuration stands in the setup.cfg file.""" +# coding: utf-8 + +from setuptools import setup + +setup() diff --git a/src/helm/env.d/dev/secrets.enc.yaml b/src/helm/env.d/dev/secrets.enc.yaml new file mode 100644 index 00000000..b2a410eb --- /dev/null +++ b/src/helm/env.d/dev/secrets.enc.yaml @@ -0,0 +1,60 @@ +djangoSecretKey: ENC[AES256_GCM,data:2b4nHO2i/HtaNJYi1d8xJyhCpK1qV7fHD45T6VarWpNg1HkcJgC7zTgHMEvfedRd2tE=,iv:qcHlXG/mNr3CFtZhjbw3AVRbMxkGZaAZPtHtS8ksO58=,tag:mTC6mc5JKqpEQ/9ubggKmA==,type:str] +oidc: + clientId: ENC[AES256_GCM,data:gcwhXfL4iNwWWleR/l3p2aRSp9nsdLhQtUMlglLqJSdDy6iu,iv:WxK7BBQrVa115dsHEiMC7NyvlQXuhLiZzHYSuhZYy4w=,tag:RYwutm8QB+mIl7b+AYvqxg==,type:str] + clientSecret: ENC[AES256_GCM,data:9rU6HWRiX+6afLf4fGyIRyiv/pyihbCbO9DA2L4HOz/RAMaO9iZWW1QqIK8JCBuGh/XP1I3sd0mlbiXxCv1X3w==,iv:0NgcQtCVjIWhfzQbBx2Hh7NxumF3xW8nNuReUkvdk58=,tag:rkMAJ8Ilk8Pusw3PAyW/6A==,type:str] +sops: + kms: [] + gcp_kms: [] + azure_kv: [] + hc_vault: [] + age: + - recipient: age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiem9OZTZYQnV5UVpzaHN0 + MlpGL2xaMTVldkVPY2Jub3IxU2FhcVBNYWxvCk1qbHJFa2ZVdmp4Yy9COGFPNzlL + amh5S21qbm1jTlgxZjBZMk5BTllNZlUKLS0tIGM5aTJrbnRSdXZPWVF3RVR2dlRD + NThRV1hpb0k5RElvRlYySTZyMXp3dGsK92FrBnrHAIRcGooyJviJSUA+eHiwvVkm + b1T9jk9bmoipV/8WkXbGyk0TZKYuB4pvPE88eNLrYeotTiRu9tJUNw== + -----END AGE ENCRYPTED FILE----- + - recipient: age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxQUhLb2Z4N3ptTjBHZ3N3 + cVBNMDJFS09wck9LcytJR1h4WCtlblZpYkJnCmdBN1laOGdiN1lKbUFBOTdLTUM1 + NHFLZm51M0dLakIxcG1ncnFrb3dCeXMKLS0tIDdWUmlkYy9PSWhoYkRPNXc4aDNa + TWxUMUlqUHhNL3NZL0R2WE9ySU5wcTAKMzwEzXiGSGr4BJNZ78mo68V1Jq4ydOWl + dlSkEe+zv2jYYmLxirBDbLN+dwUwyAA8/eYYidvuMvHw1sfT14GyRw== + -----END AGE ENCRYPTED FILE----- + - recipient: age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiZFRjVmtiVXpONk4xZktB + TXo5OE1Jam1qREdPTjJSanUvd3R3dWI5SDA0CjZqZDNxZklNZXhvOExSaGlzOW85 + OFYxMzhYMTFDUStpYTdLdEFEdUU3ZW8KLS0tIDVkYmVQMTcvbFhFa0xPb2h6TlFW + TmJUY2hncjg4TkhxOWRxazh5cXQyWHcKgDbgGfl1WQiT6tIG/pmikYUYIF0l4kj7 + ZxlgL+Vn9y3fl5B2LGn/fXfi9B/exgLMCR/GRm3vF4OpPqLYbL0rzw== + -----END AGE ENCRYPTED FILE----- + - recipient: age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWanllM2FDOStFNWVhR1A3 + MVJMRDFCTHY3ZlF3MHg3MGxOWGRtSko0MVd3CmFheUllSkN4VTF5WmZubU1BeWtp + em1tL3dwWGszYmVYSUlwVVZDR3BIK0UKLS0tIDQrWEtuZGVSM3JwM0xYc2N2alpG + eEtzN3Y3UVZkQVlBd0dUWmdVdStSUmcKNQZ0uj0Sj3e7Q9PKsZi4CcS5LEWlD9tL + nOaoMiN1AA307uvePKgFAuChQ5VsAGMcegLJ5M8w516/+yO42yexUw== + -----END AGE ENCRYPTED FILE----- + - recipient: age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAxa0F4VW5oRGFYSVpPOTVW + QjEvL2czQkRwK0tWOStxYkJRaUlHUjlSWWswClE4TW9tLy9oQXZQSVc3R3cwTGU1 + ZGh4UTUzR0FKY0NmMFFaaTFKakVNNlkKLS0tIFRvZ0V5emV6cjBqNlZxOEpwVy8y + N0ZkVmNzTzhhRTA5TDMxc2tGN3BFemMKlyPtb7gfYREoPaU3ZlpynCuqxo4KW0b9 + G+3aGz7SKZ7pcuAaWuuMdyA6XzwS/HOe2L2cW3P5x/0k0JQd2Ie8jA== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2024-04-11T14:59:54Z" + mac: ENC[AES256_GCM,data:Pv37FsNCpk5Ckx3a+j+daPB6f34X5kIko/AZIQkgfRXs3SRJtAdp5VuwYTtwcp/s3Hxi6ZZPLZ+YRh6OqN5g3GaOBR4z2Ohv0ioB/5FLMICOt7VM/zroyXWIjWwpRPsRwjesba7nr9CqbQNDYt8ko4O9kR4w6y2JHbzLeOkohHc=,iv:+/B4m+c03e9iQMrijg7hJhDwQJZP55Bhnsr0n00Y2Cw=,tag:vXVZVbU+R1FpNVUSgnFA9A==,type:str] + pgp: [] + unencrypted_suffix: _unencrypted + version: 3.8.1 \ No newline at end of file diff --git a/src/helm/env.d/dev/values.impress.yaml.gotmpl b/src/helm/env.d/dev/values.impress.yaml.gotmpl new file mode 100644 index 00000000..8f8768b8 --- /dev/null +++ b/src/helm/env.d/dev/values.impress.yaml.gotmpl @@ -0,0 +1,105 @@ +image: + repository: localhost:5001/impress-backend + pullPolicy: Always + tag: "latest" + +backend: + replicas: 1 + envVars: + DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io,http://impress.127.0.0.1.nip.io + DJANGO_CONFIGURATION: Production + DJANGO_ALLOWED_HOSTS: "*" + DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }} + DJANGO_SETTINGS_MODULE: impress.settings + DJANGO_SUPERUSER_PASSWORD: admin + DJANGO_EMAIL_HOST: "mailcatcher" + DJANGO_EMAIL_PORT: 1025 + DJANGO_EMAIL_USE_SSL: False + OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks + OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize + OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token + OIDC_OP_USER_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/userinfo + OIDC_OP_LOGOUT_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/session/end + OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }} + OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }} + OIDC_RP_SIGN_ALGO: RS256 + OIDC_RP_SCOPES: "openid email" + OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io + OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" + LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io + LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io + LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io + DB_HOST: postgres-postgresql + DB_NAME: impress + DB_USER: dinum + DB_PASSWORD: pass + DB_PORT: 5432 + POSTGRES_DB: impress + POSTGRES_USER: dinum + POSTGRES_PASSWORD: pass + REDIS_URL: redis://default:pass@redis-master:6379/1 + AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000 + AWS_S3_ACCESS_KEY_ID: impress + AWS_S3_SECRET_ACCESS_KEY: password + AWS_STORAGE_BUCKET_NAME: impress-media-storage + STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + + migrate: + command: + - "/bin/sh" + - "-c" + - | + python manage.py migrate --no-input && + python manage.py create_demo --force + restartPolicy: Never + + command: + - "gunicorn" + - "-c" + - "/usr/local/etc/gunicorn/impress.py" + - "impress.wsgi:application" + - "--reload" + + createsuperuser: + command: + - "/bin/sh" + - "-c" + - | + python manage.py createsuperuser --email admin@example.com --password admin + restartPolicy: Never + +frontend: + envVars: + PORT: 8080 + NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io + NEXT_PUBLIC_SIGNALING_URL: wss://impress.127.0.0.1.nip.io/ws + + replicas: 1 + command: + - yarn + - dev + + image: + repository: localhost:5001/impress-frontend + pullPolicy: Always + tag: "latest" + +webrtc: + replicas: 1 + + image: + repository: localhost:5001/impress-y-webrtc-signaling + pullPolicy: Always + tag: "latest" + +ingress: + enabled: true + host: impress.127.0.0.1.nip.io + +ingressWS: + enabled: true + host: impress.127.0.0.1.nip.io + +ingressAdmin: + enabled: true + host: impress.127.0.0.1.nip.io diff --git a/src/helm/env.d/preprod/secrets.enc.yaml b/src/helm/env.d/preprod/secrets.enc.yaml new file mode 120000 index 00000000..25e608e4 --- /dev/null +++ b/src/helm/env.d/preprod/secrets.enc.yaml @@ -0,0 +1 @@ +../../../../secrets/numerique-gouv/impress/env/preprod/secrets.enc.yaml \ No newline at end of file diff --git a/src/helm/env.d/preprod/values.impress.yaml.gotmpl b/src/helm/env.d/preprod/values.impress.yaml.gotmpl new file mode 100644 index 00000000..2cc51acc --- /dev/null +++ b/src/helm/env.d/preprod/values.impress.yaml.gotmpl @@ -0,0 +1,147 @@ +image: + repository: lasuite/impress-backend + pullPolicy: Always + tag: "v0.1.0" + +backend: + migrateJobAnnotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook-delete-policy: HookSucceeded + envVars: + DJANGO_CSRF_TRUSTED_ORIGINS: http://impress-preprod.beta.numerique.gouv.fr,https://impress-preprod.beta.numerique.gouv.fr + DJANGO_CONFIGURATION: Production + DJANGO_ALLOWED_HOSTS: "*" + DJANGO_SUPERUSER_EMAIL: + secretKeyRef: + name: backend + key: DJANGO_SUPERUSER_EMAIL + DJANGO_SECRET_KEY: + secretKeyRef: + name: backend + key: DJANGO_SECRET_KEY + DJANGO_SETTINGS_MODULE: impress.settings + DJANGO_SUPERUSER_PASSWORD: + secretKeyRef: + name: backend + key: DJANGO_SUPERUSER_PASSWORD + DJANGO_EMAIL_HOST: "snap-mail.numerique.gouv.fr" + DJANGO_EMAIL_PORT: 465 + DJANGO_EMAIL_USE_SSL: True + DJANGO_SILENCED_SYSTEM_CHECKS: security.W008,security.W004 + OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks + OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize + OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token + OIDC_OP_USER_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/userinfo + OIDC_OP_LOGOUT_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/session/end + OIDC_RP_CLIENT_ID: + secretKeyRef: + name: backend + key: OIDC_RP_CLIENT_ID + OIDC_RP_CLIENT_SECRET: + secretKeyRef: + name: backend + key: OIDC_RP_CLIENT_SECRET + OIDC_RP_SIGN_ALGO: RS256 + OIDC_RP_SCOPES: "openid email" + OIDC_REDIRECT_ALLOWED_HOSTS: https://impress-preprod.beta.numerique.gouv.fr + OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" + LOGIN_REDIRECT_URL: https://impress-preprod.beta.numerique.gouv.fr + LOGIN_REDIRECT_URL_FAILURE: https://impress-preprod.beta.numerique.gouv.fr + LOGOUT_REDIRECT_URL: https://impress-preprod.beta.numerique.gouv.fr + DB_HOST: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: host + DB_NAME: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: database + DB_USER: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: username + DB_PASSWORD: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: password + DB_PORT: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: port + POSTGRES_USER: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: username + POSTGRES_DB: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: database + POSTGRES_PASSWORD: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: password + REDIS_URL: + secretKeyRef: + name: redis.redis.libre.sh + key: url + AWS_S3_ENDPOINT_URL: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: url + AWS_S3_ACCESS_KEY_ID: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: accessKey + AWS_S3_SECRET_ACCESS_KEY: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: secretKey + AWS_STORAGE_BUCKET_NAME: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: bucket + AWS_S3_REGION_NAME: local + STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + + createsuperuser: + command: + - "/bin/sh" + - "-c" + - | + python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD + restartPolicy: Never + +frontend: + image: + repository: lasuite/impress-frontend + pullPolicy: Always + tag: "v0.1.0" + +webrtc: + image: + repository: lasuite/impress-y-webrtc-signaling + pullPolicy: Always + tag: "v0.1.0" + +ingress: + enabled: true + host: impress-preprod.beta.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + +ingressWS: + enabled: true + host: impress-preprod.beta.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + +ingressAdmin: + enabled: true + host: impress-preprod.beta.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/start + nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/auth diff --git a/src/helm/env.d/production/secrets.enc.yaml b/src/helm/env.d/production/secrets.enc.yaml new file mode 120000 index 00000000..1efef50d --- /dev/null +++ b/src/helm/env.d/production/secrets.enc.yaml @@ -0,0 +1 @@ +../../../../secrets/numerique-gouv/impress/env/production/secrets.enc.yaml \ No newline at end of file diff --git a/src/helm/env.d/production/values.impress.yaml.gotmpl b/src/helm/env.d/production/values.impress.yaml.gotmpl new file mode 100644 index 00000000..54aa1e3b --- /dev/null +++ b/src/helm/env.d/production/values.impress.yaml.gotmpl @@ -0,0 +1,147 @@ +image: + repository: lasuite/impress-backend + pullPolicy: Always + tag: "v0.1.0" + +backend: + migrateJobAnnotations: + argocd.argoproj.io/hook: PostSync + argocd.argoproj.io/hook-delete-policy: HookSucceeded + envVars: + DJANGO_CSRF_TRUSTED_ORIGINS: https://docs.numerique.gouv.fr + DJANGO_CONFIGURATION: Production + DJANGO_ALLOWED_HOSTS: "*" + DJANGO_SECRET_KEY: + secretKeyRef: + name: backend + key: DJANGO_SECRET_KEY + DJANGO_SETTINGS_MODULE: impress.settings + DJANGO_SUPERUSER_EMAIL: + secretKeyRef: + name: backend + key: DJANGO_SUPERUSER_EMAIL + DJANGO_SUPERUSER_PASSWORD: + secretKeyRef: + name: backend + key: DJANGO_SUPERUSER_PASSWORD + DJANGO_EMAIL_HOST: "snap-mail.numerique.gouv.fr" + DJANGO_EMAIL_PORT: 465 + DJANGO_EMAIL_USE_SSL: True + DJANGO_SILENCED_SYSTEM_CHECKS: security.W008,security.W004 + OIDC_OP_JWKS_ENDPOINT: https://auth.agentconnect.gouv.fr/api/v2/jwks + OIDC_OP_AUTHORIZATION_ENDPOINT: https://auth.agentconnect.gouv.fr/api/v2/authorize + OIDC_OP_TOKEN_ENDPOINT: https://auth.agentconnect.gouv.fr/api/v2/token + OIDC_OP_USER_ENDPOINT: https://auth.agentconnect.gouv.fr/api/v2/userinfo + OIDC_OP_LOGOUT_ENDPOINT: https://auth.agentconnect.gouv.fr/api/v2/session/end + OIDC_RP_CLIENT_ID: + secretKeyRef: + name: backend + key: OIDC_RP_CLIENT_ID + OIDC_RP_CLIENT_SECRET: + secretKeyRef: + name: backend + key: OIDC_RP_CLIENT_SECRET + OIDC_RP_SIGN_ALGO: RS256 + OIDC_RP_SCOPES: "openid email" + OIDC_REDIRECT_ALLOWED_HOSTS: https://docs.numerique.gouv.fr + OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" + LOGIN_REDIRECT_URL: https://docs.numerique.gouv.fr + LOGIN_REDIRECT_URL_FAILURE: https://docs.numerique.gouv.fr + LOGOUT_REDIRECT_URL: https://docs.numerique.gouv.fr + DB_HOST: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: host + DB_NAME: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: database + DB_USER: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: username + DB_PASSWORD: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: password + DB_PORT: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: port + POSTGRES_USER: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: username + POSTGRES_DB: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: database + POSTGRES_PASSWORD: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: password + REDIS_URL: + secretKeyRef: + name: redis.redis.libre.sh + key: url + AWS_S3_ENDPOINT_URL: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: url + AWS_S3_ACCESS_KEY_ID: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: accessKey + AWS_S3_SECRET_ACCESS_KEY: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: secretKey + AWS_STORAGE_BUCKET_NAME: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: bucket + AWS_S3_REGION_NAME: local + STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + + createsuperuser: + command: + - "/bin/sh" + - "-c" + - | + python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD + restartPolicy: Never + +frontend: + image: + repository: lasuite/impress-frontend + pullPolicy: Always + tag: "v0.1.0" + +webrtc: + image: + repository: lasuite/impress-y-webrtc-signaling + pullPolicy: Always + tag: "v0.1.0" + +ingress: + enabled: true + host: docs.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + +ingressWS: + enabled: true + host: docs.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + +ingressAdmin: + enabled: true + host: docs.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt + nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/start + nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy.beta.numerique.gouv.fr/oauth2/auth diff --git a/src/helm/env.d/staging/secrets.enc.yaml b/src/helm/env.d/staging/secrets.enc.yaml new file mode 120000 index 00000000..ca63795d --- /dev/null +++ b/src/helm/env.d/staging/secrets.enc.yaml @@ -0,0 +1 @@ +../../../../secrets/numerique-gouv/impress/env/staging/secrets.enc.yaml \ No newline at end of file diff --git a/src/helm/env.d/staging/values.impress.yaml.gotmpl b/src/helm/env.d/staging/values.impress.yaml.gotmpl new file mode 100644 index 00000000..e3cc75a5 --- /dev/null +++ b/src/helm/env.d/staging/values.impress.yaml.gotmpl @@ -0,0 +1,147 @@ +image: + repository: lasuite/impress-backend + pullPolicy: Always + tag: "main" + +backend: + migrateJobAnnotations: + argocd.argoproj.io/hook: PreSync + argocd.argoproj.io/hook-delete-policy: HookSucceeded + envVars: + DJANGO_CSRF_TRUSTED_ORIGINS: http://impress-staging.beta.numerique.gouv.fr,https://impress-staging.beta.numerique.gouv.fr + DJANGO_CONFIGURATION: Production + DJANGO_ALLOWED_HOSTS: "*" + DJANGO_SECRET_KEY: + secretKeyRef: + name: backend + key: DJANGO_SECRET_KEY + DJANGO_SETTINGS_MODULE: impress.settings + DJANGO_SUPERUSER_EMAIL: + secretKeyRef: + name: backend + key: DJANGO_SUPERUSER_EMAIL + DJANGO_SUPERUSER_PASSWORD: + secretKeyRef: + name: backend + key: DJANGO_SUPERUSER_PASSWORD + DJANGO_EMAIL_HOST: "snap-mail.numerique.gouv.fr" + DJANGO_EMAIL_PORT: 465 + DJANGO_EMAIL_USE_SSL: True + DJANGO_SILENCED_SYSTEM_CHECKS: security.W008,security.W004 + OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks + OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize + OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token + OIDC_OP_USER_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/userinfo + OIDC_OP_LOGOUT_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/session/end + OIDC_RP_CLIENT_ID: + secretKeyRef: + name: backend + key: OIDC_RP_CLIENT_ID + OIDC_RP_CLIENT_SECRET: + secretKeyRef: + name: backend + key: OIDC_RP_CLIENT_SECRET + OIDC_RP_SIGN_ALGO: RS256 + OIDC_RP_SCOPES: "openid email" + OIDC_REDIRECT_ALLOWED_HOSTS: https://impress-staging.beta.numerique.gouv.fr + OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}" + LOGIN_REDIRECT_URL: https://impress-staging.beta.numerique.gouv.fr + LOGIN_REDIRECT_URL_FAILURE: https://impress-staging.beta.numerique.gouv.fr + LOGOUT_REDIRECT_URL: https://impress-staging.beta.numerique.gouv.fr + DB_HOST: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: host + DB_NAME: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: database + DB_USER: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: username + DB_PASSWORD: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: password + DB_PORT: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: port + POSTGRES_USER: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: username + POSTGRES_DB: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: database + POSTGRES_PASSWORD: + secretKeyRef: + name: postgresql.postgres.libre.sh + key: password + REDIS_URL: + secretKeyRef: + name: redis.redis.libre.sh + key: url + AWS_S3_ENDPOINT_URL: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: url + AWS_S3_ACCESS_KEY_ID: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: accessKey + AWS_S3_SECRET_ACCESS_KEY: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: secretKey + AWS_STORAGE_BUCKET_NAME: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: bucket + AWS_S3_REGION_NAME: local + STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage + + createsuperuser: + command: + - "/bin/sh" + - "-c" + - | + python manage.py createsuperuser --email $DJANGO_SUPERUSER_EMAIL --password $DJANGO_SUPERUSER_PASSWORD + restartPolicy: Never + +frontend: + image: + repository: lasuite/impress-frontend + pullPolicy: Always + tag: "main" + +webrtc: + image: + repository: lasuite/impress-y-webrtc-signaling + pullPolicy: Always + tag: "main" + +ingress: + enabled: true + host: impress-staging.beta.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + +ingressWS: + enabled: true + host: impress-staging.beta.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + +ingressAdmin: + enabled: true + host: impress-staging.beta.numerique.gouv.fr + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/auth-signin: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/start + nginx.ingress.kubernetes.io/auth-url: https://oauth2-proxy-preprod.beta.numerique.gouv.fr/oauth2/auth diff --git a/src/helm/extra/Chart.yaml b/src/helm/extra/Chart.yaml new file mode 100644 index 00000000..f3a2b2fc --- /dev/null +++ b/src/helm/extra/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: extra +description: A Helm chart to add some manifests to impress +type: application +version: 0.1.0 diff --git a/src/helm/extra/templates/keydb.yaml b/src/helm/extra/templates/keydb.yaml new file mode 100644 index 00000000..00898ae9 --- /dev/null +++ b/src/helm/extra/templates/keydb.yaml @@ -0,0 +1,7 @@ +apiVersion: core.libre.sh/v1alpha1 +kind: Redis +metadata: + name: redis + namespace: {{ .Release.Namespace | quote }} +spec: + disableAuth: false diff --git a/src/helm/extra/templates/postgresql.yaml b/src/helm/extra/templates/postgresql.yaml new file mode 100644 index 00000000..24d22c47 --- /dev/null +++ b/src/helm/extra/templates/postgresql.yaml @@ -0,0 +1,7 @@ +apiVersion: core.libre.sh/v1alpha1 +kind: Postgres +metadata: + name: postgresql + namespace: {{ .Release.Namespace | quote }} +spec: + database: impress diff --git a/src/helm/extra/templates/s3.yaml b/src/helm/extra/templates/s3.yaml new file mode 100644 index 00000000..6ba05f99 --- /dev/null +++ b/src/helm/extra/templates/s3.yaml @@ -0,0 +1,8 @@ +apiVersion: core.libre.sh/v1alpha1 +kind: Bucket +metadata: + name: impress-media-storage + namespace: {{ .Release.Namespace | quote }} +spec: + provider: data + versioned: true diff --git a/src/helm/extra/templates/secrets.yaml b/src/helm/extra/templates/secrets.yaml new file mode 100644 index 00000000..e0e78492 --- /dev/null +++ b/src/helm/extra/templates/secrets.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: backend +stringData: + DJANGO_SUPERUSER_EMAIL: {{ .Values.djangoSuperUserEmail }} + DJANGO_SUPERUSER_PASSWORD: {{ .Values.djangoSuperUserPass }} + DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }} + OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }} + OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }} diff --git a/src/helm/helmfile.yaml b/src/helm/helmfile.yaml new file mode 100644 index 00000000..434a0241 --- /dev/null +++ b/src/helm/helmfile.yaml @@ -0,0 +1,82 @@ +repositories: +- name: bitnami + url: registry-1.docker.io/bitnamicharts + oci: true + +releases: + - name: postgres + installed: {{ eq .Environment.Name "dev" | toYaml }} + namespace: {{ .Namespace }} + chart: bitnami/postgresql + version: 13.1.5 + values: + - auth: + username: dinum + password: pass + database: impress + - tls: + enabled: true + autoGenerated: true + + - name: minio + installed: {{ eq .Environment.Name "dev" | toYaml }} + namespace: {{ .Namespace }} + chart: bitnami/minio + version: 12.10.10 + values: + - auth: + rootUser: impress + rootPassword: password + - provisioning: + enabled: true + buckets: + - name: impress-media-storage + versioning: true + + - name: redis + installed: {{ eq .Environment.Name "dev" | toYaml }} + namespace: {{ .Namespace }} + chart: bitnami/redis + version: 18.19.2 + values: + - auth: + password: pass + architecture: standalone + + - name: extra + installed: {{ ne .Environment.Name "dev" | toYaml }} + namespace: {{ .Namespace }} + chart: ./extra + secrets: + - env.d/{{ .Environment.Name }}/secrets.enc.yaml + + - name: impress + version: {{ .Values.version }} + namespace: {{ .Namespace }} + chart: ./impress + values: + - env.d/{{ .Environment.Name }}/values.impress.yaml.gotmpl + secrets: + - env.d/{{ .Environment.Name }}/secrets.enc.yaml + +environments: + dev: + values: + - version: 0.0.1 + secrets: + - env.d/{{ .Environment.Name }}/secrets.enc.yaml + staging: + values: + - version: 0.0.1 + secrets: + - env.d/{{ .Environment.Name }}/secrets.enc.yaml + preprod: + values: + - version: 0.0.1 + secrets: + - env.d/{{ .Environment.Name }}/secrets.enc.yaml + production: + values: + - version: 0.0.1 + secrets: + - env.d/{{ .Environment.Name }}/secrets.enc.yaml diff --git a/src/helm/impress/Chart.yaml b/src/helm/impress/Chart.yaml new file mode 100644 index 00000000..a9f05c77 --- /dev/null +++ b/src/helm/impress/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +type: application +name: impress +version: 0.0.1 diff --git a/src/helm/impress/README.md b/src/helm/impress/README.md new file mode 100644 index 00000000..2b16874e --- /dev/null +++ b/src/helm/impress/README.md @@ -0,0 +1,128 @@ +# Impress helm chart + +## Parameters + +### General configuration + +| Name | Description | Value | +| ------------------------------------------ | ---------------------------------------------------- | ------------------------ | +| `image.repository` | Repository to use to pull impress's container image | `lasuite/impress-backend` | +| `image.tag` | impress's container tag | `latest` | +| `image.pullPolicy` | Container image pull policy | `IfNotPresent` | +| `image.credentials.username` | Username for container registry authentication | | +| `image.credentials.password` | Password for container registry authentication | | +| `image.credentials.registry` | Registry url for which the credentials are specified | | +| `image.credentials.name` | Name of the generated secret for imagePullSecrets | | +| `nameOverride` | Override the chart name | `""` | +| `fullnameOverride` | Override the full application name | `""` | +| `ingress.enabled` | whether to enable the Ingress or not | `false` | +| `ingress.className` | IngressClass to use for the Ingress | `nil` | +| `ingress.host` | Host for the Ingress | `impress.example.com` | +| `ingress.path` | Path to use for the Ingress | `/` | +| `ingress.hosts` | Additional host to configure for the Ingress | `[]` | +| `ingress.tls.enabled` | Weather to enable TLS for the Ingress | `true` | +| `ingress.tls.additional[].secretName` | Secret name for additional TLS config | | +| `ingress.tls.additional[].hosts[]` | Hosts for additional TLS config | | +| `ingress.customBackends` | Add custom backends to ingress | `[]` | +| `ingressAdmin.enabled` | whether to enable the Ingress or not | `false` | +| `ingressAdmin.className` | IngressClass to use for the Ingress | `nil` | +| `ingressAdmin.host` | Host for the Ingress | `impress.example.com` | +| `ingressAdmin.path` | Path to use for the Ingress | `/admin` | +| `ingressAdmin.hosts` | Additional host to configure for the Ingress | `[]` | +| `ingressAdmin.tls.enabled` | Weather to enable TLS for the Ingress | `true` | +| `ingressAdmin.tls.additional[].secretName` | Secret name for additional TLS config | | +| `ingressAdmin.tls.additional[].hosts[]` | Hosts for additional TLS config | | + +### backend + +| Name | Description | Value | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------- | +| `backend.command` | Override the backend container command | `[]` | +| `backend.args` | Override the backend container args | `[]` | +| `backend.replicas` | Amount of backend replicas | `3` | +| `backend.shareProcessNamespace` | Enable share process namespace between containers | `false` | +| `backend.sidecars` | Add sidecars containers to backend deployment | `[]` | +| `backend.securityContext` | Configure backend Pod security context | `nil` | +| `backend.envVars` | Configure backend container environment variables | `undefined` | +| `backend.envVars.BY_VALUE` | Example environment variable by setting value directly | | +| `backend.envVars.FROM_CONFIGMAP.configMapKeyRef.name` | Name of a ConfigMap when configuring env vars from a ConfigMap | | +| `backend.envVars.FROM_CONFIGMAP.configMapKeyRef.key` | Key within a ConfigMap when configuring env vars from a ConfigMap | | +| `backend.envVars.FROM_SECRET.secretKeyRef.name` | Name of a Secret when configuring env vars from a Secret | | +| `backend.envVars.FROM_SECRET.secretKeyRef.key` | Key within a Secret when configuring env vars from a Secret | | +| `backend.podAnnotations` | Annotations to add to the backend Pod | `{}` | +| `backend.service.type` | backend Service type | `ClusterIP` | +| `backend.service.port` | backend Service listening port | `80` | +| `backend.service.targetPort` | backend container listening port | `8000` | +| `backend.service.annotations` | Annotations to add to the backend Service | `{}` | +| `backend.migrate.command` | backend migrate command | `["python","manage.py","migrate","--no-input"]` | +| `backend.migrate.restartPolicy` | backend migrate job restart policy | `Never` | +| `backend.probes.liveness.path` | Configure path for backend HTTP liveness probe | `/__heartbeat__` | +| `backend.probes.liveness.targetPort` | Configure port for backend HTTP liveness probe | `undefined` | +| `backend.probes.liveness.initialDelaySeconds` | Configure initial delay for backend liveness probe | `10` | +| `backend.probes.liveness.initialDelaySeconds` | Configure timeout for backend liveness probe | `10` | +| `backend.probes.startup.path` | Configure path for backend HTTP startup probe | `undefined` | +| `backend.probes.startup.targetPort` | Configure port for backend HTTP startup probe | `undefined` | +| `backend.probes.startup.initialDelaySeconds` | Configure initial delay for backend startup probe | `undefined` | +| `backend.probes.startup.initialDelaySeconds` | Configure timeout for backend startup probe | `undefined` | +| `backend.probes.readiness.path` | Configure path for backend HTTP readiness probe | `/__lbheartbeat__` | +| `backend.probes.readiness.targetPort` | Configure port for backend HTTP readiness probe | `undefined` | +| `backend.probes.readiness.initialDelaySeconds` | Configure initial delay for backend readiness probe | `10` | +| `backend.probes.readiness.initialDelaySeconds` | Configure timeout for backend readiness probe | `10` | +| `backend.resources` | Resource requirements for the backend container | `{}` | +| `backend.nodeSelector` | Node selector for the backend Pod | `{}` | +| `backend.tolerations` | Tolerations for the backend Pod | `[]` | +| `backend.affinity` | Affinity for the backend Pod | `{}` | +| `backend.persistence` | Additional volumes to create and mount on the backend. Used for debugging purposes | `{}` | +| `backend.persistence.volume-name.size` | Size of the additional volume | | +| `backend.persistence.volume-name.type` | Type of the additional volume, persistentVolumeClaim or emptyDir | | +| `backend.persistence.volume-name.mountPath` | Path where the volume should be mounted to | | +| `backend.extraVolumeMounts` | Additional volumes to mount on the backend. | `[]` | +| `backend.extraVolumes` | Additional volumes to mount on the backend. | `[]` | + +### frontend + +| Name | Description | Value | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------- | ------------------------- | +| `frontend.image.repository` | Repository to use to pull impress's frontend container image | `lasuite/impress-frontend` | +| `frontend.image.tag` | impress's frontend container tag | `latest` | +| `frontend.image.pullPolicy` | frontend container image pull policy | `IfNotPresent` | +| `frontend.command` | Override the frontend container command | `[]` | +| `frontend.args` | Override the frontend container args | `[]` | +| `frontend.replicas` | Amount of frontend replicas | `3` | +| `frontend.shareProcessNamespace` | Enable share process namefrontend between containers | `false` | +| `frontend.sidecars` | Add sidecars containers to frontend deployment | `[]` | +| `frontend.securityContext` | Configure frontend Pod security context | `nil` | +| `frontend.envVars` | Configure frontend container environment variables | `undefined` | +| `frontend.envVars.BY_VALUE` | Example environment variable by setting value directly | | +| `frontend.envVars.FROM_CONFIGMAP.configMapKeyRef.name` | Name of a ConfigMap when configuring env vars from a ConfigMap | | +| `frontend.envVars.FROM_CONFIGMAP.configMapKeyRef.key` | Key within a ConfigMap when configuring env vars from a ConfigMap | | +| `frontend.envVars.FROM_SECRET.secretKeyRef.name` | Name of a Secret when configuring env vars from a Secret | | +| `frontend.envVars.FROM_SECRET.secretKeyRef.key` | Key within a Secret when configuring env vars from a Secret | | +| `frontend.podAnnotations` | Annotations to add to the frontend Pod | `{}` | +| `frontend.service.type` | frontend Service type | `ClusterIP` | +| `frontend.service.port` | frontend Service listening port | `80` | +| `frontend.service.targetPort` | frontend container listening port | `8080` | +| `frontend.service.annotations` | Annotations to add to the frontend Service | `{}` | +| `frontend.probes` | Configure probe for frontend | `{}` | +| `frontend.probes.liveness.path` | Configure path for frontend HTTP liveness probe | | +| `frontend.probes.liveness.targetPort` | Configure port for frontend HTTP liveness probe | | +| `frontend.probes.liveness.initialDelaySeconds` | Configure initial delay for frontend liveness probe | | +| `frontend.probes.liveness.initialDelaySeconds` | Configure timeout for frontend liveness probe | | +| `frontend.probes.startup.path` | Configure path for frontend HTTP startup probe | | +| `frontend.probes.startup.targetPort` | Configure port for frontend HTTP startup probe | | +| `frontend.probes.startup.initialDelaySeconds` | Configure initial delay for frontend startup probe | | +| `frontend.probes.startup.initialDelaySeconds` | Configure timeout for frontend startup probe | | +| `frontend.probes.readiness.path` | Configure path for frontend HTTP readiness probe | | +| `frontend.probes.readiness.targetPort` | Configure port for frontend HTTP readiness probe | | +| `frontend.probes.readiness.initialDelaySeconds` | Configure initial delay for frontend readiness probe | | +| `frontend.probes.readiness.initialDelaySeconds` | Configure timeout for frontend readiness probe | | +| `frontend.resources` | Resource requirements for the frontend container | `{}` | +| `frontend.nodeSelector` | Node selector for the frontend Pod | `{}` | +| `frontend.tolerations` | Tolerations for the frontend Pod | `[]` | +| `frontend.affinity` | Affinity for the frontend Pod | `{}` | +| `frontend.persistence` | Additional volumes to create and mount on the frontend. Used for debugging purposes | `{}` | +| `frontend.persistence.volume-name.size` | Size of the additional volume | | +| `frontend.persistence.volume-name.type` | Type of the additional volume, persistentVolumeClaim or emptyDir | | +| `frontend.persistence.volume-name.mountPath` | Path where the volume should be mounted to | | +| `frontend.extraVolumeMounts` | Additional volumes to mount on the frontend. | `[]` | +| `frontend.extraVolumes` | Additional volumes to mount on the frontend. | `[]` | diff --git a/src/helm/impress/generate-readme.sh b/src/helm/impress/generate-readme.sh new file mode 100644 index 00000000..edbd2806 --- /dev/null +++ b/src/helm/impress/generate-readme.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +docker image ls | grep readme-generator-for-helm +if [ "$?" -ne "0" ]; then + git clone https://github.com/bitnami/readme-generator-for-helm.git /tmp/readme-generator-for-helm + cd /tmp/readme-generator-for-helm + docker build -t readme-generator-for-helm:latest . + cd $(dirname -- "${BASH_SOURCE[0]}") +fi +docker run --rm -it -v ./values.yaml:/app/values.yaml -v ./README.md:/app/README.md readme-generator-for-helm:latest readme-generator -v values.yaml -r README.md diff --git a/src/helm/impress/templates/_helpers.tpl b/src/helm/impress/templates/_helpers.tpl new file mode 100644 index 00000000..7295dd2f --- /dev/null +++ b/src/helm/impress/templates/_helpers.tpl @@ -0,0 +1,184 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "impress.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "impress.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "impress.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +impress.labels +*/}} +{{- define "impress.labels" -}} +helm.sh/chart: {{ include "impress.chart" . }} +{{ include "impress.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "impress.selectorLabels" -}} +app.kubernetes.io/name: {{ include "impress.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +transform dictionnary of environment variables +Usage : {{ include "impress.env.transformDict" .Values.envVars }} + +Example: +envVars: + # Using simple strings as env vars + ENV_VAR_NAME: "envVar value" + # Using a value from a configMap + ENV_VAR_FROM_CM: + configMapKeyRef: + name: cm-name + key: "key_in_cm" + # Using a value from a secret + ENV_VAR_FROM_SECRET: + secretKeyRef: + name: secret-name + key: "key_in_secret" +*/}} +{{- define "impress.env.transformDict" -}} +{{- range $key, $value := . }} +- name: {{ $key | quote }} +{{- if $value | kindIs "map" }} + valueFrom: {{ $value | toYaml | nindent 4 }} +{{- else }} + value: {{ $value | quote }} +{{- end }} +{{- end }} +{{- end }} + + +{{/* +impress env vars +*/}} +{{- define "impress.common.env" -}} +{{- $topLevelScope := index . 0 -}} +{{- $workerScope := index . 1 -}} +{{- include "impress.env.transformDict" $workerScope.envVars -}} +{{- end }} + +{{/* +Common labels + +Requires array with top level scope and component name +*/}} +{{- define "impress.common.labels" -}} +{{- $topLevelScope := index . 0 -}} +{{- $component := index . 1 -}} +{{- include "impress.labels" $topLevelScope }} +app.kubernetes.io/component: {{ $component }} +{{- end }} + +{{/* +Common selector labels + +Requires array with top level scope and component name +*/}} +{{- define "impress.common.selectorLabels" -}} +{{- $topLevelScope := index . 0 -}} +{{- $component := index . 1 -}} +{{- include "impress.selectorLabels" $topLevelScope }} +app.kubernetes.io/component: {{ $component }} +{{- end }} + +{{- define "impress.probes.abstract" -}} +{{- if .exec -}} +exec: +{{- toYaml .exec | nindent 2 }} +{{- else if .tcpSocket -}} +tcpSocket: +{{- toYaml .tcpSocket | nindent 2 }} +{{- else -}} +httpGet: + path: {{ .path }} + port: {{ .targetPort }} +{{- end }} +initialDelaySeconds: {{ .initialDelaySeconds | eq nil | ternary 0 .initialDelaySeconds }} +timeoutSeconds: {{ .timeoutSeconds | eq nil | ternary 1 .timeoutSeconds }} +{{- end }} + +{{/* +Full name for the backend + +Requires top level scope +*/}} +{{- define "impress.backend.fullname" -}} +{{ include "impress.fullname" . }}-backend +{{- end }} + +{{/* +Full name for the frontend + +Requires top level scope +*/}} +{{- define "impress.frontend.fullname" -}} +{{ include "impress.fullname" . }}-frontend +{{- end }} + +{{/* +Full name for the webrtc + +Requires top level scope +*/}} +{{- define "impress.webrtc.fullname" -}} +{{ include "impress.fullname" . }}-webrtc +{{- end }} + +{{/* +Usage : {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" .Values.path.to.the.image1) }} +*/}} +{{- define "impress.secret.dockerconfigjson.name" }} +{{- if (default (dict) .imageCredentials).name }}{{ .imageCredentials.name }}{{ else }}{{ .fullname | trunc 63 | trimSuffix "-" }}-dockerconfig{{ end -}} +{{- end }} + +{{/* +Usage : {{ include "impress.secret.dockerconfigjson" (dict "fullname" (include "impress.fullname" .) "imageCredentials" .Values.path.to.the.image1) }} +*/}} +{{- define "impress.secret.dockerconfigjson" }} +{{- if .imageCredentials -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "impress.secret.dockerconfigjson.name" (dict "fullname" .fullname "imageCredentials" .imageCredentials) }} + annotations: + "helm.sh/hook": pre-install,pre-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation +type: kubernetes.io/dockerconfigjson +data: + .dockerconfigjson: {{ template "impress.secret.dockerconfigjson.data" .imageCredentials }} +{{- end -}} +{{- end }} diff --git a/src/helm/impress/templates/backend_deployment.yaml b/src/helm/impress/templates/backend_deployment.yaml new file mode 100644 index 00000000..d0c0f086 --- /dev/null +++ b/src/helm/impress/templates/backend_deployment.yaml @@ -0,0 +1,136 @@ +{{- $envVars := include "impress.common.env" (list . .Values.backend) -}} +{{- $fullName := include "impress.backend.fullname" . -}} +{{- $component := "backend" -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + replicas: {{ .Values.backend.replicas }} + selector: + matchLabels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.backend.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + containers: + {{- with .Values.backend.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: {{ .Chart.Name }} + image: "{{ (.Values.backend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.backend.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.backend.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- if $envVars}} + {{- $envVars | indent 12 }} + {{- end }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.backend.service.targetPort }} + protocol: TCP + {{- if .Values.backend.probes.liveness }} + livenessProbe: + {{- include "impress.probes.abstract" (merge .Values.backend.probes.liveness (dict "targetPort" .Values.backend.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.backend.probes.readiness }} + readinessProbe: + {{- include "impress.probes.abstract" (merge .Values.backend.probes.readiness (dict "targetPort" .Values.backend.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.backend.probes.startup }} + startupProbe: + {{- include "impress.probes.abstract" (merge .Values.backend.probes.startup (dict "targetPort" .Values.backend.service.targetPort )) | nindent 12 }} + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range $name, $volume := .Values.backend.persistence }} + - name: "{{ $name }}" + mountPath: "{{ $volume.mountPath }}" + {{- end }} + {{- range .Values.backend.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range $name, $volume := .Values.backend.persistence }} + - name: "{{ $name }}" + {{- if eq $volume.type "emptyDir" }} + emptyDir: {} + {{- else }} + persistentVolumeClaim: + claimName: "{{ $fullName }}-{{ $name }}" + {{- end }} + {{- end }} + {{- range .Values.backend.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} diff --git a/src/helm/impress/templates/backend_job.yaml b/src/helm/impress/templates/backend_job.yaml new file mode 100644 index 00000000..f55447ae --- /dev/null +++ b/src/helm/impress/templates/backend_job.yaml @@ -0,0 +1,121 @@ +{{- $envVars := include "impress.common.env" (list . .Values.backend) -}} +{{- $fullName := include "impress.backend.fullname" . -}} +{{- $component := "backend" -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $fullName }}-migrate + namespace: {{ .Release.Namespace | quote }} + {{- with .Values.backend.migrateJobAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + template: + metadata: + annotations: + {{- with .Values.backend.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + containers: + {{- with .Values.backend.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: {{ .Chart.Name }} + image: "{{ (.Values.backend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.backend.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.backend.migrate.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- if $envVars}} + {{- $envVars | indent 12 }} + {{- end }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range $name, $volume := .Values.backend.persistence }} + - name: "{{ $name }}" + mountPath: "{{ $volume.mountPath }}" + {{- end }} + {{- range .Values.backend.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ .Values.backend.migrate.restartPolicy }} + volumes: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range $name, $volume := .Values.backend.persistence }} + - name: "{{ $name }}" + {{- if eq $volume.type "emptyDir" }} + emptyDir: {} + {{- else }} + persistentVolumeClaim: + claimName: "{{ $fullName }}-{{ $name }}" + {{- end }} + {{- end }} + {{- range .Values.backend.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} diff --git a/src/helm/impress/templates/backend_job_createsuperuser.yaml b/src/helm/impress/templates/backend_job_createsuperuser.yaml new file mode 100644 index 00000000..c6b6949b --- /dev/null +++ b/src/helm/impress/templates/backend_job_createsuperuser.yaml @@ -0,0 +1,121 @@ +{{- $envVars := include "impress.common.env" (list . .Values.backend) -}} +{{- $fullName := include "impress.backend.fullname" . -}} +{{- $component := "backend" -}} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ $fullName }}-createsuperuser + namespace: {{ .Release.Namespace | quote }} + {{- with .Values.backend.migrateJobAnnotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + template: + metadata: + annotations: + {{- with .Values.backend.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + shareProcessNamespace: {{ .Values.backend.shareProcessNamespace }} + containers: + {{- with .Values.backend.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: {{ .Chart.Name }} + image: "{{ (.Values.backend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.backend.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.backend.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.backend.createsuperuser.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- if $envVars}} + {{- $envVars | indent 12 }} + {{- end }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range $name, $volume := .Values.backend.persistence }} + - name: "{{ $name }}" + mountPath: "{{ $volume.mountPath }}" + {{- end }} + {{- range .Values.backend.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + restartPolicy: {{ .Values.backend.createsuperuser.restartPolicy }} + volumes: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range $name, $volume := .Values.backend.persistence }} + - name: "{{ $name }}" + {{- if eq $volume.type "emptyDir" }} + emptyDir: {} + {{- else }} + persistentVolumeClaim: + claimName: "{{ $fullName }}-{{ $name }}" + {{- end }} + {{- end }} + {{- range .Values.backend.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} diff --git a/src/helm/impress/templates/backend_svc.yaml b/src/helm/impress/templates/backend_svc.yaml new file mode 100644 index 00000000..579bdbf6 --- /dev/null +++ b/src/helm/impress/templates/backend_svc.yaml @@ -0,0 +1,21 @@ +{{- $envVars := include "impress.common.env" (list . .Values.backend) -}} +{{- $fullName := include "impress.backend.fullname" . -}} +{{- $component := "backend" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} + annotations: + {{- toYaml $.Values.backend.service.annotations | nindent 4 }} +spec: + type: {{ .Values.backend.service.type }} + ports: + - port: {{ .Values.backend.service.port }} + targetPort: {{ .Values.backend.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} diff --git a/src/helm/impress/templates/frontend_deployment.yaml b/src/helm/impress/templates/frontend_deployment.yaml new file mode 100644 index 00000000..959ab02a --- /dev/null +++ b/src/helm/impress/templates/frontend_deployment.yaml @@ -0,0 +1,136 @@ +{{- $envVars := include "impress.common.env" (list . .Values.frontend) -}} +{{- $fullName := include "impress.frontend.fullname" . -}} +{{- $component := "frontend" -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + replicas: {{ .Values.frontend.replicas }} + selector: + matchLabels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.frontend.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + shareProcessNamespace: {{ .Values.frontend.shareProcessNamespace }} + containers: + {{- with .Values.frontend.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: {{ .Chart.Name }} + image: "{{ (.Values.frontend.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.frontend.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.frontend.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.frontend.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.frontend.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- if $envVars}} + {{- $envVars | indent 12 }} + {{- end }} + {{- with .Values.frontend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.frontend.service.targetPort }} + protocol: TCP + {{- if .Values.frontend.probes.liveness }} + livenessProbe: + {{- include "impress.probes.abstract" (merge .Values.frontend.probes.liveness (dict "targetPort" .Values.frontend.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.frontend.probes.readiness }} + readinessProbe: + {{- include "impress.probes.abstract" (merge .Values.frontend.probes.readiness (dict "targetPort" .Values.frontend.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.frontend.probes.startup }} + startupProbe: + {{- include "impress.probes.abstract" (merge .Values.frontend.probes.startup (dict "targetPort" .Values.frontend.service.targetPort )) | nindent 12 }} + {{- end }} + {{- with .Values.frontend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range $name, $volume := .Values.frontend.persistence }} + - name: "{{ $name }}" + mountPath: "{{ $volume.mountPath }}" + {{- end }} + {{- range .Values.frontend.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.frontend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.frontend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.frontend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range $name, $volume := .Values.frontend.persistence }} + - name: "{{ $name }}" + {{- if eq $volume.type "emptyDir" }} + emptyDir: {} + {{- else }} + persistentVolumeClaim: + claimName: "{{ $fullName }}-{{ $name }}" + {{- end }} + {{- end }} + {{- range .Values.frontend.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} diff --git a/src/helm/impress/templates/frontend_svc.yaml b/src/helm/impress/templates/frontend_svc.yaml new file mode 100644 index 00000000..ac42e4c6 --- /dev/null +++ b/src/helm/impress/templates/frontend_svc.yaml @@ -0,0 +1,21 @@ +{{- $envVars := include "impress.common.env" (list . .Values.frontend) -}} +{{- $fullName := include "impress.frontend.fullname" . -}} +{{- $component := "frontend" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} + annotations: + {{- toYaml $.Values.frontend.service.annotations | nindent 4 }} +spec: + type: {{ .Values.frontend.service.type }} + ports: + - port: {{ .Values.frontend.service.port }} + targetPort: {{ .Values.frontend.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} diff --git a/src/helm/impress/templates/ingress.yaml b/src/helm/impress/templates/ingress.yaml new file mode 100644 index 00000000..aecc5977 --- /dev/null +++ b/src/helm/impress/templates/ingress.yaml @@ -0,0 +1,118 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "impress.fullname" . -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + {{- if .Values.ingress.host }} + - secretName: {{ $fullName }}-tls + hosts: + - {{ .Values.ingress.host | quote }} + {{- end }} + {{- range .Values.ingress.tls.additional }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- if .Values.ingress.host }} + - host: {{ .Values.ingress.host | quote }} + http: + paths: + - path: {{ .Values.ingress.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.frontend.fullname" . }} + port: + number: {{ .Values.frontend.service.port }} + {{- else }} + serviceName: {{ include "impress.frontend.fullname" . }} + servicePort: {{ .Values.frontend.service.port }} + {{- end }} + - path: /api + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.backend.fullname" . }} + port: + number: {{ .Values.backend.service.port }} + {{- else }} + serviceName: {{ include "impress.backend.fullname" . }} + servicePort: {{ .Values.backend.service.port }} + {{- end }} + {{- with .Values.ingress.customBackends }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} + {{- range .Values.ingress.hosts }} + - host: {{ . | quote }} + http: + paths: + - path: {{ $.Values.ingress.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.frontend.fullname" $ }} + port: + number: {{ $.Values.frontend.service.port }} + {{- else }} + serviceName: {{ include "impress.frontend.fullname" $ }} + servicePort: {{ $.Values.frontend.service.port }} + {{- end }} + - path: /api + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.backend.fullname" $ }} + port: + number: {{ $.Values.backend.service.port }} + {{- else }} + serviceName: {{ include "impress.backend.fullname" $ }} + servicePort: {{ $.Values.backend.service.port }} + {{- end }} + {{- with $.Values.ingress.customBackends }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} +{{- end }} + diff --git a/src/helm/impress/templates/ingress_admin.yaml b/src/helm/impress/templates/ingress_admin.yaml new file mode 100644 index 00000000..64532389 --- /dev/null +++ b/src/helm/impress/templates/ingress_admin.yaml @@ -0,0 +1,98 @@ +{{- if .Values.ingressAdmin.enabled -}} +{{- $fullName := include "impress.fullname" . -}} +{{- if and .Values.ingressAdmin.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingressAdmin.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingressAdmin.annotations "kubernetes.io/ingress.class" .Values.ingressAdmin.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-admin + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.labels" . | nindent 4 }} + {{- with .Values.ingressAdmin.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingressAdmin.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingressAdmin.className }} + {{- end }} + {{- if .Values.ingressAdmin.tls.enabled }} + tls: + {{- if .Values.ingressAdmin.host }} + - secretName: {{ $fullName }}-tls + hosts: + - {{ .Values.ingressAdmin.host | quote }} + {{- end }} + {{- range .Values.ingressAdmin.tls.additional }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- if .Values.ingressAdmin.host }} + - host: {{ .Values.ingressAdmin.host | quote }} + http: + paths: + - path: {{ .Values.ingressAdmin.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.backend.fullname" . }} + port: + number: {{ .Values.backend.service.port }} + {{- else }} + serviceName: {{ include "impress.backend.fullname" . }} + servicePort: {{ .Values.backend.service.port }} + {{- end }} + - path: /static + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.backend.fullname" . }} + port: + number: {{ .Values.backend.service.port }} + {{- else }} + serviceName: {{ include "impress.backend.fullname" . }} + servicePort: {{ .Values.backend.service.port }} + {{- end }} + {{- end }} + {{- range .Values.ingressAdmin.hosts }} + - host: {{ . | quote }} + http: + paths: + - path: {{ $.Values.ingressAdmin.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: Prefix + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.backend.fullname" $ }} + port: + number: {{ $.Values.backend.service.port }} + {{- else }} + serviceName: {{ include "impress.backend.fullname" $ }} + servicePort: {{ $.Values.backend.service.port }} + {{- end }} + {{- end }} +{{- end }} + diff --git a/src/helm/impress/templates/ingress_ws.yaml b/src/helm/impress/templates/ingress_ws.yaml new file mode 100644 index 00000000..138efbc6 --- /dev/null +++ b/src/helm/impress/templates/ingress_ws.yaml @@ -0,0 +1,72 @@ +{{- if .Values.ingressWS.enabled -}} +{{- $fullName := include "impress.fullname" . -}} +{{- if and .Values.ingressWS.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingressWS.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingressWS.annotations "kubernetes.io/ingress.class" .Values.ingressWS.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }}-ws + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.labels" . | nindent 4 }} + {{- with .Values.ingressWS.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingressWS.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingressWS.className }} + {{- end }} + {{- if .Values.ingressWS.tls.enabled }} + tls: + {{- if .Values.ingressWS.host }} + - secretName: {{ $fullName }}-tls + hosts: + - {{ .Values.ingressWS.host | quote }} + {{- end }} + {{- range .Values.ingressWS.tls.additional }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- if .Values.ingressWS.host }} + - host: {{ .Values.ingressWS.host | quote }} + http: + paths: + - path: {{ .Values.ingressWS.path | quote }} + {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} + pathType: ImplementationSpecific + {{- end }} + backend: + service: + name: {{ include "impress.webrtc.fullname" . }} + port: + number: {{ .Values.webrtc.service.port }} + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ include "impress.webrtc.fullname" . }} + port: + number: {{ .Values.webrtc.service.port }} + {{- else }} + serviceName: {{ include "impress.webrtc.fullname" . }} + servicePort: {{ .Values.webrtc.service.port }} + {{- end }} + {{- with .Values.ingressWS.customBackends }} + {{- toYaml . | nindent 10 }} + {{- end }} + {{- end }} +{{- end }} + diff --git a/src/helm/impress/templates/webrtc_deployment.yaml b/src/helm/impress/templates/webrtc_deployment.yaml new file mode 100644 index 00000000..2a34fc7b --- /dev/null +++ b/src/helm/impress/templates/webrtc_deployment.yaml @@ -0,0 +1,136 @@ +{{- $envVars := include "impress.common.env" (list . .Values.webrtc) -}} +{{- $fullName := include "impress.webrtc.fullname" . -}} +{{- $component := "webrtc" -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} +spec: + replicas: {{ .Values.webrtc.replicas }} + selector: + matchLabels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }} + template: + metadata: + annotations: + {{- with .Values.webrtc.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 8 }} + spec: + {{- if $.Values.image.credentials }} + imagePullSecrets: + - name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }} + {{- end}} + shareProcessNamespace: {{ .Values.webrtc.shareProcessNamespace }} + containers: + {{- with .Values.webrtc.sidecars }} + {{- toYaml . | nindent 8 }} + {{- end }} + - name: {{ .Chart.Name }} + image: "{{ (.Values.webrtc.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.webrtc.image | default dict).tag | default .Values.image.tag }}" + imagePullPolicy: {{ (.Values.webrtc.image | default dict).pullPolicy | default .Values.image.pullPolicy }} + {{- with .Values.webrtc.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.webrtc.args }} + args: + {{- toYaml . | nindent 12 }} + {{- end }} + env: + {{- if $envVars}} + {{- $envVars | indent 12 }} + {{- end }} + {{- with .Values.webrtc.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.webrtc.service.targetPort }} + protocol: TCP + {{- if .Values.webrtc.probes.liveness }} + livenessProbe: + {{- include "impress.probes.abstract" (merge .Values.webrtc.probes.liveness (dict "targetPort" .Values.webrtc.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.webrtc.probes.readiness }} + readinessProbe: + {{- include "impress.probes.abstract" (merge .Values.webrtc.probes.readiness (dict "targetPort" .Values.webrtc.service.targetPort )) | nindent 12 }} + {{- end }} + {{- if .Values.webrtc.probes.startup }} + startupProbe: + {{- include "impress.probes.abstract" (merge .Values.webrtc.probes.startup (dict "targetPort" .Values.webrtc.service.targetPort )) | nindent 12 }} + {{- end }} + {{- with .Values.webrtc.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + mountPath: {{ $value.path }} + subPath: content + {{- end }} + {{- range $name, $volume := .Values.webrtc.persistence }} + - name: "{{ $name }}" + mountPath: "{{ $volume.mountPath }}" + {{- end }} + {{- range .Values.webrtc.extraVolumeMounts }} + - name: {{ .name }} + mountPath: {{ .mountPath }} + subPath: {{ .subPath | default "" }} + readOnly: {{ .readOnly }} + {{- end }} + {{- with .Values.webrtc.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.webrtc.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.webrtc.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + {{- range $index, $value := .Values.mountFiles }} + - name: "files-{{ $index }}" + configMap: + name: "{{ include "impress.fullname" $ }}-files-{{ $index }}" + {{- end }} + {{- range $name, $volume := .Values.webrtc.persistence }} + - name: "{{ $name }}" + {{- if eq $volume.type "emptyDir" }} + emptyDir: {} + {{- else }} + persistentVolumeClaim: + claimName: "{{ $fullName }}-{{ $name }}" + {{- end }} + {{- end }} + {{- range .Values.webrtc.extraVolumes }} + - name: {{ .name }} + {{- if .existingClaim }} + persistentVolumeClaim: + claimName: {{ .existingClaim }} + {{- else if .hostPath }} + hostPath: + {{ toYaml .hostPath | nindent 12 }} + {{- else if .csi }} + csi: + {{- toYaml .csi | nindent 12 }} + {{- else if .configMap }} + configMap: + {{- toYaml .configMap | nindent 12 }} + {{- else if .emptyDir }} + emptyDir: + {{- toYaml .emptyDir | nindent 12 }} + {{- else }} + emptyDir: {} + {{- end }} + {{- end }} diff --git a/src/helm/impress/templates/webrtc_svc.yaml b/src/helm/impress/templates/webrtc_svc.yaml new file mode 100644 index 00000000..c1322141 --- /dev/null +++ b/src/helm/impress/templates/webrtc_svc.yaml @@ -0,0 +1,21 @@ +{{- $envVars := include "impress.common.env" (list . .Values.webrtc) -}} +{{- $fullName := include "impress.webrtc.fullname" . -}} +{{- $component := "webrtc" -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace | quote }} + labels: + {{- include "impress.common.labels" (list . $component) | nindent 4 }} + annotations: + {{- toYaml $.Values.webrtc.service.annotations | nindent 4 }} +spec: + type: {{ .Values.webrtc.service.type }} + ports: + - port: {{ .Values.webrtc.service.port }} + targetPort: {{ .Values.webrtc.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }} diff --git a/src/helm/impress/values.yaml b/src/helm/impress/values.yaml new file mode 100644 index 00000000..60dbbcce --- /dev/null +++ b/src/helm/impress/values.yaml @@ -0,0 +1,385 @@ +# Default values for impress. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +## @section General configuration + +## @param image.repository Repository to use to pull impress's container image +## @param image.tag impress's container tag +## @param image.pullPolicy Container image pull policy +## @extra image.credentials.username Username for container registry authentication +## @extra image.credentials.password Password for container registry authentication +## @extra image.credentials.registry Registry url for which the credentials are specified +## @extra image.credentials.name Name of the generated secret for imagePullSecrets +image: + repository: lasuite/impress-backend + pullPolicy: IfNotPresent + tag: "latest" + +## @param nameOverride Override the chart name +## @param fullnameOverride Override the full application name +nameOverride: "" +fullnameOverride: "" + +## @skip commonEnvVars +commonEnvVars: &commonEnvVars + <<: [] + +## @param ingress.enabled whether to enable the Ingress or not +## @param ingress.className IngressClass to use for the Ingress +## @param ingress.host Host for the Ingress +## @param ingress.path Path to use for the Ingress +ingress: + enabled: false + className: null + host: impress.example.com + path: / + ## @param ingress.hosts Additional host to configure for the Ingress + hosts: [] + # - chart-example.local + ## @param ingress.tls.enabled Weather to enable TLS for the Ingress + ## @skip ingress.tls.additional + ## @extra ingress.tls.additional[].secretName Secret name for additional TLS config + ## @extra ingress.tls.additional[].hosts[] Hosts for additional TLS config + tls: + enabled: true + additional: [] + + ## @param ingress.customBackends Add custom backends to ingress + customBackends: [] + +## @param ingressWS.enabled whether to enable the Ingress or not +## @param ingressWS.className IngressClass to use for the Ingress +## @param ingressWS.host Host for the Ingress +## @param ingressWS.path Path to use for the Ingress +ingressWS: + enabled: false + className: null + host: impress.example.com + path: /ws + ## @param ingress.hosts Additional host to configure for the Ingress + hosts: [] + # - chart-example.local + ## @param ingressWS.tls.enabled Weather to enable TLS for the Ingress + ## @skip ingressWS.tls.additional + ## @extra ingressWS.tls.additional[].secretName Secret name for additional TLS config + ## @extra ingressWS.tls.additional[].hosts[] Hosts for additional TLS config + tls: + enabled: true + additional: [] + + ## @param ingressWS.customBackends Add custom backends to ingress + customBackends: [] + + annotations: + nginx.ingress.kubernetes.io/enable-websocket: "true" + nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri" + +## @param ingressAdmin.enabled whether to enable the Ingress or not +## @param ingressAdmin.className IngressClass to use for the Ingress +## @param ingressAdmin.host Host for the Ingress +## @param ingressAdmin.path Path to use for the Ingress +ingressAdmin: + enabled: false + className: null + host: impress.example.com + path: /admin + ## @param ingressAdmin.hosts Additional host to configure for the Ingress + hosts: [ ] + # - chart-example.local + ## @param ingressAdmin.tls.enabled Weather to enable TLS for the Ingress + ## @skip ingressAdmin.tls.additional + ## @extra ingressAdmin.tls.additional[].secretName Secret name for additional TLS config + ## @extra ingressAdmin.tls.additional[].hosts[] Hosts for additional TLS config + tls: + enabled: true + additional: [] + + +## @section backend + +backend: + + ## @param backend.command Override the backend container command + command: [] + + ## @param backend.args Override the backend container args + args: [] + + ## @param backend.replicas Amount of backend replicas + replicas: 3 + + ## @param backend.shareProcessNamespace Enable share process namespace between containers + shareProcessNamespace: false + + ## @param backend.sidecars Add sidecars containers to backend deployment + sidecars: [] + + ## @param backend.migrateJobAnnotations Annotations for the migrate job + migrateJobAnnotations: {} + + ## @param backend.securityContext Configure backend Pod security context + securityContext: null + + ## @param backend.envVars Configure backend container environment variables + ## @extra backend.envVars.BY_VALUE Example environment variable by setting value directly + ## @extra backend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap + ## @extra backend.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap + ## @extra backend.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret + ## @extra backend.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret + ## @skip backend.envVars + envVars: + <<: *commonEnvVars + + ## @param backend.podAnnotations Annotations to add to the backend Pod + podAnnotations: {} + + ## @param backend.service.type backend Service type + ## @param backend.service.port backend Service listening port + ## @param backend.service.targetPort backend container listening port + ## @param backend.service.annotations Annotations to add to the backend Service + service: + type: ClusterIP + port: 80 + targetPort: 8000 + annotations: {} + + ## @param backend.migrate.command backend migrate command + ## @param backend.migrate.restartPolicy backend migrate job restart policy + migrate: + command: + - "python" + - "manage.py" + - "migrate" + - "--no-input" + restartPolicy: Never + + ## @param backend.probes.liveness.path [nullable] Configure path for backend HTTP liveness probe + ## @param backend.probes.liveness.targetPort [nullable] Configure port for backend HTTP liveness probe + ## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe + ## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure timeout for backend liveness probe + ## @param backend.probes.startup.path [nullable] Configure path for backend HTTP startup probe + ## @param backend.probes.startup.targetPort [nullable] Configure port for backend HTTP startup probe + ## @param backend.probes.startup.initialDelaySeconds [nullable] Configure initial delay for backend startup probe + ## @param backend.probes.startup.initialDelaySeconds [nullable] Configure timeout for backend startup probe + ## @param backend.probes.readiness.path [nullable] Configure path for backend HTTP readiness probe + ## @param backend.probes.readiness.targetPort [nullable] Configure port for backend HTTP readiness probe + ## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for backend readiness probe + ## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure timeout for backend readiness probe + probes: + liveness: + path: /__heartbeat__ + initialDelaySeconds: 10 + readiness: + path: /__lbheartbeat__ + initialDelaySeconds: 10 + + ## @param backend.resources Resource requirements for the backend container + resources: {} + + ## @param backend.nodeSelector Node selector for the backend Pod + nodeSelector: {} + + ## @param backend.tolerations Tolerations for the backend Pod + tolerations: [] + + ## @param backend.affinity Affinity for the backend Pod + affinity: {} + + ## @param backend.persistence Additional volumes to create and mount on the backend. Used for debugging purposes + ## @extra backend.persistence.volume-name.size Size of the additional volume + ## @extra backend.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir + ## @extra backend.persistence.volume-name.mountPath Path where the volume should be mounted to + persistence: {} + + ## @param backend.extraVolumeMounts Additional volumes to mount on the backend. + extraVolumeMounts: [] + + ## @param backend.extraVolumes Additional volumes to mount on the backend. + extraVolumes: [] + + +## @section frontend + +frontend: + ## @param frontend.image.repository Repository to use to pull impress's frontend container image + ## @param frontend.image.tag impress's frontend container tag + ## @param frontend.image.pullPolicy frontend container image pull policy + image: + repository: lasuite/impress-frontend + pullPolicy: IfNotPresent + tag: "latest" + + ## @param frontend.command Override the frontend container command + command: [] + + ## @param frontend.args Override the frontend container args + args: [] + + ## @param frontend.replicas Amount of frontend replicas + replicas: 3 + + ## @param frontend.shareProcessNamespace Enable share process namefrontend between containers + shareProcessNamespace: false + + ## @param frontend.sidecars Add sidecars containers to frontend deployment + sidecars: [] + + ## @param frontend.securityContext Configure frontend Pod security context + securityContext: null + + ## @param frontend.envVars Configure frontend container environment variables + ## @extra frontend.envVars.BY_VALUE Example environment variable by setting value directly + ## @extra frontend.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap + ## @extra frontend.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap + ## @extra frontend.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret + ## @extra frontend.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret + ## @skip frontend.envVars + envVars: + <<: *commonEnvVars + + ## @param frontend.podAnnotations Annotations to add to the frontend Pod + podAnnotations: {} + + ## @param frontend.service.type frontend Service type + ## @param frontend.service.port frontend Service listening port + ## @param frontend.service.targetPort frontend container listening port + ## @param frontend.service.annotations Annotations to add to the frontend Service + service: + type: ClusterIP + port: 80 + targetPort: 8080 + annotations: {} + + ## @param frontend.probes Configure probe for frontend + ## @extra frontend.probes.liveness.path Configure path for frontend HTTP liveness probe + ## @extra frontend.probes.liveness.targetPort Configure port for frontend HTTP liveness probe + ## @extra frontend.probes.liveness.initialDelaySeconds Configure initial delay for frontend liveness probe + ## @extra frontend.probes.liveness.initialDelaySeconds Configure timeout for frontend liveness probe + ## @extra frontend.probes.startup.path Configure path for frontend HTTP startup probe + ## @extra frontend.probes.startup.targetPort Configure port for frontend HTTP startup probe + ## @extra frontend.probes.startup.initialDelaySeconds Configure initial delay for frontend startup probe + ## @extra frontend.probes.startup.initialDelaySeconds Configure timeout for frontend startup probe + ## @extra frontend.probes.readiness.path Configure path for frontend HTTP readiness probe + ## @extra frontend.probes.readiness.targetPort Configure port for frontend HTTP readiness probe + ## @extra frontend.probes.readiness.initialDelaySeconds Configure initial delay for frontend readiness probe + ## @extra frontend.probes.readiness.initialDelaySeconds Configure timeout for frontend readiness probe + probes: {} + + ## @param frontend.resources Resource requirements for the frontend container + resources: {} + + ## @param frontend.nodeSelector Node selector for the frontend Pod + nodeSelector: {} + + ## @param frontend.tolerations Tolerations for the frontend Pod + tolerations: [] + + ## @param frontend.affinity Affinity for the frontend Pod + affinity: {} + + ## @param frontend.persistence Additional volumes to create and mount on the frontend. Used for debugging purposes + ## @extra frontend.persistence.volume-name.size Size of the additional volume + ## @extra frontend.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir + ## @extra frontend.persistence.volume-name.mountPath Path where the volume should be mounted to + persistence: {} + + ## @param frontend.extraVolumeMounts Additional volumes to mount on the frontend. + extraVolumeMounts: [] + + ## @param frontend.extraVolumes Additional volumes to mount on the frontend. + extraVolumes: [] + +## @section webrtc + +webrtc: + ## @param webrtc.image.repository Repository to use to pull impress's webrtc container image + ## @param webrtc.image.tag impress's webrtc container tag + ## @param webrtc.image.pullPolicy webrtc container image pull policy + image: + repository: lasuite/impress-y-webrtc-signaling + pullPolicy: IfNotPresent + tag: "latest" + + ## @param webrtc.command Override the webrtc container command + command: [] + + ## @param webrtc.args Override the webrtc container args + args: [] + + ## @param webrtc.replicas Amount of webrtc replicas + replicas: 3 + + ## @param webrtc.shareProcessNamespace Enable share process namewebrtc between containers + shareProcessNamespace: false + + ## @param webrtc.sidecars Add sidecars containers to webrtc deployment + sidecars: [] + + ## @param webrtc.securityContext Configure webrtc Pod security context + securityContext: null + + ## @param webrtc.envVars Configure webrtc container environment variables + ## @extra webrtc.envVars.BY_VALUE Example environment variable by setting value directly + ## @extra webrtc.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap + ## @extra webrtc.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap + ## @extra webrtc.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret + ## @extra webrtc.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret + ## @skip webrtc.envVars + envVars: + <<: *commonEnvVars + + ## @param webrtc.podAnnotations Annotations to add to the webrtc Pod + podAnnotations: {} + + ## @param webrtc.service.type webrtc Service type + ## @param webrtc.service.port webrtc Service listening port + ## @param webrtc.service.targetPort webrtc container listening port + ## @param webrtc.service.annotations Annotations to add to the webrtc Service + service: + type: ClusterIP + port: 443 + targetPort: 4444 + annotations: {} + + ## @param webrtc.probes Configure probe for webrtc + ## @extra webrtc.probes.liveness.path Configure path for webrtc HTTP liveness probe + ## @extra webrtc.probes.liveness.targetPort Configure port for webrtc HTTP liveness probe + ## @extra webrtc.probes.liveness.initialDelaySeconds Configure initial delay for webrtc liveness probe + ## @extra webrtc.probes.liveness.initialDelaySeconds Configure timeout for webrtc liveness probe + ## @extra webrtc.probes.startup.path Configure path for webrtc HTTP startup probe + ## @extra webrtc.probes.startup.targetPort Configure port for webrtc HTTP startup probe + ## @extra webrtc.probes.startup.initialDelaySeconds Configure initial delay for webrtc startup probe + ## @extra webrtc.probes.startup.initialDelaySeconds Configure timeout for webrtc startup probe + ## @extra webrtc.probes.readiness.path Configure path for webrtc HTTP readiness probe + ## @extra webrtc.probes.readiness.targetPort Configure port for webrtc HTTP readiness probe + ## @extra webrtc.probes.readiness.initialDelaySeconds Configure initial delay for webrtc readiness probe + ## @extra webrtc.probes.readiness.initialDelaySeconds Configure timeout for webrtc readiness probe + probes: + liveness: + path: /ping + initialDelaySeconds: 10 + + ## @param webrtc.resources Resource requirements for the webrtc container + resources: {} + + ## @param webrtc.nodeSelector Node selector for the webrtc Pod + nodeSelector: {} + + ## @param webrtc.tolerations Tolerations for the webrtc Pod + tolerations: [] + + ## @param webrtc.affinity Affinity for the webrtc Pod + affinity: {} + + ## @param webrtc.persistence Additional volumes to create and mount on the webrtc. Used for debugging purposes + ## @extra webrtc.persistence.volume-name.size Size of the additional volume + ## @extra webrtc.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir + ## @extra webrtc.persistence.volume-name.mountPath Path where the volume should be mounted to + persistence: {} + + ## @param webrtc.extraVolumeMounts Additional volumes to mount on the webrtc. + extraVolumeMounts: [] + + ## @param webrtc.extraVolumes Additional volumes to mount on the webrtc. + extraVolumes: [] \ No newline at end of file diff --git a/src/mail/bin/html-to-plain-text b/src/mail/bin/html-to-plain-text new file mode 100755 index 00000000..ced0c13d --- /dev/null +++ b/src/mail/bin/html-to-plain-text @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -eo pipefail +# Run html-to-text to convert all html files to text files +DIR_MAILS="../backend/core/templates/mail/" + +if [ ! -d "${DIR_MAILS}" ]; then + mkdir -p "${DIR_MAILS}"; +fi + +if [ ! -d "${DIR_MAILS}"html/ ]; then + mkdir -p "${DIR_MAILS}"html/; + exit; +fi + +for file in "${DIR_MAILS}"html/*.html; + do html-to-text -j ./html-to-text.config.json < "$file" > "${file%.html}".txt; done; + +if [ ! -d "${DIR_MAILS}"text/ ]; then + mkdir -p "${DIR_MAILS}"text/; +fi + +mv "${DIR_MAILS}"html/*.txt "${DIR_MAILS}"text/; diff --git a/src/mail/bin/mjml-to-html b/src/mail/bin/mjml-to-html new file mode 100755 index 00000000..fb5710b0 --- /dev/null +++ b/src/mail/bin/mjml-to-html @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +# Run mjml command to convert all mjml templates to html files +DIR_MAILS="../backend/core/templates/mail/html/" + +if [ ! -d "${DIR_MAILS}" ]; then + mkdir -p "${DIR_MAILS}"; +fi +mjml mjml/*.mjml -o "${DIR_MAILS}"; diff --git a/src/mail/html-to-text.config.json b/src/mail/html-to-text.config.json new file mode 100644 index 00000000..8d2e57aa --- /dev/null +++ b/src/mail/html-to-text.config.json @@ -0,0 +1,11 @@ +{ + "wordwrap": 600, + "selectors": [ + { + "selector": "h1", + "options": { + "uppercase": false + } + } + ] +} diff --git a/src/mail/mjml/invitation.mjml b/src/mail/mjml/invitation.mjml new file mode 100644 index 00000000..74b2b471 --- /dev/null +++ b/src/mail/mjml/invitation.mjml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + +

{% trans "Invitation to join a team" %}

+
+ + + +

{% blocktrans %}Welcome to Impress{% endblocktrans %}

+
+ + + + + + {% trans "We are delighted to welcome you to our community on Impress, your new companion to collaborate on documents efficiently, intuitively, and securely." %} + {% trans "Our application is designed to help you organize, collaborate, and manage permissions." %} + + {% trans "With Impress, you will be able to:" %} +
    +
  • {% trans "Create documents."%}
  • +
  • {% trans "Invite members of your document or community in just a few clicks."%}
  • +
+
+ + {% trans "Visit Impress"%} + + {% trans "We are confident that Impress will help you increase efficiency and productivity while strengthening the bond among members." %} + {% trans "Feel free to explore all the features of the application and share your feedback and suggestions with us. Your feedback is valuable to us and will enable us to continually improve our service." %} + {% trans "Once again, welcome aboard! We are eager to accompany you on you collaboration adventure." %} + + + +

{% trans "Sincerely," %}

+

{% trans "The La Suite Numérique Team" %}

+
+
+
+
+
+ + +
+ diff --git a/src/mail/mjml/partial/footer.mjml b/src/mail/mjml/partial/footer.mjml new file mode 100644 index 00000000..ad343a3a --- /dev/null +++ b/src/mail/mjml/partial/footer.mjml @@ -0,0 +1,9 @@ + + + + {% blocktranslate with href=site.url name=site.name trimmed %} + This mail has been sent to {{email}} by {{name}} + {% endblocktranslate %} + + + diff --git a/src/mail/mjml/partial/header.mjml b/src/mail/mjml/partial/header.mjml new file mode 100644 index 00000000..37bc590f --- /dev/null +++ b/src/mail/mjml/partial/header.mjml @@ -0,0 +1,46 @@ + + {{ title }} + + + {% load i18n static extra_tags %} + {{ title }} + + + + + + + /* Reset */ + h1, h2, h3, h4, h5, h6, p { + margin: 0; + padding: 0; + } + + a { + color: inherit; + } + + + /* Global styles */ + h1 { + color: #161616; + font-size: 2rem; + line-height: 1em; + font-weight: 700; + } + + .wrapper { + background: #FFFFFF; + border-radius: 0 0 6px 6px; + box-shadow: 0 0 6px rgba(2 117 180 / 0.3); + } + + diff --git a/src/mail/package.json b/src/mail/package.json new file mode 100644 index 00000000..3c8cca8a --- /dev/null +++ b/src/mail/package.json @@ -0,0 +1,22 @@ +{ + "name": "mail_mjml", + "version": "0.1.0", + "description": "An util to generate html and text django's templates from mjml templates", + "type": "module", + "dependencies": { + "@html-to/text-cli": "0.5.4", + "mjml": "4.15.3" + }, + "private": true, + "scripts": { + "build-mjml-to-html": "bash ./bin/mjml-to-html", + "build-html-to-plain-text": "bash ./bin/html-to-plain-text", + "build": "yarn build-mjml-to-html && yarn build-html-to-plain-text" + }, + "volta": { + "node": "16.15.1" + }, + "repository": "https://github.com/numerique-gouv/impress", + "author": "DINUM", + "license": "MIT" +} diff --git a/src/mail/yarn.lock b/src/mail/yarn.lock new file mode 100644 index 00000000..c0d5fcc4 --- /dev/null +++ b/src/mail/yarn.lock @@ -0,0 +1,1292 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@babel/runtime@^7.23.9": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd" + integrity sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA== + dependencies: + regenerator-runtime "^0.14.0" + +"@html-to/text-cli@0.5.4": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@html-to/text-cli/-/text-cli-0.5.4.tgz#f40804d139e0eaa43c923c3d845cbf1f8b133acf" + integrity sha512-V7WDfiYjXcibHGD6q61oW8HD68UPvBVkKit0X+9v54nTmLe8KDCc+56STleqqP7CzuEK5f/1jqa652fnr9Pmsw== + dependencies: + "@selderee/plugin-htmlparser2" "^0.11.0" + aspargvs "^0.6.0" + deepmerge "^4.3.1" + htmlparser2 "^8.0.2" + selderee "^0.11.0" + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@selderee/plugin-htmlparser2@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz#d5b5e29a7ba6d3958a1972c7be16f4b2c188c517" + integrity sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ== + dependencies: + domhandler "^5.0.3" + selderee "^0.11.0" + +abbrev@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + +ansi-colors@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a" + integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA== + +ansi-styles@^4.0.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +aspargvs@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/aspargvs/-/aspargvs-0.6.0.tgz#15991c35425b044cb99868b6b3cfa7e051a28424" + integrity sha512-yUrWCd1hkK5UtDOne1gM3O+FoTFGQ+BVlSd4G7FczBz8+JaFn1uzvQzROxwp9hmlhIUtwSwyRuV9mHgd/WbXxg== + dependencies: + peberminta "^0.8.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +camel-case@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" + integrity sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w== + dependencies: + no-case "^2.2.0" + upper-case "^1.1.1" + +cheerio-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" + integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== + dependencies: + boolbase "^1.0.0" + css-select "^5.1.0" + css-what "^6.1.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + +cheerio@1.0.0-rc.12, cheerio@^1.0.0-rc.12: + version "1.0.0-rc.12" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" + integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== + dependencies: + cheerio-select "^2.1.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + domutils "^3.0.1" + htmlparser2 "^8.0.1" + parse5 "^7.0.0" + parse5-htmlparser2-tree-adapter "^7.0.0" + +chokidar@^3.0.0: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +clean-css@^4.2.1: + version "4.2.4" + resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178" + integrity sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A== + dependencies: + source-map "~0.6.0" + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^2.19.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +commander@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + +config-chain@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +cross-spawn@^7.0.0: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-what@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +detect-node@2.1.0, detect-node@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" + integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== + +dom-serializer@^1.0.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" + integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== + dependencies: + domelementtype "^2.0.1" + domhandler "^4.2.0" + entities "^2.0.0" + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" + integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== + dependencies: + domelementtype "^2.0.1" + +domhandler@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" + integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== + dependencies: + domelementtype "^2.2.0" + +domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^2.4.2: + version "2.8.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" + integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== + dependencies: + dom-serializer "^1.0.1" + domelementtype "^2.2.0" + domhandler "^4.2.0" + +domutils@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.0.1.tgz#696b3875238338cb186b6c0612bd4901c89a4f1c" + integrity sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.1" + +domutils@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" + integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +editorconfig@^0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" + integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g== + dependencies: + commander "^2.19.0" + lru-cache "^4.1.5" + semver "^5.6.0" + sigmund "^1.0.1" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + +entities@^4.2.0, entities@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" + integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== + +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +escalade@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" + integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + +escape-goat@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-3.0.0.tgz#e8b5fb658553fe8a3c4959c316c6ebb8c842b19c" + integrity sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw== + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +foreground-child@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d" + integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg== + dependencies: + cross-spawn "^7.0.0" + signal-exit "^4.0.1" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^10.3.10: + version "10.3.12" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.12.tgz#3a65c363c2e9998d220338e88a5f6ac97302960b" + integrity sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^2.3.6" + minimatch "^9.0.1" + minipass "^7.0.4" + path-scurry "^1.10.2" + +glob@^8.0.3: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +html-minifier@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-4.0.0.tgz#cca9aad8bce1175e02e17a8c33e46d8988889f56" + integrity sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig== + dependencies: + camel-case "^3.0.0" + clean-css "^4.2.1" + commander "^2.19.0" + he "^1.2.0" + param-case "^2.1.1" + relateurl "^0.2.7" + uglify-js "^3.5.1" + +htmlparser2@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-5.0.1.tgz#7daa6fc3e35d6107ac95a4fc08781f091664f6e7" + integrity sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ== + dependencies: + domelementtype "^2.0.1" + domhandler "^3.3.0" + domutils "^2.4.2" + entities "^2.0.0" + +htmlparser2@^8.0.1, htmlparser2@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + +htmlparser2@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23" + integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.1.0" + entities "^4.5.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" + integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +js-beautify@^1.6.14: + version "1.14.7" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.14.7.tgz#9206296de33f86dc106d3e50a35b7cf8729703b2" + integrity sha512-5SOX1KXPFKx+5f6ZrPsIPEY7NwKeQz47n3jm2i+XeHx9MoRsfQenlOP13FQhWvg8JRS0+XLO6XYUQ2GX+q+T9A== + dependencies: + config-chain "^1.1.13" + editorconfig "^0.15.3" + glob "^8.0.3" + nopt "^6.0.0" + +juice@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/juice/-/juice-10.0.0.tgz#c6b717ded8be4b969f12503ac9cfbd2604d35937" + integrity sha512-9f68xmhGrnIi6DBkiiP3rUrQN33SEuaKu1+njX6VgMP+jwZAsnT33WIzlrWICL9matkhYu3OyrqSUP55YTIdGg== + dependencies: + cheerio "^1.0.0-rc.12" + commander "^6.1.0" + mensch "^0.3.4" + slick "^1.12.2" + web-resource-inliner "^6.0.1" + +leac@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" + integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== + +lodash@^4.17.15, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +lower-case@^1.1.1: + version "1.1.4" + resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" + integrity sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA== + +lru-cache@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" + integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== + +lru-cache@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + +mensch@^0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/mensch/-/mensch-0.3.4.tgz#770f91b46cb16ea5b204ee735768c3f0c491fecd" + integrity sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g== + +mime@^2.4.6: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.1, minimatch@^9.0.3: + version "9.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" + integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== + dependencies: + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.4: + version "7.0.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.4.tgz#dbce03740f50a4786ba994c1fb908844d27b038c" + integrity sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ== + +mjml-accordion@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-accordion/-/mjml-accordion-4.15.3.tgz#10e4c4297df3ad8dfa709fc64e887f89bfbff0a8" + integrity sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-body@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-body/-/mjml-body-4.15.3.tgz#8885b2921f6daa1a287e8aea0924ee1fc4aaf222" + integrity sha512-7pfUOVPtmb0wC+oUOn4xBsAw4eT5DyD6xqaxj/kssu6RrFXOXgJaVnDPAI9AzIvXJ/5as9QrqRGYAddehwWpHQ== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-button@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-button/-/mjml-button-4.15.3.tgz#34baf2d7fbf77a5febe6993e311103723279adbd" + integrity sha512-79qwn9AgdGjJR1vLnrcm2rq2AsAZkKC5JPwffTMG+Nja6zGYpTDZFZ56ekHWr/r1b5WxkukcPj2PdevUug8c+Q== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-carousel@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-carousel/-/mjml-carousel-4.15.3.tgz#fe82d2c4c8020ef14f3b360316c670f7da294193" + integrity sha512-3ju6I4l7uUhPRrJfN3yK9AMsfHvrYbRkcJ1GRphFHzUj37B2J6qJOQUpzA547Y4aeh69TSb7HFVf1t12ejQxVw== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-cli@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-cli/-/mjml-cli-4.15.3.tgz#5638f1919c952d224f51970a2fbf3141dee6d487" + integrity sha512-+V2TDw3tXUVEptFvLSerz125C2ogYl8klIBRY1m5BHd4JvGVf3yhx8N3PngByCzA6PGcv/eydGQN+wy34SHf0Q== + dependencies: + "@babel/runtime" "^7.23.9" + chokidar "^3.0.0" + glob "^10.3.10" + html-minifier "^4.0.0" + js-beautify "^1.6.14" + lodash "^4.17.21" + minimatch "^9.0.3" + mjml-core "4.15.3" + mjml-migrate "4.15.3" + mjml-parser-xml "4.15.3" + mjml-validator "4.15.3" + yargs "^17.7.2" + +mjml-column@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-column/-/mjml-column-4.15.3.tgz#ffc538f6b87a7340697f88600330110a40f82c05" + integrity sha512-hYdEFdJGHPbZJSEysykrevEbB07yhJGSwfDZEYDSbhQQFjV2tXrEgYcFD5EneMaowjb55e3divSJxU4c5q4Qgw== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-core@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-core/-/mjml-core-4.15.3.tgz#96c30f49340b95bb9c825a6479557cc9ad1af6c6" + integrity sha512-Dmwk+2cgSD9L9GmTbEUNd8QxkTZtW9P7FN/ROZW/fGZD6Hq6/4TB0zEspg2Ow9eYjZXO2ofOJ3PaQEEShKV0kQ== + dependencies: + "@babel/runtime" "^7.23.9" + cheerio "1.0.0-rc.12" + detect-node "^2.0.4" + html-minifier "^4.0.0" + js-beautify "^1.6.14" + juice "^10.0.0" + lodash "^4.17.21" + mjml-migrate "4.15.3" + mjml-parser-xml "4.15.3" + mjml-validator "4.15.3" + +mjml-divider@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-divider/-/mjml-divider-4.15.3.tgz#2aadaf7e9955a9d9473f7093598f933aa289c683" + integrity sha512-vh27LQ9FG/01y0b9ntfqm+GT5AjJnDSDY9hilss2ixIUh0FemvfGRfsGVeV5UBVPBKK7Ffhvfqc7Rciob9Spzw== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-group@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-group/-/mjml-group-4.15.3.tgz#7e4418d7d4b5d5d5e4d6af9865c25d6d358a7f75" + integrity sha512-HSu/rKnGZVKFq3ciT46vi1EOy+9mkB0HewO4+P6dP/Y0UerWkN6S3UK11Cxsj0cAp0vFwkPDCdOeEzRdpFEkzA== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-head-attributes@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-head-attributes/-/mjml-head-attributes-4.15.3.tgz#4c81e561982fca2657bf3dda7576fcafec778b66" + integrity sha512-2ISo0r5ZKwkrvJgDou9xVPxxtXMaETe2AsAA02L89LnbB2KC0N5myNsHV0sEysTw9+CfCmgjAb0GAI5QGpxKkQ== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-head-breakpoint@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-head-breakpoint/-/mjml-head-breakpoint-4.15.3.tgz#be1fbe6b4f6cd77f7f666b2cb9e48e81f727b74f" + integrity sha512-Eo56FA5C2v6ucmWQL/JBJ2z641pLOom4k0wP6CMZI2utfyiJ+e2Uuinj1KTrgDcEvW4EtU9HrfAqLK9UosLZlg== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-head-font@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-head-font/-/mjml-head-font-4.15.3.tgz#0340872d0ffe9e29044d66ede452575cb7da3ddf" + integrity sha512-CzV2aDPpiNIIgGPHNcBhgyedKY4SX3BJoTwOobSwZVIlEA6TAWB4Z9WwFUmQqZOgo1AkkiTHPZQvGcEhFFXH6g== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-head-html-attributes@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-head-html-attributes/-/mjml-head-html-attributes-4.15.3.tgz#852710724b976fac7aabd648f5f9770bfa1e21e5" + integrity sha512-MDNDPMBOgXUZYdxhosyrA2kudiGO8aogT0/cODyi2Ed9o/1S7W+je11JUYskQbncqhWKGxNyaP4VWa+6+vUC/g== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-head-preview@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-head-preview/-/mjml-head-preview-4.15.3.tgz#710ce159974bf2924edb7f920dd05280a433afd3" + integrity sha512-J2PxCefUVeFwsAExhrKo4lwxDevc5aKj888HBl/wN4EuWOoOg06iOGCxz4Omd8dqyFsrqvbBuPqRzQ+VycGmaA== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-head-style@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-head-style/-/mjml-head-style-4.15.3.tgz#66a9a3926888681578c2550c7444e4f8cbddfda3" + integrity sha512-9J+JuH+mKrQU65CaJ4KZegACUgNIlYmWQYx3VOBR/tyz+8kDYX7xBhKJCjQ1I4wj2Tvga3bykd89Oc2kFZ5WOw== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-head-title@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-head-title/-/mjml-head-title-4.15.3.tgz#ccbd11a7771965f5ac5f3069f6c4f74668c9e6ea" + integrity sha512-IM59xRtsxID4DubQ0iLmoCGXguEe+9BFG4z6y2xQDrscIa4QY3KlfqgKGT69ojW+AVbXXJPEVqrAi4/eCsLItQ== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-head@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-head/-/mjml-head-4.15.3.tgz#3e7311af0de4911dd167c877cf04d4291206cd2f" + integrity sha512-o3mRuuP/MB5fZycjD3KH/uXsnaPl7Oo8GtdbJTKtH1+O/3pz8GzGMkscTKa97l03DAG2EhGrzzLcU2A6eshwFw== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-hero@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-hero/-/mjml-hero-4.15.3.tgz#c51d9f6d1f37acf7e35d827ce3116f8a4aaf9037" + integrity sha512-9cLAPuc69yiuzNrMZIN58j+HMK1UWPaq2i3/Fg2ZpimfcGFKRcPGCbEVh0v+Pb6/J0+kf8yIO0leH20opu3AyQ== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-image@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-image/-/mjml-image-4.15.3.tgz#e652a4b18663c7d93cc22d88eed45f3fdb9c82ea" + integrity sha512-g1OhSdofIytE9qaOGdTPmRIp7JsCtgO0zbsn1Fk6wQh2gEL55Z40j/VoghslWAWTgT2OHFdBKnMvWtN6U5+d2Q== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-migrate@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-migrate/-/mjml-migrate-4.15.3.tgz#65e2b335a2ffc7e29e09f96793961d0e8f081d98" + integrity sha512-sr/+35RdxZroNQVegjpfRHJ5hda9XCgaS4mK2FGO+Mb1IUevKfeEPII3F/cHDpNwFeYH3kAgyqQ22ClhGLWNBA== + dependencies: + "@babel/runtime" "^7.23.9" + js-beautify "^1.6.14" + lodash "^4.17.21" + mjml-core "4.15.3" + mjml-parser-xml "4.15.3" + yargs "^17.7.2" + +mjml-navbar@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-navbar/-/mjml-navbar-4.15.3.tgz#c9805a98f24a475dd3feece58e690838c075fdff" + integrity sha512-VsKH/Jdlf8Yu3y7GpzQV5n7JMdpqvZvTSpF6UQXL0PWOm7k6+LX+sCZimOfpHJ+wCaaybpxokjWZ71mxOoCWoA== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-parser-xml@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-parser-xml/-/mjml-parser-xml-4.15.3.tgz#8b94550dbe0d16155ea6cd1fb34bc53dba6f59ed" + integrity sha512-Tz0UX8/JVYICLjT+U8J1f/TFxIYVYjzZHeh4/Oyta0pLpRLeZlxEd71f3u3kdnulCKMP4i37pFRDmyLXAlEuLw== + dependencies: + "@babel/runtime" "^7.23.9" + detect-node "2.1.0" + htmlparser2 "^9.1.0" + lodash "^4.17.15" + +mjml-preset-core@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-preset-core/-/mjml-preset-core-4.15.3.tgz#d4972292b7db42b51d08feb1104ad23ee5d3b87f" + integrity sha512-1zZS8P4O0KweWUqNS655+oNnVMPQ1Rq1GaZq5S9JfwT1Vh/m516lSmiTW9oko6gGHytt5s6Yj6oOeu5Zm8FoLw== + dependencies: + "@babel/runtime" "^7.23.9" + mjml-accordion "4.15.3" + mjml-body "4.15.3" + mjml-button "4.15.3" + mjml-carousel "4.15.3" + mjml-column "4.15.3" + mjml-divider "4.15.3" + mjml-group "4.15.3" + mjml-head "4.15.3" + mjml-head-attributes "4.15.3" + mjml-head-breakpoint "4.15.3" + mjml-head-font "4.15.3" + mjml-head-html-attributes "4.15.3" + mjml-head-preview "4.15.3" + mjml-head-style "4.15.3" + mjml-head-title "4.15.3" + mjml-hero "4.15.3" + mjml-image "4.15.3" + mjml-navbar "4.15.3" + mjml-raw "4.15.3" + mjml-section "4.15.3" + mjml-social "4.15.3" + mjml-spacer "4.15.3" + mjml-table "4.15.3" + mjml-text "4.15.3" + mjml-wrapper "4.15.3" + +mjml-raw@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-raw/-/mjml-raw-4.15.3.tgz#ab771a3d9b5b05583ff90653bf7ca74ec96ffc20" + integrity sha512-IGyHheOYyRchBLiAEgw3UM11kFNmBSMupu2BDdejC6ZiDhEAdG+tyERlsCwDPYtXanvFpGWULIu3XlsUPc+RZw== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-section@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-section/-/mjml-section-4.15.3.tgz#ba2b524449b18a4fbbdf05c223a0627e02afa7a9" + integrity sha512-JfVPRXH++Hd933gmQfG8JXXCBCR6fIzC3DwiYycvanL/aW1cEQ2EnebUfQkt5QzlYjOkJEH+JpccAsq3ln6FZQ== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-social@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-social/-/mjml-social-4.15.3.tgz#8d1ac1dfd3c56077e1106ead283a40878a2c32d9" + integrity sha512-7sD5FXrESOxpT9Z4Oh36bS6u/geuUrMP1aCg2sjyAwbPcF1aWa2k9OcatQfpRf6pJEhUZ18y6/WBBXmMVmSzXg== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-spacer@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-spacer/-/mjml-spacer-4.15.3.tgz#9a2a4b9d51df2e9cae9fbe9848fd722ef0dfd335" + integrity sha512-3B7Qj+17EgDdAtZ3NAdMyOwLTX1jfmJuY7gjyhS2HtcZAmppW+cxqHUBwCKfvSRgTQiccmEvtNxaQK+tfyrZqA== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-table@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-table/-/mjml-table-4.15.3.tgz#702271761e450172bd5dda9ffcb2faefed3f5db0" + integrity sha512-FLx7DcRKTdKdcOCbMyBaeudeHaHpwPveRrBm6WyQe3LXx6FfdmOh59i71/16LFQMgBOD3N4/UJkzxLzlTJzMqQ== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-text@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-text/-/mjml-text-4.15.3.tgz#045ca711b0c18d2ba163c5a9f296a0c7ed82dbfc" + integrity sha512-+C0hxCmw9kg0XzT6vhE5mFkK6y225nC8UEQcN94K0fBCjPKkM+HqZMwGX205fzdGRi+Bxa55b/VhrIVwdv+8vw== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + +mjml-validator@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-validator/-/mjml-validator-4.15.3.tgz#c7934ca66ff41fa7293927b1328cfbafa8268ffb" + integrity sha512-Xb72KdqRwjv/qM2rJpV22syyP2N3cRQ9VVDrN6u2FSzLq02buFNxmSPJ7CKhat3PrUNdVHU75KZwOf/tz4UEhA== + dependencies: + "@babel/runtime" "^7.23.9" + +mjml-wrapper@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml-wrapper/-/mjml-wrapper-4.15.3.tgz#6526824608514561376ecfdab079275f53cc8706" + integrity sha512-ditsCijeHJrmBmObtJmQ18ddLxv5oPyMTdPU8Di8APOnD2zPk7Z4UAuJSl7HXB45oFiivr3MJf4koFzMUSZ6Gg== + dependencies: + "@babel/runtime" "^7.23.9" + lodash "^4.17.21" + mjml-core "4.15.3" + mjml-section "4.15.3" + +mjml@4.15.3: + version "4.15.3" + resolved "https://registry.yarnpkg.com/mjml/-/mjml-4.15.3.tgz#d46996d63e957ae946b2da6ca78fcef5186beee9" + integrity sha512-bW2WpJxm6HS+S3Yu6tq1DUPFoTxU9sPviUSmnL7Ua+oVO3WA5ILFWqvujUlz+oeuM+HCwEyMiP5xvKNPENVjYA== + dependencies: + "@babel/runtime" "^7.23.9" + mjml-cli "4.15.3" + mjml-core "4.15.3" + mjml-migrate "4.15.3" + mjml-preset-core "4.15.3" + mjml-validator "4.15.3" + +no-case@^2.2.0: + version "2.3.2" + resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac" + integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ== + dependencies: + lower-case "^1.1.1" + +node-fetch@^2.6.0: + version "2.6.9" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.9.tgz#7c7f744b5cc6eb5fd404e0c7a9fec630a55657e6" + integrity sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg== + dependencies: + whatwg-url "^5.0.0" + +nopt@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d" + integrity sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g== + dependencies: + abbrev "^1.0.0" + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +param-case@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247" + integrity sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w== + dependencies: + no-case "^2.2.0" + +parse5-htmlparser2-tree-adapter@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" + integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g== + dependencies: + domhandler "^5.0.2" + parse5 "^7.0.0" + +parse5@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + +parseley@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.0.tgz#e0dc6b0f99201d136ce756d5f3cec9461ad09830" + integrity sha512-uLqDm6IQVb6m50a3dIxF66hI8VWr7wFDYUULtHa1ITRh9mwYIXzFpPTkPM66Cm5V0t+bMyeSHgUCGzoXTV96LQ== + dependencies: + leac "^0.6.0" + peberminta "^0.9.0" + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.10.2: + version "1.10.2" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.2.tgz#8f6357eb1239d5fa1da8b9f70e9c080675458ba7" + integrity sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +peberminta@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.8.0.tgz#acf7b105f3d13c8ac28cad81f2f5fe4698507590" + integrity sha512-YYEs+eauIjDH5nUEGi18EohWE0nV2QbGTqmxQcqgZ/0g+laPCQmuIqq7EBLVi9uim9zMgfJv0QBZEnQ3uHw/Tw== + +peberminta@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352" + integrity sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + integrity sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ== + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + +relateurl@^0.2.7: + version "0.2.7" + resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" + integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +selderee@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.11.0.tgz#6af0c7983e073ad3e35787ffe20cefd9daf0ec8a" + integrity sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA== + dependencies: + parseley "^0.12.0" + +semver@^5.6.0: + version "5.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" + integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +sigmund@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" + integrity sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +slick@^1.12.2: + version "1.12.2" + resolved "https://registry.yarnpkg.com/slick/-/slick-1.12.2.tgz#bd048ddb74de7d1ca6915faa4a57570b3550c2d7" + integrity sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A== + +source-map@~0.6.0: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +uglify-js@^3.5.1: + version "3.17.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" + integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== + +upper-case@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" + integrity sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA== + +valid-data-url@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/valid-data-url/-/valid-data-url-3.0.1.tgz#826c1744e71b5632e847dd15dbd45b9fb38aa34f" + integrity sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA== + +web-resource-inliner@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz#df0822f0a12028805fe80719ed52ab6526886e02" + integrity sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A== + dependencies: + ansi-colors "^4.1.1" + escape-goat "^3.0.0" + htmlparser2 "^5.0.0" + mime "^2.4.6" + node-fetch "^2.6.0" + valid-data-url "^3.0.0" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + integrity sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" diff --git a/src/tsclient/package.json b/src/tsclient/package.json new file mode 100644 index 00000000..7b76906f --- /dev/null +++ b/src/tsclient/package.json @@ -0,0 +1,25 @@ +{ + "name": "impress-openapi-client-ts", + "version": "0.1.0", + "private": true, + "description": "Tool to generate Typescript API client for the impress application.", + "scripts": { + "generate:api:client:local": "./scripts/openapi-typescript-codegen/generate_api_client_local.sh $1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/numerique-gouv/impress.git" + }, + "author": { + "name": "DINUM", + "email": "dev@mail.numerique.gouv.fr" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/numerique-gouv/impress/issues" + }, + "homepage": "https://github.com/numerique-gouv/impress#readme", + "devDependencies": { + "openapi-typescript-codegen": "0.29.0" + } +} diff --git a/src/tsclient/scripts/openapi-typescript-codegen/generate_api_client_local.sh b/src/tsclient/scripts/openapi-typescript-codegen/generate_api_client_local.sh new file mode 100755 index 00000000..4a570f68 --- /dev/null +++ b/src/tsclient/scripts/openapi-typescript-codegen/generate_api_client_local.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +# usage: yarn generate:api:client:local [--output] + +# OPTIONS: +# --output the path folder where types will be generated + +openapi --input http://app-dev:8000/v1.0/swagger.json --output $1 --indent='2' --name ApiClientImpress --useOptions diff --git a/src/tsclient/yarn.lock b/src/tsclient/yarn.lock new file mode 100644 index 00000000..70280aac --- /dev/null +++ b/src/tsclient/yarn.lock @@ -0,0 +1,120 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@apidevtools/json-schema-ref-parser@^11.5.4": + version "11.5.4" + resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.5.4.tgz#6a90caf2140834025cf72651280c46084de187ae" + integrity sha512-o2fsypTGU0WxRxbax8zQoHiIB4dyrkwYfcm8TxZ+bx9pCzcWZbQtiMqpgBvWA/nJ2TrGjK5adCLfTH8wUeU/Wg== + dependencies: + "@jsdevtools/ono" "^7.1.3" + "@types/json-schema" "^7.0.15" + js-yaml "^4.1.0" + +"@jsdevtools/ono@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" + integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +camelcase@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +commander@^12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-12.0.0.tgz#b929db6df8546080adfd004ab215ed48cf6f2592" + integrity sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA== + +fs-extra@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" + integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== + +handlebars@^4.7.8: + version "4.7.8" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" + integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +minimist@^1.2.5: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +openapi-typescript-codegen@0.29.0: + version "0.29.0" + resolved "https://registry.yarnpkg.com/openapi-typescript-codegen/-/openapi-typescript-codegen-0.29.0.tgz#e98a1daa223ccdeb1cc51b2e2dc11bafae6fe746" + integrity sha512-/wC42PkD0LGjDTEULa/XiWQbv4E9NwLjwLjsaJ/62yOsoYhwvmBR31kPttn1DzQ2OlGe5stACcF/EIkZk43M6w== + dependencies: + "@apidevtools/json-schema-ref-parser" "^11.5.4" + camelcase "^6.3.0" + commander "^12.0.0" + fs-extra "^11.2.0" + handlebars "^4.7.8" + +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +uglify-js@^3.1.4: + version "3.17.4" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c" + integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g== + +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==