Compare commits

...

40 Commits

Author SHA1 Message Date
Anthony LC 2a7e3116bd 🔖(minor) release 1.3.0
Added:
- Add image attachments with access control
- (frontend) Upload image to a document
- (frontend) Summary
- (frontend) update meta title for docs page

Changed:
- 💄(frontend) code background darkened on editor
- 🔥(frontend) hide markdown button if not text

Fixed:
- 🐛 Fix emoticon in pdf export
- 🐛 Fix collaboration on document
- 🐛 (docker) Fix compatibility with mac

Removed:
- 🔥(frontend) remove saving modal
2024-09-10 09:04:54 +02:00
Anthony LC b9046a2d9b 🐛(frontend) meta title rerender issue
The meta title is not displayed when we come back to
a page from the dynamic router. The code seems to
compute to quickly so we need to add a delay to the
meta title computation.
2024-09-09 17:47:24 +02:00
Anthony LC d249ed0c71 🔧(helm) change production media storage name
The set the correct media storage name
for production environment.
2024-09-09 16:36:24 +02:00
Anthony LC 48d3738ec2 (frontend) update meta title for docs page
We update the meta title for the docs page
with the title of the document.
It will be easier for the user
to identify the document in their browser tab,
in their bookmarks and history.
2024-09-05 13:26:49 +02:00
Anthony LC 92102e4a36 🔧(compose) stop forcing platform for Keycloak PostgreSQL image
Forcing `platform: linux/amd64` for the PostgreSQL
image causes compatibility issues and performance
degradation on Mac ARM chips (M1/M2).
Removing the platform specification allows Docker
to select the appropriate architecture automatically,
ensuring better performance and compatibility.
2024-09-05 12:09:15 +02:00
Anthony LC dd1b271b71 🌐(frontend) translate last features
Translate:
- doc versions
- doc summary
- export docx
2024-09-05 09:30:56 +02:00
Anthony LC 7cfc1d8036 ⬆️(i18n) i18next-parser to 9.0.2
i18next-parser had a compatibility issue with
a dependency (cheerio). The last version
fixed this issue, plus fixed another issue
about a configuration problem.
We can now remove it from the renovate ignore list.
2024-09-05 09:30:56 +02:00
Anthony LC 86fdbeacaa 🔥(frontend) do not display feature version
A bug was found in the version feature.
A 404 error appears sometimes, probably because
of Minio that does not keep enough versions.
We want to do a realease, so we will remove the
version feature for now.
2024-09-05 09:30:56 +02:00
Anthony LC 520d511f59 🔧(project) replace webrtc by yProvider
Replace webrtc by yProvider the project
(docker, helm chart, etc).
2024-09-04 21:10:24 +02:00
Anthony LC 9c512fae69 ♻️(y-provider) replace y-webrtc-signaling by server-y-provider
We replace the y-webrtc-signaling app by
the server-y-provider server.
The server-y-provider server uses @hocuspocus to
do collaborative editing on docs.
2024-09-04 21:10:24 +02:00
Anthony LC 1139c0abea ♻️(frontend) replace y-webrtc by @hocuspocus
y-webrtc had some issues, users had difficulties
to connect with each others.
We replace it by @hocuspocus/provider.
2024-09-04 21:10:24 +02:00
Anthony LC 9e1979f637 🐛(docker) add emoji font
In order to have the emoji font available in
the container, we need to install it.
The font will be then available in the
PDF export.
2024-09-03 17:37:56 +02:00
Anthony LC ddd93ab0c5 🐛(frontend) close panel when unmount
When the panel is unmounted, the summary and
version panel should be closed.
2024-09-03 17:37:56 +02:00
Anthony LC 85044fd665 (frontend) summary feature
Add the summary feature to the doc.
We will be able to access part of the doc quickly
from the summary.
2024-09-03 15:55:25 +02:00
Anthony LC b83875fc97 ♻️(frontend) move Panel component
We will have multiple Panel components in the future,
so we move it to the root of the components folder.
We refacto the Version Panel to use the new
Panel component.
2024-09-03 15:55:25 +02:00
Anthony LC 7a8caf5475 🐛(backend) compatibility issue with django and easy_thumbnails
There is a compatibility issue between django 5.1
and easy_thumbnails 2.9.
This commit fixes the issue.
2024-09-03 11:36:50 +02:00
renovate[bot] e927f2c004 ⬆️(dependencies) update python dependencies 2024-09-03 11:36:50 +02:00
Anthony LC 7f25b05474 ⚗️(frontend) add button Restore this version near title
When a user is on a page version, we will display
a button "Restore this version" near the title of
the page. It gives an obvious way to restore the
version of the doc.
2024-09-02 17:02:23 +02:00
Anthony LC 296b5dbf59 ♻️(frontend) add modal confirmation restore version
Add modal confirmation restore version explaining
that the current version will be replaced
by the selected version, and that some data
may be lost.
2024-09-02 17:02:23 +02:00
Anthony LC accbda44e2 ♻️(frontend) open version panel from docs options
Versions panel is a feature that will not be used
by all users, so it should be hidden
by default. The user can open it from the docs
options.
2024-09-02 17:02:23 +02:00
Anthony LC f2a78ada47 🔧(helm) replace storage url in ingressMedia
There is no mechanism to have the media storage
URL from a secret from the ingress.
The media storage URL has to be hardcoded.
We replace the media storage URL in the ingress,
if we change the cluster, we will have to update
these urls.
2024-09-02 12:17:40 +02:00
renovate[bot] 4cb0423511 ⬆️(dependencies) update js dependencies 2024-09-02 09:50:21 +02:00
Anthony LC 766aee6a92 💄(frontend) code background darkened on editor
The "code" was not visible on the editor
because the background was too light.
The background color was darkened to make the
"code" more visible.
2024-08-30 15:58:25 +02:00
Anthony LC 3d19893091 🔥(frontend) remove saving modal
The saving toast are removed.
Users were complaining about the toast
that was shown when saving a document.
2024-08-30 15:43:48 +02:00
Anthony LC 00b223f648 🔥(frontend) hide markdown button if not text
If we are selected a block that is not a text block,
we hide the markdown button.
2024-08-30 15:43:48 +02:00
Anthony LC 38b32c1227 ️(tilt) stop kind-registry to restart when stopped
To save resources, it is nive to be able to stop
the kind-registry when it is not needed.
This commit allow us to stop it.
2024-08-29 18:31:26 +02:00
Anthony LC 1ff3d9c54e 🧑‍💻(ngnix) add conf ngnix to proxy media url
In development mode with docker-compose, we need to
configure Nginx to proxy requests to the Minio server.
Before to proxy to Minio, we need to
authenticate the request, so we proxy to the
Django server first to fill the request with the
necessary headers, then we proxy to Minio.
2024-08-29 18:31:26 +02:00
Anthony LC 6eff21f51e (frontend) add upload to the doc editor
We can now upload images to the doc editor.
The image is uploaded to the server
and the URL is inserted into the editor.
2024-08-29 18:31:26 +02:00
Anthony LC 3eb8f88b5c 👔(frontend) integrate attachment-upload endpoint
Integrate the `documents/${docId}/attachment-upload/`
endpoint. This endpoint is used to upload attachments
to a document.
To have automatically the good content-type form-data,
the `fetchApi` function has been updated to remove the
prefill `Content-Type` header.
2024-08-29 18:31:26 +02:00
renovate[bot] 3a3483b776 ⬆️(dependencies) update js dependencies 2024-08-29 14:11:11 +02:00
Samuel Paccoud - DINUM 67a20f249e (backend) add url to download media attachments with access rights
We make use of nginx subrequests to block media file downloads while
we check for access rights. The request is then proxied to the object
storage engine and authorization is added via the "Authorization"
header. This way the media urls are static and can be stored in the
document's json content without compromising on security: access
control is done on all requests based on the user cookie session.
2024-08-27 15:59:44 +02:00
Samuel Paccoud - DINUM c9f1356d3e (backend) allow uploading images as attachments to a document
We only rely on S3 to store attachments for a document. Nothing
is persisted in the database as the image media urls will be
stored in the document json.
2024-08-27 15:59:44 +02:00
Samuel Paccoud - DINUM f12708acee ⬆️(backend) upgrade boto3 to 1.14.4 for unsigned urls
For media urls, we want to compute authorization as a header
instead of computing signed urls.

The url of a media file can then be computed without the
querystring authorization part. This requires upgrading
django-storages to the 1.14 version to benefit from the
"unsigned connection" in the S3Storage backend.
2024-08-27 15:59:44 +02:00
Anthony LC 58eaea000c 🔖(patch) release 1.2.1
Changed:
- ♻️ Change ordering docs datagrid
- 🔥(helm) use scaleway email
2024-08-23 16:27:52 +02:00
Anthony LC 7d97a037f6 🧑‍💻(makefile) bump-packages-version
Create the command bump-packages-version in Makefile.
This command will bump the version of
all the javascript packages in the project.
2024-08-23 16:27:52 +02:00
Anthony LC c830b4dae6 ♻️(email) replace base64 image with a link
The emails were too big, gmail by example was not
able to display them correctly.
It was caused by base64 image, so they are
replaced with a link to the image.

We fixed the link to the website, it will improve
the score of the email.
2024-08-23 15:37:01 +02:00
Anthony LC a0fe98e156 🔥(helm) configure staging to use scaleway email
Change staging configuration to use scaleway transactionnal email
2024-08-23 15:37:01 +02:00
Anthony LC fa105e5b54 📌(e2e) pin pdf-parse to 1.1.1
pdf-parse was not pinned to a specific version.
This could lead to unexpected behavior
if the package is updated.
This change pins pdf-parse to version 1.1.1.
2024-08-23 14:29:52 +02:00
Anthony LC ced850aecf ♻️(frontend) datagrid ordered by updated_at desc
The datagrid is now ordered by updated_at desc.
2024-08-23 14:29:52 +02:00
Anthony LC 3a420c0416 ♻️(backend) document list order by updated_at desc
Document list is now ordered by updated_at in
descending order.
Test cases were improved as well.
2024-08-23 14:29:52 +02:00
94 changed files with 3819 additions and 2873 deletions
+3 -3
View File
@@ -104,7 +104,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-y-webrtc-signaling:
build-and-push-y-provider:
runs-on: ubuntu-latest
steps:
-
@@ -132,7 +132,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/impress-y-webrtc-signaling
images: lasuite/impress-y-provider
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
@@ -143,7 +143,7 @@ jobs:
with:
context: .
file: ./src/frontend/Dockerfile
target: y-webrtc-signaling
target: y-provider
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+2 -2
View File
@@ -139,7 +139,7 @@ jobs:
with:
targets: |
app-dev
y-webrtc-signaling
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
@@ -213,7 +213,7 @@ jobs:
with:
targets: |
app-dev
y-webrtc-signaling
y-provider
load: true
set: |
*.cache-from=type=gha,scope=cached-stage
+38 -2
View File
@@ -6,9 +6,43 @@ 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]
## [1.2.0] - 2024-08-06
## [1.3.0] - 2024-09-05
## Added
- ✨Add image attachments with access control
- ✨(frontend) Upload image to a document #211
- ✨(frontend) Summary #223
- ✨(frontend) update meta title for docs page #231
## Changed
- 💄(frontend) code background darkened on editor #214
- 🔥(frontend) hide markdown button if not text #213
## Fixed
- 🐛 Fix emoticon in pdf export #225
- 🐛 Fix collaboration on document #226
- 🐛 (docker) Fix compatibility with mac #230
## Removed
- 🔥(frontend) remove saving modal #213
## [1.2.1] - 2024-08-23
## Changed
- ♻️ Change ordering docs datagrid #195
- 🔥(helm) use scaleway email #194
## [1.2.0] - 2024-08-22
## Added
@@ -97,7 +131,9 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.2.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.3.0...main
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
+1
View File
@@ -76,6 +76,7 @@ RUN apt-get update && \
libpango-1.0-0 \
libpangocairo-1.0-0 \
pandoc \
fonts-noto-color-emoji \
shared-mime-info && \
rm -rf /var/lib/apt/lists/*
+11 -1
View File
@@ -104,7 +104,7 @@ logs: ## display app-dev logs (follow mode)
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-webrtc-signaling
@$(COMPOSE) up --force-recreate -d y-provider
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
@@ -313,3 +313,13 @@ start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
VERSION_TYPE ?= minor
bump-packages-version: ## bump the version of the project - VERSION_TYPE can be "major", "minor", "patch"
cd ./src/mail && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/e2e/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/apps/impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/servers/y-provider/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/eslint-config-impress/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
cd ./src/frontend/packages/i18n/ && yarn version --no-git-tag-version --$(VERSION_TYPE)
.PHONY: bump-packages-version
+3 -3
View File
@@ -18,13 +18,13 @@ docker_build(
)
docker_build(
'localhost:5001/impress-y-webrtc-signaling:latest',
'localhost:5001/impress-y-provider:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend/', './docker/', './.dockerignore'],
target = 'y-webrtc-signaling',
target = 'y-provider',
live_update=[
sync('../src/frontend/apps/y-webrtc-signaling/src', '/home/frontend/apps/y-webrtc-signaling/src'),
sync('../src/frontend/servers/y-provider/src', '/home/frontend/servers/y-provider/src'),
]
)
+1 -1
View File
@@ -16,7 +16,7 @@ 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}" \
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
+6 -7
View File
@@ -141,19 +141,19 @@ services:
volumes:
- ".:/app"
y-webrtc-signaling:
y-provider:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: y-webrtc-signaling
target: y-provider
restart: unless-stopped
ports:
- "4444:4444"
volumes:
- ./src/frontend/apps/y-webrtc-signaling:/home/frontend/apps/y-webrtc-signaling
- /home/frontend/apps/y-webrtc-signaling/node_modules/
- /home/frontend/apps/y-webrtc-signaling/dist/
- ./src/frontend/servers/y-provider:/home/frontend/servers/y-provider
- /home/frontend/servers/y-provider/node_modules/
- /home/frontend/servers/y-provider/dist/
frontend-dev:
user: "${DOCKER_USER:-1000}"
@@ -167,12 +167,11 @@ services:
- ./src/frontend/apps/impress:/home/frontend/apps/impress
- /home/frontend/node_modules/
depends_on:
- y-webrtc-signaling
- y-provider
- celery-dev
kc_postgresql:
image: postgres:14.3
platform: linux/amd64
ports:
- "5433:5432"
env_file:
@@ -4,6 +4,36 @@ server {
server_name localhost;
charset utf-8;
location /media/ {
# Auth request configuration
auth_request /auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/impress-media-storage/;
proxy_set_header Host minio:9000;
}
location /auth {
proxy_pass http://app-dev:8000/api/v1.0/documents/retrieve-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
+2 -2
View File
@@ -23,9 +23,9 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
pullPolicy: Always
tag: "v4.18.1"
webrtc:
y-provider:
image:
repository: lasuite/impress-y-webrtc-signaling
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "v4.18.1"
```
+1 -7
View File
@@ -13,13 +13,7 @@
"enabled": false,
"groupName": "ignored js dependencies",
"matchManagers": ["npm"],
"matchPackageNames": [
"fetch-mock",
"node",
"node-fetch",
"i18next-parser",
"eslint"
]
"matchPackageNames": ["fetch-mock", "node", "node-fetch", "eslint"]
}
]
}
+1 -1
Submodule secrets updated: 1485c6dc9d...2643697e5f
+31
View File
@@ -1,5 +1,8 @@
"""Client serializers for the impress core app."""
import mimetypes
from django.conf import settings
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
@@ -153,6 +156,34 @@ class DocumentSerializer(BaseResourceSerializer):
read_only_fields = ["id", "accesses", "abilities", "created_at", "updated_at"]
# Suppress the warning about not implementing `create` and `update` methods
# since we don't use a model and only rely on the serializer for validation
# pylint: disable=abstract-method
class FileUploadSerializer(serializers.Serializer):
"""Receive file upload requests."""
file = serializers.FileField()
def validate_file(self, file):
"""Add file size and type constraints as defined in settings."""
# Validate file size
if file.size > settings.DOCUMENT_IMAGE_MAX_SIZE:
max_size = settings.DOCUMENT_IMAGE_MAX_SIZE // (1024 * 1024)
raise serializers.ValidationError(
f"File size exceeds the maximum limit of {max_size:d} MB."
)
# Validate file type
mime_type, _ = mimetypes.guess_type(file.name)
if mime_type not in settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES:
mime_types = ", ".join(settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES)
raise serializers.ValidationError(
f"File type '{mime_type:s}' is not allowed. Allowed types are: {mime_types:s}"
)
return file
class TemplateSerializer(BaseResourceSerializer):
"""Serialize templates."""
+33
View File
@@ -0,0 +1,33 @@
"""Util to generate S3 authorization headers for object storage access control"""
from django.core.files.storage import default_storage
import botocore
def generate_s3_authorization_headers(key):
"""
Generate authorization headers for an s3 object.
These headers can be used as an alternative to signed urls with many benefits:
- the urls of our files never expire and can be stored in our documents' content
- we don't leak authorized urls that could be shared (file access can only be done
with cookies)
- access control is truly realtime
- the object storage service does not need to be exposed on internet
"""
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
"get_object",
ExpiresIn=0,
Params={"Bucket": default_storage.bucket_name, "Key": key},
)
request = botocore.awsrequest.AWSRequest(method="get", url=url)
s3_client = default_storage.connection.meta.client
# pylint: disable=protected-access
credentials = s3_client._request_signer._credentials # noqa: SLF001
frozen_credentials = credentials.get_frozen_credentials()
region = s3_client.meta.region_name
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
auth.add_auth(request)
return request
+89 -2
View File
@@ -1,6 +1,13 @@
"""API endpoints"""
import os
import re
import uuid
from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.core.files.storage import default_storage
from django.db.models import (
OuterRef,
Q,
@@ -25,10 +32,22 @@ from rest_framework import (
from core import models
from core.utils import email_invitation
from . import permissions, serializers
from . import permissions, serializers, utils
ATTACHMENTS_FOLDER = "attachments"
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"\.[a-zA-Z]{3,4}"
MEDIA_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}({UUID_REGEX:s})/"
f"({ATTACHMENTS_FOLDER:s}/{UUID_REGEX:s}{FILE_EXT_REGEX:s})$"
)
# pylint: disable=too-many-ancestors
ATTACHMENTS_FOLDER = "attachments"
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
@@ -265,7 +284,7 @@ class ResourceAccessViewsetMixin:
):
return drf_response.Response(
{"detail": "Cannot delete the last owner access for the resource."},
status=403,
status=status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
@@ -310,6 +329,7 @@ class DocumentViewSet(
access_model_class = models.DocumentAccess
resource_field_name = "document"
queryset = models.Document.objects.all()
ordering = ["-updated_at"]
def perform_create(self, serializer):
"""
@@ -389,6 +409,73 @@ class DocumentViewSet(
}
)
@decorators.action(detail=True, methods=["post"], url_path="attachment-upload")
def attachment_upload(self, request, *args, **kwargs):
"""Upload a file related to a given document"""
# Check permissions first
document = self.get_object()
# Validate metadata in payload
serializer = serializers.FileUploadSerializer(data=request.data)
if not serializer.is_valid():
return drf_response.Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
# Extract the file extension from the original filename
file = serializer.validated_data["file"]
extension = os.path.splitext(file.name)[1]
# Generate a generic yet unique filename to store the image in object storage
file_id = uuid.uuid4()
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}{extension:s}"
default_storage.save(key, file)
return drf_response.Response(
{"file": f"{settings.MEDIA_URL:s}{key:s}"}, status=status.HTTP_201_CREATED
)
@decorators.action(detail=False, methods=["get"], url_path="retrieve-auth")
def retrieve_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a document's
attachment file.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
original_url = urlparse(request.META.get("HTTP_X_ORIGINAL_URL"))
match = MEDIA_URL_PATTERN.search(original_url.path)
try:
pk, attachment_key = match.groups()
except AttributeError as excpt:
raise exceptions.PermissionDenied() from excpt
# Check permission
try:
document = models.Document.objects.get(pk=pk)
except models.Document.DoesNotExist as excpt:
raise exceptions.PermissionDenied() from excpt
if not document.get_abilities(request.user).get("retrieve", False):
raise exceptions.PermissionDenied()
# Generate authorization headers and return an authorization to proceed with the request
request = utils.generate_s3_authorization_headers(f"{pk:s}/{attachment_key:s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
class DocumentAccessViewSet(
ResourceAccessViewsetMixin,
+32 -23
View File
@@ -36,18 +36,19 @@ logger = getLogger(__name__)
def get_resource_roles(resource, user):
"""Compute the roles a user has on a resource."""
roles = []
if user.is_authenticated:
if not user.is_authenticated:
return []
try:
roles = resource.user_roles or []
except AttributeError:
teams = user.get_teams()
try:
roles = resource.user_roles or []
except AttributeError:
teams = user.get_teams()
try:
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
roles = resource.accesses.filter(
models.Q(user=user) | models.Q(team__in=teams),
).values_list("role", flat=True)
except (models.ObjectDoesNotExist, IndexError):
roles = []
return roles
@@ -324,16 +325,23 @@ class Document(BaseModel):
file_key = self.file_key
bytes_content = self._content.encode("utf-8")
if default_storage.exists(file_key):
# Attempt to directly check if the object exists using the storage client.
try:
response = default_storage.connection.meta.client.head_object(
Bucket=default_storage.bucket_name, Key=file_key
)
except ClientError as excpt:
# If the error is a 404, the object doesn't exist, so we should create it.
if excpt.response["Error"]["Code"] == "404":
has_changed = True
else:
raise
else:
# Compare the existing ETag with the MD5 hash of the new content.
has_changed = (
response["ETag"].strip('"')
!= hashlib.md5(bytes_content).hexdigest() # noqa
!= hashlib.md5(bytes_content).hexdigest() # noqa: S324
)
else:
has_changed = True
if has_changed:
content_file = ContentFile(bytes_content)
@@ -396,7 +404,7 @@ class Document(BaseModel):
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=settings.S3_VERSIONS_PAGE_SIZE,
MaxKeys=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
**token,
)
@@ -414,7 +422,7 @@ class Document(BaseModel):
if response["NextVersionIdMarker"]:
return self.get_versions_slice(
from_version_id=response["NextVersionIdMarker"],
page_size=settings.S3_VERSIONS_PAGE_SIZE,
page_size=settings.DOCUMENT_VERSIONS_PAGE_SIZE,
from_datetime=from_datetime,
)
return {
@@ -426,9 +434,9 @@ class Document(BaseModel):
response = default_storage.connection.meta.client.list_object_versions(
Bucket=default_storage.bucket_name,
Prefix=self.file_key,
MaxKeys=min(page_size, settings.S3_VERSIONS_PAGE_SIZE)
MaxKeys=min(page_size, settings.DOCUMENT_VERSIONS_PAGE_SIZE)
if page_size
else settings.S3_VERSIONS_PAGE_SIZE,
else settings.DOCUMENT_VERSIONS_PAGE_SIZE,
**token,
)
return {
@@ -468,13 +476,14 @@ class Document(BaseModel):
return {
"destroy": RoleChoices.OWNER in roles,
"attachment_upload": is_owner_or_admin or is_editor,
"manage_accesses": is_owner_or_admin,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
"update": is_owner_or_admin or is_editor,
"versions_destroy": is_owner_or_admin,
"versions_list": can_get_versions,
"versions_retrieve": can_get_versions,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin or is_editor,
"partial_update": is_owner_or_admin or is_editor,
"retrieve": can_get,
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

@@ -0,0 +1,197 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import re
import uuid
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
import pytest
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_attachment_upload_anonymous():
"""Anonymous users can't upload attachments to a document."""
document = factories.DocumentFactory()
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = APIClient().post(url, {"file": file}, format="multipart")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_documents_attachment_upload_authenticated_public():
"""
Users who are not related to a public document should not be allowed to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=True)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_documents_attachment_upload_authenticated_private():
"""
Users who are not related to a private document should not be able to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=False)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 404
assert response.json() == {"detail": "No Document matches the given query."}
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_reader(via, mock_user_get_teams):
"""
Users who are simple readers on a document should not be allowed to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role="reader")
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role="reader"
)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
@pytest.mark.parametrize("role", ["editor", "administrator", "owner"])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_attachment_upload_success(via, role, mock_user_get_teams):
"""
Editors, administrators and owners of a document should be able to upload an attachment.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory()
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=document, team="lasuite", role=role
)
file = SimpleUploadedFile("test_file.jpg", b"Dummy content")
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.jpg")
match = pattern.search(response.json()["file"])
file_id = match.group(1)
# Validate that file_id is a valid UUID
uuid.UUID(file_id)
def test_api_documents_attachment_upload_invalid(client):
"""Attempt to upload without a file should return an explicit error."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
response = client.post(url, {}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["No file was submitted."]}
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceeed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
# Create a temporary file larger than the allowed size
content = b"a" * (1048576 + 1)
file = ContentFile(content, name="test.jpg")
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {"file": ["File size exceeds the maximum limit of 1 MB."]}
def test_api_documents_attachment_upload_type_not_allowed(settings):
"""The uploaded file should be of a whitelisted type."""
settings.DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = ["image/jpeg", "image/png"]
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "owner")])
url = f"/api/v1.0/documents/{document.id!s}/attachment-upload/"
# Create a temporary file with a not allowed type (e.g., text file)
file = ContentFile(b"a" * 1048576, name="test.txt")
response = client.post(url, {"file": file}, format="multipart")
assert response.status_code == 400
assert response.json() == {
"file": [
"File type 'text/plain' is not allowed. Allowed types are: image/jpeg, image/png"
]
}
@@ -169,87 +169,26 @@ def test_api_documents_list_authenticated_distinct():
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_order_created_at_desc():
def test_api_documents_order_updated_at_desc_default():
"""
Test that the endpoint GET documents is sorted in 'created_at' descending order by default.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_created = [
document.created_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_created.sort(reverse=True)
response = client.get(
"/api/v1.0/documents/",
)
assert response.status_code == 200
response_data = response.json()
response_document_created = [
document["created_at"] for document in response_data["results"]
]
assert (
response_document_created == documents_created
), "created_at values are not sorted from newest to oldest"
def test_api_documents_order_created_at_asc():
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_created = [
document.created_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_created.sort()
response = client.get(
"/api/v1.0/documents/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_document_created = [
document["created_at"] for document in response_data["results"]
]
assert (
response_document_created == documents_created
), "created_at values are not sorted from oldest to newest"
def test_api_documents_order_updated_at_desc():
"""
Test that the 'updated_at' field is sorted in descending order
when the 'ordering' query parameter is set.
Test that the endpoint GET documents is sorted in 'updated_at' descending order by default.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
# Updated at next year to ensure the order is correct
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
for document in factories.DocumentFactory.create_batch(
5, is_public=True, updated_at=fake.date_time_this_year(before_now=False)
)
]
documents_updated.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/?ordering=-updated_at",
"/api/v1.0/documents/",
)
assert response.status_code == 200
@@ -264,97 +203,99 @@ def test_api_documents_order_updated_at_desc():
), "updated_at values are not sorted from newest to oldest"
def test_api_documents_order_updated_at_asc():
@pytest.mark.parametrize(
"ordering_field, factory_field",
[
("-created_at", "created_at"),
("-updated_at", "updated_at"),
("-title", "title"),
],
)
def test_api_documents_ordering_desc(ordering_field, factory_field):
"""
Test that the 'updated_at' field is sorted in ascending order
Test that the specified field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
if factory_field == "title":
documents_field_values = [
factories.DocumentFactory(
is_public=True, title=fake.sentence(nb_words=4)
).title
for _ in range(5)
]
else:
documents_field_values = [
getattr(document, factory_field).isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_updated.sort()
documents_field_values.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/?ordering=updated_at",
response = client.get(
f"/api/v1.0/documents/?ordering={ordering_field}"
if ordering_field != "-created_at"
else "/api/v1.0/documents/",
)
assert response.status_code == 200
response_data = response.json()
response_document_updated = [
document["updated_at"] for document in response_data["results"]
response_documents_field_values = [
document[factory_field] for document in response_data["results"]
]
assert (
response_document_updated == documents_updated
), "updated_at values are not sorted from oldest to newest"
response_documents_field_values == documents_field_values
), f"{factory_field} values are not sorted as expected"
def test_api_documents_order_title_desc():
@pytest.mark.parametrize(
"field",
[
("updated_at"),
("title"),
("created_at"),
],
)
def test_api_documents_ordering_asc(field):
"""
Test that the 'title' field is sorted in descending order
Test that the specified field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_title = [
factories.DocumentFactory(is_public=True, title=fake.sentence(nb_words=4)).title
for _ in range(5)
]
if field == "title":
documents_field_values = [
factories.DocumentFactory(
is_public=True, title=fake.sentence(nb_words=4)
).title
for _ in range(5)
]
else:
documents_field_values = [
getattr(document, field).isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_title.sort(reverse=True)
documents_field_values.sort()
response = APIClient().get(
"/api/v1.0/documents/?ordering=-title",
response = client.get(
f"/api/v1.0/documents/?ordering={field}",
)
assert response.status_code == 200
response_data = response.json()
response_documents_title = [
document["title"] for document in response_data["results"]
response_documents_field_values = [
document[field] for document in response_data["results"]
]
assert (
response_documents_title == documents_title
), "title values are not sorted descending"
def test_api_documents_order_title_asc():
"""
Test that the 'title' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_title = [
factories.DocumentFactory(is_public=True, title=fake.sentence(nb_words=4)).title
for _ in range(5)
]
documents_title.sort()
response = APIClient().get(
"/api/v1.0/documents/?ordering=title",
)
assert response.status_code == 200
response_data = response.json()
response_documents_title = [
document["title"] for document in response_data["results"]
]
assert (
response_documents_title == documents_title
), "title values are not sorted ascending"
response_documents_field_values == documents_field_values
), f"{field} values are not sorted as expected"
@@ -22,6 +22,7 @@ def test_api_documents_retrieve_anonymous_public():
"id": str(document.id),
"abilities": {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
@@ -69,6 +70,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
"id": str(document.id),
"abilities": {
"destroy": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
@@ -0,0 +1,213 @@
"""
Test file uploads API endpoint for users in impress's core app.
"""
import uuid
from io import BytesIO
from urllib.parse import urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import factories
from core.tests.conftest import TEAM, USER, VIA
pytestmark = pytest.mark.django_db
def test_api_documents_retrieve_auth_anonymous_public():
"""Anonymous users should be able to retrieve attachments linked to a public document"""
document = factories.DocumentFactory(is_public=True)
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_anonymous_not_public():
"""
Anonymous users should not be allowed to retrieve attachments linked to a document
that is not public.
"""
document = factories.DocumentFactory(is_public=False)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
assert "Authorization" not in response
def test_api_documents_retrieve_auth_authenticated_public():
"""
Authenticated users who are not related to a document should be able to
retrieve attachments linked to a public document.
"""
document = factories.DocumentFactory(is_public=True)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_documents_retrieve_auth_authenticated_not_public():
"""
Authenticated users who are not related to a document should not be allowed to
retrieve attachments linked to a document that is not public.
"""
document = factories.DocumentFactory(is_public=False)
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
filename = f"{uuid.uuid4()!s}.jpg"
media_url = f"http://localhost/media/{document.pk!s}/attachments/{filename:s}"
response = APIClient().get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=media_url
)
assert response.status_code == 403
assert "Authorization" not in response
@pytest.mark.parametrize("is_public", [True, False])
@pytest.mark.parametrize("via", VIA)
def test_api_documents_retrieve_auth_related(via, is_public, mock_user_get_teams):
"""
Users who have a role on a document, whatever the role, should be able to
retrieve related attachments.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(is_public=is_public)
if via == USER:
factories.UserDocumentAccessFactory(document=document, user=user)
elif via == TEAM:
mock_user_get_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(document=document, team="lasuite")
filename = f"{uuid.uuid4()!s}.jpg"
key = f"{document.pk!s}/attachments/{filename:s}"
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{key:s}"
response = client.get(
"/api/v1.0/documents/retrieve-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/impress-media-storage/{key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
+28 -19
View File
@@ -63,10 +63,11 @@ def test_models_documents_get_abilities_anonymous_public():
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -79,10 +80,11 @@ def test_models_documents_get_abilities_anonymous_not_public():
abilities = document.get_abilities(AnonymousUser())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": False,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -95,10 +97,11 @@ def test_models_documents_get_abilities_authenticated_unrelated_public():
abilities = document.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -111,10 +114,11 @@ def test_models_documents_get_abilities_authenticated_unrelated_not_public():
abilities = document.get_abilities(factories.UserFactory())
assert abilities == {
"destroy": False,
"retrieve": False,
"update": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": False,
"update": False,
"versions_destroy": False,
"versions_list": False,
"versions_retrieve": False,
@@ -128,10 +132,11 @@ def test_models_documents_get_abilities_owner():
abilities = access.document.get_abilities(access.user)
assert abilities == {
"destroy": True,
"retrieve": True,
"update": True,
"attachment_upload": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
@@ -144,10 +149,11 @@ def test_models_documents_get_abilities_administrator():
abilities = access.document.get_abilities(access.user)
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"attachment_upload": True,
"manage_accesses": True,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": True,
"versions_list": True,
"versions_retrieve": True,
@@ -163,10 +169,11 @@ def test_models_documents_get_abilities_editor_user(django_assert_num_queries):
assert abilities == {
"destroy": False,
"retrieve": True,
"update": True,
"attachment_upload": True,
"manage_accesses": False,
"partial_update": True,
"retrieve": True,
"update": True,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -182,10 +189,11 @@ def test_models_documents_get_abilities_reader_user(django_assert_num_queries):
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -202,10 +210,11 @@ def test_models_documents_get_abilities_preset_role(django_assert_num_queries):
assert abilities == {
"destroy": False,
"retrieve": True,
"update": False,
"attachment_upload": False,
"manage_accesses": False,
"partial_update": False,
"retrieve": True,
"update": False,
"versions_destroy": False,
"versions_list": True,
"versions_retrieve": True,
@@ -217,7 +226,7 @@ def test_models_documents_get_versions_slice(settings):
The "get_versions_slice" method should allow navigating all versions of
the document with pagination.
"""
settings.S3_VERSIONS_PAGE_SIZE = 4
settings.DOCUMENT_VERSIONS_PAGE_SIZE = 4
# Create a document with 7 versions
document = factories.DocumentFactory()
+19 -1
View File
@@ -138,7 +138,24 @@ class Base(Configuration):
environ_prefix=None,
)
S3_VERSIONS_PAGE_SIZE = 50
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.Value(
10 * (2**20), # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
)
DOCUMENT_IMAGE_ALLOWED_MIME_TYPES = [
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/tiff",
"image/webp",
]
# Document versions
DOCUMENT_VERSIONS_PAGE_SIZE = 50
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -297,6 +314,7 @@ class Base(Configuration):
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
THUMBNAIL_DEFAULT_STORAGE_ALIAS = "default"
THUMBNAIL_ALIASES = {}
# Celery
+8 -8
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.2.0"
version = "1.3.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.35.0",
"boto3==1.35.10",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -34,9 +34,9 @@ dependencies = [
"django-parler==2.3",
"redis==5.0.8",
"django-redis==5.4.0",
"django-storages[s3]==1.14.2",
"django-storages[s3]==1.14.4",
"django-timezone-field>=5.1",
"django==5.0.8",
"django==5.1",
"djangorestframework==3.15.2",
"drf_spectacular==0.27.2",
"dockerflow==2024.4.2",
@@ -70,17 +70,17 @@ dev = [
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2024.7.1",
"ipdb==0.13.13",
"ipython==8.26.0",
"ipython==8.27.0",
"pyfakefs==5.6.0",
"pylint-django==2.5.5",
"pylint==3.2.6",
"pylint==3.2.7",
"pytest-cov==5.0.0",
"pytest-django==4.8.0",
"pytest-django==4.9.0",
"pytest==8.3.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.6.1",
"ruff==0.6.3",
"types-requests==2.32.0.20240712",
]
+7 -7
View File
@@ -1,10 +1,10 @@
FROM node:20-alpine as frontend-deps-y-webrtc-signaling
FROM node:20-alpine as frontend-deps-y-provider
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/apps/y-webrtc-signaling/package.json ./apps/y-webrtc-signaling/package.json
COPY ./src/frontend/servers/y-provider/package.json ./servers/y-provider/package.json
COPY ./src/frontend/packages/eslint-config-impress/package.json ./packages/eslint-config-impress/package.json
RUN yarn install
@@ -14,10 +14,10 @@ COPY ./src/frontend/ .
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# ---- y-webrtc-signaling ----
FROM frontend-deps-y-webrtc-signaling as y-webrtc-signaling
# ---- y-provider ----
FROM frontend-deps-y-provider as y-provider
WORKDIR /home/frontend/apps/y-webrtc-signaling
WORKDIR /home/frontend/servers/y-provider
RUN yarn build
# Un-privileged user running the application
@@ -64,8 +64,8 @@ WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG SIGNALING_URL
ENV NEXT_PUBLIC_SIGNALING_URL=${SIGNALING_URL}
ARG Y_PROVIDER_URL
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

@@ -55,6 +55,10 @@ test.describe('Doc Create', () => {
true,
);
expect(await page.locator('title').textContent()).toMatch(
/My new doc - Docs/,
);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
@@ -1,3 +1,5 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
@@ -7,7 +9,7 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Doc Editor', () => {
test('checks the Doc is connected to the webrtc server', async ({
test('checks the Doc is connected to the provider server', async ({
page,
browserName,
}) => {
@@ -27,12 +29,7 @@ test.describe('Doc Editor', () => {
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
const framesent = await framesentPromise;
const payload = JSON.parse(framesent.payload as string) as {
type: string;
};
const typeCases = ['publish', 'subscribe', 'unsubscribe', 'ping'];
expect(typeCases.includes(payload.type)).toBeTruthy();
expect(framesent.payload).not.toBeNull();
});
test('markdown button converts from markdown to the editor syntax json', async ({
@@ -104,10 +101,6 @@ test.describe('Doc Editor', () => {
nthRow: 2,
});
await expect(
page.getByText(`Your document "${doc}" has been saved.`),
).toBeVisible();
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await goToGridDoc(page, {
@@ -159,4 +152,31 @@ test.describe('Doc Editor', () => {
page.getByText('Read only, you cannot edit this document.'),
).toBeVisible();
});
test('it adds an image to the doc editor', async ({ page }) => {
await goToGridDoc(page);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
await expect(image).toBeVisible();
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
});
@@ -175,6 +175,11 @@ test.describe('Doc Export', () => {
name: 'Image',
})
.click();
await page
.getByRole('tab', {
name: 'Embed',
})
.click();
await page
.getByPlaceholder('Enter URL')
.fill('https://example.com/image.jpg');
@@ -49,111 +49,130 @@ test.describe('Documents Grid', () => {
nameColumn: 'Document name',
ordering: 'title',
cellNumber: 1,
orderDefault: '',
orderDesc: '&ordering=-title',
orderAsc: '&ordering=title',
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
},
].forEach(({ nameColumn, ordering, cellNumber }) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
].forEach(
({
nameColumn,
ordering,
cellNumber,
orderDefault,
orderDesc,
orderAsc,
}) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDefault}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1&ordering=-${ordering}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDesc}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1&ordering=${ordering}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderAsc}`) &&
response.status() === 200,
);
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
// Checks the initial state
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagrid
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagrid
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
const docNameRow1 = datagrid
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagrid
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
// Ordering ASC
await thead.getByText(nameColumn).click();
// Ordering ASC
await thead.getByText(nameColumn).click();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) <= 0,
).toBeTruthy();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) <= 0,
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) >= 0,
).toBeTruthy();
});
});
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) >= 0,
).toBeTruthy();
});
},
);
test('checks the pagination', async ({ page }) => {
const responsePromisePage1 = page.waitForResponse(
@@ -0,0 +1,64 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Summary', () => {
test('it checks the doc summary', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-summary', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Summary',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 6; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await panel.getByText('Another World').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
});
});
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -20,7 +20,7 @@
},
"dependencies": {
"convert-stream": "1.0.2",
"jsdom": "24.1.1",
"pdf-parse": "^1.1.1"
"jsdom": "25.0.0",
"pdf-parse": "1.1.1"
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SIGNALING_URL=
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
+2 -1
View File
@@ -1,3 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_SIGNALING_URL=ws://localhost:4444
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
NEXT_PUBLIC_SW_DEACTIVATED=true
+12 -12
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,35 +19,35 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@openfun/cunningham-react": "2.9.3",
"@tanstack/react-query": "5.51.24",
"@hocuspocus/provider": "2.13.5",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.53.2",
"i18next": "23.14.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.5",
"next": "14.2.7",
"react": "*",
"react-aria-components": "1.3.1",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-i18next": "15.0.1",
"react-select": "5.8.0",
"styled-components": "6.1.12",
"y-webrtc": "10.3.0",
"styled-components": "6.1.13",
"yjs": "*",
"zustand": "4.5.5"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.51.24",
"@tanstack/react-query-devtools": "5.53.2",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "16.0.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.7",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.3",
"@types/react": "18.3.5",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -57,7 +57,7 @@
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.3",
"stylelint": "16.8.2",
"stylelint": "16.9.0",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+16 -17
View File
@@ -1,34 +1,33 @@
import { baseApiUrl, useAuthStore } from '@/core';
/**
* Retrieves the CSRF token from the document's cookies.
*
* @returns {string|null} The CSRF token if found in the cookies, or null if not present.
*/
function getCSRFToken() {
return document.cookie
.split(';')
.filter((cookie) => cookie.trim().startsWith('csrftoken='))
.map((cookie) => cookie.split('=')[1])
.pop();
import { getCSRFToken } from './utils';
interface FetchAPIInit extends RequestInit {
withoutContentType?: boolean;
}
export const fetchAPI = async (
input: string,
init?: RequestInit,
init?: FetchAPIInit,
apiVersion = '1.0',
) => {
const apiUrl = `${baseApiUrl(apiVersion)}${input}`;
const csrfToken = getCSRFToken();
const headers = {
'Content-Type': 'application/json',
...init?.headers,
...(csrfToken && { 'X-CSRFToken': csrfToken }),
};
if (init?.withoutContentType) {
delete headers?.['Content-Type' as keyof typeof headers];
}
const response = await fetch(apiUrl, {
...init,
credentials: 'include',
headers: {
...init?.headers,
'Content-Type': 'application/json',
...(csrfToken && { 'X-CSRFToken': csrfToken }),
},
headers,
});
if (response.status === 401) {
@@ -16,3 +16,14 @@ export const errorCauses = async (response: Response, data?: unknown) => {
data,
};
};
/**
* Retrieves the CSRF token from the document's cookies.
*/
export function getCSRFToken() {
return document.cookie
.split(';')
.filter((cookie) => cookie.trim().startsWith('csrftoken='))
.map((cookie) => cookie.split('=')[1])
.pop();
}
@@ -29,6 +29,7 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
border: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: inherit;
${$css || ''}
`}
{...props}
@@ -1,21 +1,27 @@
import React, { useState } from 'react';
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { VersionList } from './VersionList';
interface PanelProps {
doc: Doc;
title: string;
setIsPanelOpen: (isOpen: boolean) => void;
}
export const Panel = ({ doc }: PanelProps) => {
export const Panel = ({
children,
title,
setIsPanelOpen,
}: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(true);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setIsOpen(true);
}, []);
const closedOverridingStyles = !isOpen && {
$width: '0',
@@ -43,31 +49,9 @@ export const Panel = ({ doc }: PanelProps) => {
`
}
`}
aria-label={t('Document version panel')}
aria-label={t('Document panel')}
{...closedOverridingStyles}
>
<IconBG
iconName="menu_open"
aria-label={
isOpen
? t('Close the document version panel')
: t('Open the document version panel')
}
$background="transparent"
$size="h2"
$zIndex={1}
$css={`
cursor: pointer;
left: ${isOpen ? '0' : '-3.3'}rem;
top: 0.1rem;
transform: rotate(${isOpen ? '180' : '0'}deg);
transition: ${transition};
user-select: none;
`}
$position="absolute"
onClick={() => setIsOpen(!isOpen)}
$radius="2px"
/>
<Box
$overflow="hidden"
$css={`
@@ -82,11 +66,35 @@ export const Panel = ({ doc }: PanelProps) => {
$justify="center"
$css={`border-top: 2px solid ${colorsTokens()['primary-600']};`}
>
<IconBG
iconName="menu_open"
aria-label={isOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={1}
$css={`
cursor: pointer;
left: 0rem;
top: 0.1rem;
transition: ${transition};
transform: rotate(180deg);
opacity: ${isOpen ? '1' : '0'};
user-select: none;
`}
$position="absolute"
onClick={() => {
setIsOpen(false);
setTimeout(() => {
setIsPanelOpen(false);
}, 400);
}}
$radius="2px"
/>
<Text $weight="bold" $size="l" $theme="primary">
{t('VERSIONS')}
{title}
</Text>
</Box>
<VersionList doc={doc} />
{children}
</Box>
</Card>
);
+11 -8
View File
@@ -1,14 +1,17 @@
export const baseApiUrl = (apiVersion: string = '1.0') => {
const origin =
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const mediaUrl = () =>
process.env.NEXT_PUBLIC_MEDIA_URL ||
(typeof window !== 'undefined' ? window.location.origin : '');
return `${origin}/api/v${apiVersion}/`;
};
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const signalingUrl = (docId: string) => {
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
export const providerUrl = (docId: string) => {
const base =
process.env.NEXT_PUBLIC_SIGNALING_URL ||
process.env.NEXT_PUBLIC_Y_PROVIDER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${docId}`;
+2 -1
View File
@@ -20,7 +20,8 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_SIGNALING_URL?: string;
NEXT_PUBLIC_MEDIA_URL?: string;
NEXT_PUBLIC_Y_PROVIDER_URL?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
NEXT_PUBLIC_THEME?: string;
}
@@ -0,0 +1,36 @@
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { DocAttachment } from '../types';
interface CreateDocAttachment {
docId: string;
body: FormData;
}
export const createDocAttachment = async ({
docId,
body,
}: CreateDocAttachment): Promise<DocAttachment> => {
const response = await fetchAPI(`documents/${docId}/attachment-upload/`, {
method: 'POST',
body,
withoutContentType: true,
});
if (!response.ok) {
throw new APIError(
'Failed to upload on the doc',
await errorCauses(response),
);
}
return response.json() as Promise<DocAttachment>;
};
export function useCreateDocAttachment() {
return useMutation<DocAttachment, APIError, CreateDocAttachment>({
mutationFn: createDocAttachment,
});
}
@@ -2,20 +2,36 @@ import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import React, { useEffect, useMemo } from 'react';
import { WebrtcProvider } from 'y-webrtc';
import { HocuspocusProvider } from '@hocuspocus/provider';
import React, { useCallback, useEffect, useMemo } from 'react';
import { Box } from '@/components';
import { Box, TextErrors } from '@/components';
import { mediaUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Version } from '@/features/docs/doc-versioning/';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
const cssEditor = `
&, & > .bn-container, & .ProseMirror {
height:100%
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
`;
interface BlockNoteEditorProps {
doc: Doc;
version?: Version;
@@ -28,7 +44,7 @@ export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
const provider = docsStore?.[storeId]?.provider;
useEffect(() => {
if (!provider || provider.doc.guid !== storeId) {
if (!provider || provider.document.guid !== storeId) {
createProvider(storeId, initialContent);
}
}, [createProvider, initialContent, provider, storeId]);
@@ -42,7 +58,7 @@ export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
interface BlockNoteContentProps {
doc: Doc;
provider: WebrtcProvider;
provider: HocuspocusProvider;
storeId: string;
}
@@ -55,9 +71,29 @@ export const BlockNoteContent = ({
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
const canSave = doc.abilities.partial_update && !isVersion;
useSaveDoc(doc.id, provider.doc, canSave);
useSaveDoc(doc.id, provider.document, canSave);
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const ret = await createDocAttachment({
docId: doc.id,
body,
});
return `${mediaUrl()}${ret.file}`;
},
[createDocAttachment, doc.id],
);
const editor = useMemo(() => {
if (storedEditor) {
return storedEditor;
@@ -66,30 +102,28 @@ export const BlockNoteContent = ({
return BlockNoteEditorCore.create({
collaboration: {
provider,
fragment: provider.doc.getXmlFragment('document-store'),
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
color: randomColor(),
},
},
uploadFile,
});
}, [provider, storedEditor, userData?.email]);
}, [provider, storedEditor, uploadFile, userData?.email]);
useEffect(() => {
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
return (
<Box
$css={`
&, & > .bn-container, & .ProseMirror {
height:100%
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
`}
>
<Box $css={cssEditor}>
{isErrorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors causes={errorAttachment.cause} />
</Box>
)}
<BlockNoteView
editor={editor}
formattingToolbar={false}
@@ -11,9 +11,10 @@ import {
UnnestBlockButton,
useBlockNoteEditor,
useComponentsContext,
useSelectedBlocks,
} from '@blocknote/react';
import { forEach, isArray } from 'lodash';
import React from 'react';
import React, { useMemo } from 'react';
export const BlockNoteToolbar = () => {
return (
@@ -91,6 +92,7 @@ const recursiveContent = (content: Block[], base: string = '') => {
export function MarkdownButton() {
const editor = useBlockNoteEditor();
const Components = useComponentsContext();
const selectedBlocks = useSelectedBlocks(editor);
const handleConvertMarkdown = () => {
const blocks = editor.getSelection()?.blocks;
@@ -114,7 +116,11 @@ export function MarkdownButton() {
});
};
if (!Components) {
const show = useMemo(() => {
return !!selectedBlocks.find((block) => block.content !== undefined);
}, [selectedBlocks]);
if (!show || !editor.isEditable || !Components) {
return null;
}
@@ -5,10 +5,17 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { Panel } from '@/components/Panel';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { Summary, useDocSummaryStore } from '@/features/docs/doc-summary';
import {
VersionList,
Versions,
useDocVersion,
useDocVersionStore,
} from '@/features/docs/doc-versioning/';
import { BlockNoteEditor } from './BlockNoteEditor';
@@ -20,6 +27,8 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
const { isPanelSummaryOpen, setIsPanelSummaryOpen } = useDocSummaryStore();
const { t } = useTranslation();
@@ -29,7 +38,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
return (
<>
<DocHeader doc={doc} />
<DocHeader doc={doc} versionId={versionId as Versions['version_id']} />
{!doc.abilities.partial_update && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
@@ -58,6 +67,16 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
<BlockNoteEditor doc={doc} />
)}
</Card>
{doc.abilities.versions_list && isPanelVersionOpen && (
<Panel title={t('VERSIONS')} setIsPanelOpen={setIsPanelVersionOpen}>
<VersionList doc={doc} />
</Panel>
)}
{isPanelSummaryOpen && (
<Panel title={t('SUMMARY')} setIsPanelOpen={setIsPanelSummaryOpen}>
<Summary doc={doc} />
</Panel>
)}
</Box>
</>
);
@@ -1,32 +1,14 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/features/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
import { useDocStore } from '../stores';
import { toBase64 } from '../utils';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { toast } = useToastProvider();
const { t } = useTranslation();
const { forceSave, setForceSave } = useDocStore();
const { mutate: updateDoc } = useUpdateDoc({
onSuccess: (data) => {
toast(
t('Your document "{{docTitle}}" has been saved.', {
docTitle: data.title,
}),
VariantType.SUCCESS,
{
duration: 1500,
},
);
},
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
});
const [initialDoc, setInitialDoc] = useState<string>(
@@ -83,18 +65,6 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
});
}, [doc, docId, updateDoc]);
useEffect(() => {
if (forceSave === 'false') {
return;
}
setForceSave('false');
if ((forceSave === 'current' && hasChanged()) || forceSave === 'version') {
saveDoc();
}
}, [forceSave, hasChanged, saveDoc, setForceSave]);
const timeout = useRef<NodeJS.Timeout>();
const router = useRouter();
@@ -1,2 +1,3 @@
export * from './components';
export * from './stores';
export * from './utils';
@@ -1,34 +1,26 @@
import { BlockNoteEditor } from '@blocknote/core';
import { WebrtcProvider } from 'y-webrtc';
import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
import { signalingUrl } from '@/core';
import { providerUrl } from '@/core';
import { Base64 } from '@/features/docs/doc-management';
interface DocStore {
provider: WebrtcProvider;
provider: HocuspocusProvider;
editor?: BlockNoteEditor;
}
type ForceSaveState = 'false' | 'version' | 'current';
export interface UseDocStore {
docsStore: {
[storeId: string]: DocStore;
};
createProvider: (storeId: string, initialDoc: Base64) => WebrtcProvider;
createProvider: (storeId: string, initialDoc: Base64) => HocuspocusProvider;
setStore: (storeId: string, props: Partial<DocStore>) => void;
forceSave: ForceSaveState;
setForceSave: (forceSave: ForceSaveState) => void;
}
export const useDocStore = create<UseDocStore>((set, get) => ({
docsStore: {},
forceSave: 'false',
setForceSave: (forceSave) => {
set(() => ({ forceSave }));
},
createProvider: (storeId: string, initialDoc: Base64) => {
const doc = new Y.Doc({
guid: storeId,
@@ -38,9 +30,10 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new WebrtcProvider(storeId, doc, {
signaling: [signalingUrl(storeId)],
maxConns: 5,
const provider = new HocuspocusProvider({
url: providerUrl(storeId),
name: storeId,
document: doc,
});
get().setStore(storeId, { provider });
@@ -0,0 +1,3 @@
export interface DocAttachment {
file: string;
}
@@ -1,4 +1,5 @@
import React, { Fragment } from 'react';
import { Button } from '@openfun/cunningham-react';
import React, { Fragment, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, StyledLink, Text } from '@/components';
@@ -9,96 +10,124 @@ import {
currentDocRole,
useTransRole,
} from '@/features/docs/doc-management';
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { useDate } from '@/hook';
import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocHeader = ({ doc }: DocHeaderProps) => {
export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const { formatDate } = useDate();
const transRole = useTransRole();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
return (
<Card
$margin="small"
aria-label={t('It is the card information about the document.')}
>
<Box $padding="small" $direction="row" $align="center">
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$hasTransition
$radius="5px"
$padding="tiny"
>
home
</Text>
</StyledLink>
<Box
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'small' }}
/>
<Text as="h2" $align="center" $margin={{ all: 'none', left: 'tiny' }}>
{doc.title}
</Text>
<DocToolBox doc={doc} />
</Box>
<Box
$direction="row"
$align="center"
$css="border-top:1px solid #eee"
$padding={{ horizontal: 'big', vertical: 'tiny' }}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
<>
<Card
$margin="small"
aria-label={t('It is the card information about the document.')}
>
<Box $direction="row" $align="center" $gap="0.5rem 2rem" $wrap="wrap">
{doc.is_public && (
<Box $padding="small" $direction="row" $align="center">
<StyledLink href="/">
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
$size="s"
$isMaterialIcon
$theme="primary"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$hasTransition
$radius="5px"
$padding="tiny"
>
{t('Public')}
home
</Text>
)}
</StyledLink>
<Box
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'small' }}
/>
<Box $gap="1rem" $direction="row">
<Text
as="h2"
$align="center"
$margin={{ all: 'none', left: 'tiny' }}
>
{doc.title}
</Text>
{versionId && (
<Button
onClick={() => {
setIsModalVersionOpen(true);
}}
size="small"
>
{t('Restore this version')}
</Button>
)}
</Box>
<DocToolBox doc={doc} />
</Box>
<Box
$direction="row"
$align="center"
$css="border-top:1px solid #eee"
$padding={{ horizontal: 'big', vertical: 'tiny' }}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
>
<Box $direction="row" $align="center" $gap="0.5rem 2rem" $wrap="wrap">
{doc.is_public && (
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
$size="s"
>
{t('Public')}
</Text>
)}
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
<Text $size="s" $display="inline" $elipsis $maxWidth="60vw">
{t('Owners:')}{' '}
<strong>
{doc.accesses
.filter(
(access) => access.role === Role.OWNER && access.user.email,
)
.map((access, index, accesses) => (
<Fragment key={`access-${index}`}>
{access.user.email}{' '}
{index < accesses.length - 1 ? ' / ' : ''}
</Fragment>
))}
</strong>
</Text>
</Box>
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
<Text $size="s" $display="inline" $elipsis $maxWidth="60vw">
{t('Owners:')}{' '}
<strong>
{doc.accesses
.filter(
(access) => access.role === Role.OWNER && access.user.email,
)
.map((access, index, accesses) => (
<Fragment key={`access-${index}`}>
{access.user.email}{' '}
{index < accesses.length - 1 ? ' / ' : ''}
</Fragment>
))}
</strong>
{t('Your role:')}{' '}
<strong>{transRole(currentDocRole(doc.abilities))}</strong>
</Text>
</Box>
<Text $size="s" $display="inline">
{t('Your role:')}{' '}
<strong>{transRole(currentDocRole(doc.abilities))}</strong>
</Text>
</Box>
</Card>
</Card>
{isModalVersionOpen && versionId && (
<ModalVersion
onClose={() => setIsModalVersionOpen(false)}
docId={doc.id}
versionId={versionId}
/>
)}
</>
);
};
@@ -9,6 +9,8 @@ import {
ModalShare,
ModalUpdateDoc,
} from '@/features/docs/doc-management';
import { useDocSummaryStore } from '@/features/docs/doc-summary';
import { useDocVersionStore } from '@/features/docs/doc-versioning';
import { ModalPDF } from './ModalExport';
@@ -23,6 +25,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
const { setIsPanelVersionOpen } = useDocVersionStore();
const { setIsPanelSummaryOpen } = useDocSummaryStore();
return (
<Box
@@ -77,6 +81,18 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
<Text $theme="primary">{t('Delete document')}</Text>
</Button>
)}
<Button
onClick={() => {
setIsPanelSummaryOpen(true);
setIsPanelVersionOpen(false);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">summarize</span>}
size="small"
>
<Text $theme="primary">{t('Summary')}</Text>
</Button>
<Button
onClick={() => {
setIsModalPDFOpen(true);
@@ -0,0 +1,104 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { useDocStore } from '../../doc-editor';
import { Doc } from '../../doc-management';
import { useDocSummaryStore } from '../stores';
interface SummaryProps {
doc: Doc;
}
export const Summary = ({ doc }: SummaryProps) => {
const { docsStore } = useDocStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const headingFiltering = useCallback(
() => editor?.document.filter((block) => block.type === 'heading'),
[editor?.document],
);
const [headings, setHeadings] = useState(headingFiltering());
const { setIsPanelSummaryOpen } = useDocSummaryStore();
useEffect(() => {
return () => {
setIsPanelSummaryOpen(false);
};
}, [setIsPanelSummaryOpen]);
if (!editor) {
return null;
}
editor.onEditorContentChange(() => {
setHeadings(headingFiltering());
});
return (
<Box $overflow="auto" $padding="small">
{headings?.map((heading) => (
<BoxButton
key={heading.id}
onClick={() => {
editor.focus();
editor?.setTextCursorPosition(heading.id, 'end');
document
.querySelector(`[data-id="${heading.id}"]`)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
style={{ textAlign: 'left' }}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{heading.content?.[0]?.type === 'text' && heading.content?.[0]?.text
? `- ${heading.content[0].text}`
: ''}
</Text>
</BoxButton>
))}
<Box
$height="1px"
$width="auto"
$background="#e5e5e5"
$margin={{ vertical: 'small' }}
$css="flex: none;"
/>
<BoxButton
onClick={() => {
editor.focus();
document.querySelector(`.bn-editor`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Back to top')}
</Text>
</BoxButton>
<BoxButton
onClick={() => {
editor.focus();
document
.querySelector(
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Go to bottom')}
</Text>
</BoxButton>
</Box>
);
};
@@ -0,0 +1 @@
export * from './Summary';
@@ -0,0 +1,2 @@
export * from './components';
export * from './stores';
@@ -0,0 +1 @@
export * from './useDocSummaryStore';
@@ -0,0 +1,13 @@
import { create } from 'zustand';
export interface UseDocSummaryStore {
isPanelSummaryOpen: boolean;
setIsPanelSummaryOpen: (isOpen: boolean) => void;
}
export const useDocSummaryStore = create<UseDocSummaryStore>((set) => ({
isPanelSummaryOpen: false,
setIsPanelSummaryOpen: (isPanelSummaryOpen) => {
set(() => ({ isPanelSummaryOpen }));
},
}));
@@ -0,0 +1,122 @@
import {
Alert,
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import * as Y from 'yjs';
import { Box, Text } from '@/components';
import { toBase64, useDocStore } from '@/features/docs/doc-editor';
import { Doc, useUpdateDoc } from '@/features/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '../api/useDocVersions';
import { Versions } from '../types';
import { revertUpdate } from '../utils';
interface ModalVersionProps {
onClose: () => void;
docId: Doc['id'];
versionId: Versions['version_id'];
}
export const ModalVersion = ({
onClose,
docId,
versionId,
}: ModalVersionProps) => {
const { toast } = useToastProvider();
const router = useRouter();
const { docsStore, setStore } = useDocStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
onSuccess: () => {
const onDisplaySuccess = () => {
toast(t('Version restored successfully'), VariantType.SUCCESS);
router.push(`/docs/${docId}`);
};
if (!docsStore?.[docId]?.provider || !docsStore?.[versionId]?.provider) {
onDisplaySuccess();
return;
}
setStore(docId, {
editor: undefined,
});
revertUpdate(
docsStore[docId].provider.document,
docsStore[docId].provider.document,
docsStore[versionId].provider.document,
);
onDisplaySuccess();
},
});
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
rightActions={
<Button
aria-label={t('Restore')}
color="primary"
fullWidth
onClick={() => {
const newDoc = toBase64(
Y.encodeStateAsUpdate(docsStore?.[versionId]?.provider.document),
);
updateDoc({
id: docId,
content: newDoc,
});
}}
>
{t('Restore')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $gap="1rem">
<Text $isMaterialIcon $size="36px" $theme="primary">
restore
</Text>
<Text as="h2" $size="h3" $margin="none">
{t('Restore this version?')}
</Text>
</Box>
}
>
<Box aria-label={t('Modal confirmation to restore the version')}>
<Alert canClose={false} type={VariantType.WARNING}>
<Box>
<Text>
{t('Your current document will revert to this version.')}
</Text>
<Text>{t('If a member is editing, his works can be lost.')}</Text>
</Box>
</Alert>
</Box>
</Modal>
);
};
@@ -4,11 +4,11 @@ import React, { PropsWithChildren, useState } from 'react';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDocStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { Versions } from '../types';
import { revertUpdate } from '../utils';
import { ModalVersion } from './ModalVersion';
interface VersionItemProps {
docId: Doc['id'];
@@ -25,15 +25,16 @@ export const VersionItem = ({
link,
isActive,
}: VersionItemProps) => {
const { setForceSave, docsStore, setStore } = useDocStore();
const { colorsTokens } = useCunninghamTheme();
const [isDropOpen, setIsDropOpen] = useState(false);
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
return (
<Box
as="li"
$background={isActive ? colorsTokens()['primary-300'] : 'transparent'}
$css={`
<>
<Box
as="li"
$background={isActive ? colorsTokens()['primary-300'] : 'transparent'}
$css={`
border-left: 4px solid transparent;
border-bottom: 1px solid ${colorsTokens()['primary-100']};
&:hover{
@@ -41,71 +42,61 @@ export const VersionItem = ({
background: ${colorsTokens()['primary-300']};
}
`}
$hasTransition
$minWidth="13rem"
>
<Link href={link} isActive={isActive}>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
$align="center"
$direction="row"
$justify="space-between"
$width="100%"
>
<Box $direction="row" $gap="0.5rem" $align="center">
<Text $isMaterialIcon $size="24px" $theme="primary">
description
</Text>
<Text $weight="bold" $theme="primary" $size="m">
{text}
</Text>
$hasTransition
$minWidth="13rem"
>
<Link href={link} isActive={isActive}>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
$align="center"
$direction="row"
$justify="space-between"
$width="100%"
>
<Box $direction="row" $gap="0.5rem" $align="center">
<Text $isMaterialIcon $size="24px" $theme="primary">
description
</Text>
<Text $weight="bold" $theme="primary" $size="m">
{text}
</Text>
</Box>
{isActive && versionId && (
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the version options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
<Button
onClick={() => {
setIsModalVersionOpen(true);
}}
color="primary-text"
icon={<span className="material-icons">save</span>}
size="small"
>
<Text $theme="primary">{t('Restore the version')}</Text>
</Button>
</Box>
</DropButton>
)}
</Box>
{isActive && versionId && (
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the version options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
<Button
onClick={() => {
setIsDropOpen(false);
setForceSave(versionId ? 'version' : 'current');
if (
!docsStore?.[docId]?.provider ||
!docsStore?.[versionId]?.provider
) {
return;
}
setStore(docId, {
editor: undefined,
});
revertUpdate(
docsStore[docId].provider.doc,
docsStore[docId].provider.doc,
docsStore[versionId].provider.doc,
);
}}
color="primary-text"
icon={<span className="material-icons">save</span>}
size="small"
>
<Text $theme="primary">{t('Restore the version')}</Text>
</Button>
</Box>
</DropButton>
)}
</Box>
</Link>
</Box>
</Link>
</Box>
{isModalVersionOpen && versionId && (
<ModalVersion
onClose={() => setIsModalVersionOpen(false)}
docId={docId}
versionId={versionId}
/>
)}
</>
);
};
@@ -1,6 +1,6 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import React, { useMemo, useRef } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
@@ -9,6 +9,7 @@ import { Doc } from '@/features/docs/doc-management';
import { useDate } from '@/hook';
import { useDocVersionsInfiniteQuery } from '../api/useDocVersions';
import { useDocVersionStore } from '../stores';
import { Versions } from '../types';
import { VersionItem } from './VersionItem';
@@ -104,6 +105,13 @@ export const VersionList = ({ doc }: VersionListProps) => {
return acc.concat(page.results);
}, [] as Versions[]);
}, [data?.pages]);
const { setIsPanelVersionOpen } = useDocVersionStore();
useEffect(() => {
return () => {
setIsPanelVersionOpen(false);
};
}, [setIsPanelVersionOpen]);
return (
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}>
@@ -1 +1,2 @@
export * from './Panel';
export * from './ModalVersion';
export * from './VersionList';
@@ -1,3 +1,4 @@
export * from './api';
export * from './components';
export * from './stores';
export * from './types';
@@ -0,0 +1 @@
export * from './useDocVersionStore';
@@ -0,0 +1,13 @@
import { create } from 'zustand';
export interface UseDocVersionStore {
isPanelVersionOpen: boolean;
setIsPanelVersionOpen: (isOpen: boolean) => void;
}
export const useDocVersionStore = create<UseDocVersionStore>((set) => ({
isPanelVersionOpen: false,
setIsPanelVersionOpen: (isPanelVersionOpen) => {
set(() => ({ isPanelVersionOpen }));
},
}));
@@ -53,7 +53,12 @@ export const DocsGrid = () => {
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const [sortModel, setSortModel] = useState<SortModel>([]);
const [sortModel, setSortModel] = useState<SortModel>([
{
field: 'updated_at',
sort: 'desc',
},
]);
const { page, pageSize, setPagesCount } = pagination;
const [docs, setDocs] = useState<Doc[]>([]);
@@ -9,15 +9,16 @@
"Administrator": "Administrateur",
"Are you sure you want to delete the document \"{{title}}\"?": "Êtes-vous sûr de vouloir supprimer le document \"{{title}}\" ?",
"Back to home page": "Retour à l'accueil",
"Back to top": "Retour en haut",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Choose a role": "Choisissez un rôle",
"Close the document version panel": "Fermer le panneau des versions du document",
"Close the modal": "Fermer la modale",
"Close the panel": "Fermer le panneau",
"Compliance status": "État de conformité",
"Confirm deletion": "Confirmer la suppression",
"Content modal to delete document": "Contenu modal pour supprimer le document",
"Content modal to generate a PDF": "Contenu modal pour générer un PDF",
"Content modal to export the document": "Contenu modal pour exporter le document",
"Cookies placed": "Cookies déposés",
"Copyright": "Copyright",
"Create a new document": "Créer un nouveau document",
@@ -33,25 +34,28 @@
"Docs Logo": "Logo Docs",
"Document icon": "Icône du document",
"Document name": "Nom du document",
"Document version panel": "Panneau des versions du document",
"Document panel": "Panneau du document",
"Documents": "Documents",
"Docx": "Docx",
"Download": "Télécharger",
"E-mail:": "E-mail:",
"Editor": "Éditeur",
"Enter the new name of the selected document.": "Entrez le nouveau nom du document sélectionné.",
"Established on December 20, 2023.": "Établi le 20 décembre 2023.",
"Export": "Exporter",
"Export your document, it will be inserted in the selected template.": "Exportez votre document, il sera inséré dans le modèle sélectionné.",
"Failed to add the member in the document.": "Impossible d'ajouter le membre dans le document.",
"Failed to create the invitation for {{email}}.": "Impossible de créer l'invitation pour {{email}}.",
"Find a member to add to the document": "Trouver un membre à ajouter au document",
"Freedom Equality Fraternity Logo": "Logo Liberté Égalité Fraternité",
"French Interministerial Directorate for Digital Affairs (DINUM), 20 avenue de Ségur 75007 Paris.": "Direction interministérielle des affaires numériques (DINUM), 20 avenue de Segur 75007 Paris.",
"Generate PDF": "Générer PDF",
"Generate a PDF from your document, it will be inserted in the selected template.": "Générez un PDF à partir de votre document, il sera inséré dans le modèle sélectionné.",
"Go to bottom": "Aller en bas",
"If a member is editing, his works can be lost.": "Si un membre est en train d'éditer, ses travaux peuvent être perdus.",
"If you are unable to access a content or a service, you can contact the person responsible for https://lasuite.numerique.gouv.fr to be directed to an accessible alternative or to obtain the content in another form.": "Si vous ne pouvez pas accéder à un contenu ou à un service, vous pouvez contacter la personne responsable de https://lasuite. umerique.gouv.fr pour être dirigé vers une alternative accessible ou pour obtenir le contenu sous une autre forme.",
"Illustration:": "Illustration :",
"Improvement and contact": "Amélioration et contact",
"Invitation sent to {{email}}.": "Invitation envoyée à {{email}}.",
"Invite new members to {{title}}": "Invitez de nouveaux membres à rejoindre {{title}}",
"Invited": "Invité",
"Is it public ?": "Est-ce public?",
"It is the card information about the document.": "Il s'agit de la carte d'information du document.",
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Il semble que la page que vous cherchez n'existe pas ou ne puisse pas être affichée correctement.",
@@ -60,21 +64,23 @@
"Language Icon": "Icône de langue",
"Legal Notice": "Mentions Legales",
"Legal notice": "Mention légale",
"List invitation card": "Carte de liste d'invitation",
"List members card": "Carte liste des membres",
"Logout": "Se déconnecter",
"Marianne Logo": "Logo Marianne",
"Members": "Membres",
"Modal confirmation to restore the version": "Modale de confirmation pour restaurer la version",
"More info?": "Plus d'infos ?",
"My account": "Mon compte",
"No editor found": "Pas d'éditeur trouvé",
"Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.",
"Offline ?!": "Hors-ligne ?!",
"Open the document options": "Ouvrir les options du document",
"Open the document version panel": "Ouvrir le panneau des versions du document",
"Open the panel": "Ouvrir le panneau",
"Open the version options": "Ouvrir les options de version",
"Ouch !": "Aïe !",
"Owner": "Propriétaire",
"Owners:": "Propriétaires:",
"PDF": "PDF",
"Personal data and cookies": "Données personnelles et cookies",
"Public": "Public",
"Publication Director": "Directeur de la publication",
@@ -83,18 +89,25 @@
"Read only, you cannot edit this document.": "En lecture seule, vous ne pouvez pas éditer ce document.",
"Reader": "Lecteur",
"Remedies": "Voie de recours",
"Restore": "Restaurer",
"Restore the version": "Restaurer la version",
"Restore this version": "Restaurer cette version",
"Restore this version?": "Restaurer cette version?",
"Role": "Rôle",
"SUMMARY": "RÉSUMÉ",
"Search by email": "Recherche par email",
"Send a letter by post (free of charge, no stamp needed):": "Envoyer un courrier par la poste (gratuit, ne pas mettre de timbre):",
"Share": "Partager",
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
"Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Directrice numérique interministériel (DINUM).",
"Summary": "Résumé",
"Template": "Template",
"The document has been deleted.": "Le document a bien été supprimé.",
"The document has been updated.": "Le document a été mis à jour.",
"The invitation has been removed.": "L'invitation a été supprimée.",
"The member has been removed from the document": "Le membre a été retiré du document",
"The role has been updated": "Le rôle a été mis à jour",
"The role has been updated.": "Le rôle a été mis à jour.",
"The team in charge of the digital workspace \"La Suite numérique\" can be contacted directly at": "L'équipe responsable de l'espace de travail numérique \"La Suite numérique\" peut être contactée directement à l'adresse",
"This accessibility statement applies to the site hosted on": "Cette déclaration d'accessibilité s'applique au site hébergé sur",
"This allows us to measure the number of visits and understand which pages are the most viewed.": "Cela nous permet de mesurer le nombre de visites et de comprendre quelles pages sont les plus consultées.",
@@ -110,6 +123,7 @@
"VERSIONS": "VERSIONS",
"Validate": "Valider",
"Validate the modification": "Valider les modifications",
"Version restored successfully": "Version restaurée avec succès",
"We didn't find a mail matching, try to be more accurate": "Nous n'avons pas trouvé de correspondance par mail, essayez d'être plus précis",
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure daudience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
"We try to respond within 2 working days.": "Nous essayons de répondre dans les 2 jours ouvrables.",
@@ -118,10 +132,10 @@
"You can:": "Vous pouvez:",
"You cannot update the role or remove other owner.": "Vous ne pouvez pas mettre à jour le rôle ou supprimer un autre propriétaire.",
"You don't have any document yet.": "Vous n'avez pas encore de document.",
"Your document \"{{docTitle}}\" has been saved.": "Votre document \"{{docTitle}}\" a été enregistré.",
"Your pdf was downloaded succesfully": "Votre pdf a été téléchargé avec succès",
"Your current document will revert to this version.": "Votre document actuel va revenir à cette version.",
"Your role": "Votre rôle",
"Your role:": "Votre rôle:",
"Your {{format}} was downloaded succesfully": "Votre {{format}} a été téléchargé avec succès",
"accessibility-contact-defenseurdesdroits": "Contacter le délégué du<1>Défenseur des droits dans votre région</1>",
"accessibility-dinum-services": "<strong>DINUM</strong> s'engage à rendre accessibles ses services numériques, conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.",
"accessibility-form-defenseurdesdroits": "Écrire un message au<1>Défenseur des droits</1>",
+6 -1
View File
@@ -21,7 +21,12 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
<>
<Head>
<title>{t('Docs')}</title>
<meta name="description" content={t('Docs Description')} />
<meta
name="description"
content={t(
'Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.',
)}
/>
<link rel="icon" href="/favicon.ico" sizes="any" />
</Head>
<AppProvider>{getLayout(<Component {...pageProps} />)}</AppProvider>
@@ -1,6 +1,7 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { Box, Text } from '@/components';
import { TextErrors } from '@/components/TextErrors';
@@ -33,6 +34,14 @@ const DocPage = ({ id }: DocProps) => {
const { data: doc, isLoading, isError, error } = useDoc({ id });
const navigate = useNavigate();
useEffect(() => {
if (doc?.title) {
setTimeout(() => {
document.title = `${doc.title} - Docs`;
}, 100);
}
}, [doc?.title]);
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
@@ -1,168 +0,0 @@
/**
* Based on https://github.com/yjs/y-webrtc/blob/master/bin/server.js
*/
import http from 'http';
import * as map from 'lib0/map';
import WebSocket, { WebSocketServer } from 'ws';
type MessageYJSType = {
type: string;
topics?: string[];
topic?: string;
clients?: number;
};
type MessageYJSTypes = MessageYJSType | string;
const wsReadyStateConnecting = 0;
const wsReadyStateOpen = 1;
const pingTimeout = 30000;
const port = process.env.PORT || 4444;
const wss = new WebSocketServer({ noServer: true });
const topics = new Map<string, Set<{ url: string; conn: WebSocket }>>();
const send = (conn: WebSocket, message: MessageYJSTypes) => {
if (
conn.readyState !== wsReadyStateConnecting &&
conn.readyState !== wsReadyStateOpen
) {
conn.close();
}
try {
conn.send(JSON.stringify(message));
} catch (e) {
conn.close();
}
};
/**
* Setup a new client
*/
const onconnection = (conn: WebSocket, url: string) => {
const subscribedTopics = new Set<string>();
let closed = false;
// Check if connection is still alive
let pongReceived = true;
const pingInterval = setInterval(() => {
if (!pongReceived) {
conn.close();
clearInterval(pingInterval);
} else {
pongReceived = false;
try {
conn.ping();
} catch (e) {
conn.close();
}
}
}, pingTimeout);
conn.on('pong', () => {
pongReceived = true;
});
conn.on('close', () => {
subscribedTopics.forEach((topicName) => {
const subs = topics.get(topicName) || new Set();
subs.forEach((sub) => {
if (sub.url === url && sub.conn === conn) {
subs.delete(sub);
}
});
if (subs.size === 0) {
topics.delete(topicName);
}
});
subscribedTopics.clear();
closed = true;
});
conn.on('message', (message: MessageYJSTypes) => {
if (typeof message === 'string' || message instanceof Buffer) {
message = JSON.parse(message.toString()) as MessageYJSType;
}
if (message && message.type && !closed) {
switch (message.type) {
case 'subscribe':
(message.topics || []).forEach((topicName) => {
if (typeof topicName === 'string') {
// add conn to topic
const topic = map.setIfUndefined(
topics,
topicName,
() => new Set(),
);
let isAlreadyAdded = false;
topic.forEach((sub) => {
if (sub.url === url && sub.conn === conn) {
isAlreadyAdded = true;
}
});
if (!isAlreadyAdded) {
topic.add({ url, conn });
subscribedTopics.add(topicName);
}
}
});
break;
case 'unsubscribe':
(message.topics || []).forEach((topicName) => {
const subs = topics.get(topicName);
if (subs) {
subs.forEach((sub) => {
if (sub.conn === conn) {
subs.delete(sub);
}
});
}
});
break;
case 'publish':
if (message.topic) {
const receivers = topics.get(message.topic);
if (receivers) {
message.clients = receivers.size;
receivers.forEach(({ url: receiverUrl, conn: receiverConn }) => {
if (receiverUrl === url) {
send(receiverConn, message);
}
});
}
}
break;
case 'ping':
send(conn, { type: 'pong' });
}
}
});
};
wss.on('connection', (conn, request) => {
const url = request.url;
onconnection(conn, url || '');
});
const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end('okay');
});
server.on('upgrade', (request, socket, head) => {
const handleAuth = (ws: WebSocket) => {
wss.emit('connection', ws, request);
};
wss.handleUpgrade(request, socket, head, handleAuth);
});
server.listen(port);
console.log('Signaling server running on port :', port);
+7 -6
View File
@@ -1,11 +1,12 @@
{
"name": "impress",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"workspaces": {
"packages": [
"apps/*",
"packages/*"
"packages/*",
"servers/*"
]
},
"scripts": {
@@ -24,10 +25,10 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@blocknote/core": "0.15.5",
"@blocknote/mantine": "0.15.5",
"@blocknote/react": "0.15.5",
"@types/node": "20.16.1",
"@blocknote/core": "0.15.6",
"@blocknote/mantine": "0.15.6",
"@blocknote/react": "0.15.6",
"@types/node": "20.16.3",
"@types/react-dom": "18.3.0",
"cross-env": "7.0.3",
"react": "18.3.1",
@@ -1,20 +1,20 @@
{
"name": "eslint-config-impress",
"version": "1.2.0",
"version": "1.3.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "14.2.5",
"@tanstack/eslint-plugin-query": "5.51.15",
"@typescript-eslint/eslint-plugin": "8.1.0",
"@typescript-eslint/parser": "8.1.0",
"@next/eslint-plugin-next": "14.2.7",
"@tanstack/eslint-plugin-query": "5.53.0",
"@typescript-eslint/eslint-plugin": "8.3.0",
"@typescript-eslint/parser": "8.3.0",
"eslint": "8.57.0",
"eslint-config-next": "14.2.5",
"eslint-config-next": "14.2.7",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "28.8.0",
"eslint-plugin-jest": "28.8.2",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-prettier": "5.2.1",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",
@@ -15,9 +15,9 @@
"@types/node": "*",
"eslint-config-impress": "*",
"eslint-plugin-import": "2.29.1",
"i18next-parser": "8.8.0",
"i18next-parser": "9.0.2",
"jest": "29.7.0",
"ts-jest": "29.2.4",
"ts-jest": "29.2.5",
"typescript": "*",
"yargs": "17.7.2"
}
@@ -1,7 +1,7 @@
{
"name": "y-webrtc-signaling",
"version": "1.2.0",
"description": "WebRTC server for Yjs",
"name": "server-y-provider",
"version": "1.3.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",
"type": "module",
@@ -15,15 +15,13 @@
"node": ">=18"
},
"dependencies": {
"lib0": "0.2.97",
"ws": "8.18.0"
"@hocuspocus/server": "2.13.5"
},
"devDependencies": {
"@types/node": "*",
"@types/ws": "8.5.12",
"eslint-config-impress": "*",
"nodemon": "3.1.4",
"ts-jest": "29.2.4",
"ts-jest": "29.2.5",
"ts-node": "10.9.2",
"typescript": "*"
}
@@ -0,0 +1,18 @@
import { Server } from '@hocuspocus/server';
const port = Number(process.env.PORT || 4444);
const server = Server.configure({
name: 'docs-y-provider',
port: port,
timeout: 30000,
debounce: 2000,
maxDebounce: 30000,
quiet: true,
});
server.listen().catch((error) => {
console.error('Failed to start the server:', error);
});
console.log('Websocket server running on port :', port);
+1668 -1939
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -84,11 +84,11 @@ frontend:
pullPolicy: Always
tag: "latest"
webrtc:
yProvider:
replicas: 1
image:
repository: localhost:5001/impress-y-webrtc-signaling
repository: localhost:5001/impress-y-provider
pullPolicy: Always
tag: "latest"
@@ -103,3 +103,17 @@ ingressWS:
ingressAdmin:
enabled: true
host: impress.127.0.0.1.nip.io
ingressMedia:
enabled: true
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/retrieve-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
serviceMedia:
host: minio.impress.svc.cluster.local
port: 9000
@@ -1,7 +1,7 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v1.2.0"
tag: "v1.3.0-preprod"
backend:
migrateJobAnnotations:
@@ -24,10 +24,18 @@ backend:
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_EMAIL_FROM: "noreply@docs.numerique.gouv.fr"
DJANGO_EMAIL_HOST: "smtp.tem.scw.cloud"
DJANGO_EMAIL_PORT: 587
DJANGO_EMAIL_USE_TLS: True
DJANGO_EMAIL_FROM: "noreply@docs.beta.numerique.gouv.fr"
DJANGO_EMAIL_HOST_USER:
secretKeyRef:
name: backend
key: DJANGO_EMAIL_HOST_USER
DJANGO_EMAIL_HOST_PASSWORD:
secretKeyRef:
name: backend
key: DJANGO_EMAIL_HOST_PASSWORD
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
@@ -116,13 +124,13 @@ frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v1.2.0"
tag: "v1.3.0-preprod"
webrtc:
yProvider:
image:
repository: lasuite/impress-y-webrtc-signaling
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "v1.2.0"
tag: "v1.3.0-preprod"
ingress:
enabled: true
@@ -146,3 +154,21 @@ ingressAdmin:
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
ingressMedia:
enabled: true
host: impress-preprod.beta.numerique.gouv.fr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/auth-url: https://impress-preprod.beta.numerique.gouv.fr/api/v1.0/documents/retrieve-auth/
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/rewrite-target: /impress-preprod-impress-media-storage/$1
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/upstream-vhost: s3.margaret-hamilton.indiehosters.net
serviceMedia:
host: s3.margaret-hamilton.indiehosters.net
port: 443
@@ -1,7 +1,7 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v1.2.0"
tag: "v1.3.0"
backend:
migrateJobAnnotations:
@@ -24,10 +24,18 @@ backend:
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_EMAIL_FROM: "noreply@docs.numerique.gouv.fr"
DJANGO_EMAIL_HOST: "smtp.tem.scw.cloud"
DJANGO_EMAIL_PORT: 587
DJANGO_EMAIL_USE_TLS: True
DJANGO_EMAIL_FROM: "noreply@docs.beta.numerique.gouv.fr"
DJANGO_EMAIL_HOST_USER:
secretKeyRef:
name: backend
key: DJANGO_EMAIL_HOST_USER
DJANGO_EMAIL_HOST_PASSWORD:
secretKeyRef:
name: backend
key: DJANGO_EMAIL_HOST_PASSWORD
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
@@ -116,13 +124,13 @@ frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v1.2.0"
tag: "v1.3.0"
webrtc:
yProvider:
image:
repository: lasuite/impress-y-webrtc-signaling
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "v1.2.0"
tag: "v1.3.0"
ingress:
enabled: true
@@ -146,3 +154,21 @@ ingressAdmin:
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
ingressMedia:
enabled: true
host: docs.numerique.gouv.fr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/auth-url: https://docs.numerique.gouv.fr/api/v1.0/documents/retrieve-auth/
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/rewrite-target: /impress-impress-media-storage/$1
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/upstream-vhost: s3.hedy-lamarr.indiehosters.net
serviceMedia:
host: s3.hedy-lamarr.indiehosters.net
port: 443
@@ -24,10 +24,18 @@ backend:
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_EMAIL_FROM: "noreply@docs.numerique.gouv.fr"
DJANGO_EMAIL_HOST: "smtp.tem.scw.cloud"
DJANGO_EMAIL_PORT: 587
DJANGO_EMAIL_USE_TLS: True
DJANGO_EMAIL_FROM: "noreply@docs.beta.numerique.gouv.fr"
DJANGO_EMAIL_HOST_USER:
secretKeyRef:
name: backend
key: DJANGO_EMAIL_HOST_USER
DJANGO_EMAIL_HOST_PASSWORD:
secretKeyRef:
name: backend
key: DJANGO_EMAIL_HOST_PASSWORD
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
@@ -118,9 +126,9 @@ frontend:
pullPolicy: Always
tag: "main"
webrtc:
yProvider:
image:
repository: lasuite/impress-y-webrtc-signaling
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "main"
@@ -146,3 +154,21 @@ ingressAdmin:
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
ingressMedia:
enabled: true
host: impress-staging.beta.numerique.gouv.fr
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/auth-url: https://impress-staging.beta.numerique.gouv.fr/api/v1.0/documents/retrieve-auth/
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/rewrite-target: /impress-staging-impress-media-storage/$1
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/upstream-vhost: s3.margaret-hamilton.indiehosters.net
serviceMedia:
host: s3.margaret-hamilton.indiehosters.net
port: 443
+6
View File
@@ -6,5 +6,11 @@ stringData:
DJANGO_SUPERUSER_EMAIL: {{ .Values.djangoSuperUserEmail }}
DJANGO_SUPERUSER_PASSWORD: {{ .Values.djangoSuperUserPass }}
DJANGO_SECRET_KEY: {{ .Values.djangoSecretKey }}
{{- if .Values.djangoEmailHostUser }}
DJANGO_EMAIL_HOST_USER: {{ .Values.djangoEmailHostUser }}
{{- end }}
{{- if .Values.djangoEmailHostPassword }}
DJANGO_EMAIL_HOST_PASSWORD: {{ .Values.djangoEmailHostPassword }}
{{- end }}
OIDC_RP_CLIENT_ID: {{ .Values.oidc.clientId }}
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
+3 -3
View File
@@ -149,12 +149,12 @@ Requires top level scope
{{- end }}
{{/*
Full name for the webrtc
Full name for the yProvider
Requires top level scope
*/}}
{{- define "impress.webrtc.fullname" -}}
{{ include "impress.fullname" . }}-webrtc
{{- define "impress.yProvider.fullname" -}}
{{ include "impress.fullname" . }}-y-provider
{{- end }}
{{/*
@@ -0,0 +1,83 @@
{{- if .Values.ingressMedia.enabled -}}
{{- $fullName := include "impress.fullname" . -}}
{{- if and .Values.ingressMedia.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingressMedia.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingressMedia.annotations "kubernetes.io/ingress.class" .Values.ingressMedia.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 }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "impress.labels" . | nindent 4 }}
{{- with .Values.ingressMedia.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingressMedia.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingressMedia.className }}
{{- end }}
{{- if .Values.ingressMedia.tls.enabled }}
tls:
{{- if .Values.ingressMedia.host }}
- secretName: {{ $fullName }}-tls
hosts:
- {{ .Values.ingressMedia.host | quote }}
{{- end }}
{{- range .Values.ingressMedia.tls.additional }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- if .Values.ingressMedia.host }}
- host: {{ .Values.ingressMedia.host | quote }}
http:
paths:
- path: {{ .Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- range .Values.ingressMedia.hosts }}
- host: {{ . | quote }}
http:
paths:
- path: {{ $.Values.ingressMedia.path | quote }}
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
pathType: Prefix
{{- end }}
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ $fullName }}-media
port:
number: {{ .Values.serviceMedia.port }}
{{- else }}
serviceName: {{ $fullName }}-media
servicePort: {{ .Values.serviceMedia.port }}
{{- end }}
{{- end }}
{{- end }}
+6 -6
View File
@@ -52,17 +52,17 @@ spec:
{{- end }}
backend:
service:
name: {{ include "impress.webrtc.fullname" . }}
name: {{ include "impress.yProvider.fullname" . }}
port:
number: {{ .Values.webrtc.service.port }}
number: {{ .Values.yProvider.service.port }}
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "impress.webrtc.fullname" . }}
name: {{ include "impress.yProvider.fullname" . }}
port:
number: {{ .Values.webrtc.service.port }}
number: {{ .Values.yProvider.service.port }}
{{- else }}
serviceName: {{ include "impress.webrtc.fullname" . }}
servicePort: {{ .Values.webrtc.service.port }}
serviceName: {{ include "impress.yProvider.fullname" . }}
servicePort: {{ .Values.yProvider.service.port }}
{{- end }}
{{- with .Values.ingressWS.customBackends }}
{{- toYaml . | nindent 10 }}
+14
View File
@@ -0,0 +1,14 @@
{{- $fullName := include "impress.fullname" . -}}
{{- $component := "media" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-media
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "impress.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.serviceMedia.annotations | nindent 4 }}
spec:
type: ExternalName
externalName: {{ $.Values.serviceMedia.host }}
@@ -1,21 +0,0 @@
{{- $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 }}
@@ -1,6 +1,6 @@
{{- $envVars := include "impress.common.env" (list . .Values.webrtc) -}}
{{- $fullName := include "impress.webrtc.fullname" . -}}
{{- $component := "webrtc" -}}
{{- $envVars := include "impress.common.env" (list . .Values.yProvider) -}}
{{- $fullName := include "impress.yProvider.fullname" . -}}
{{- $component := "yProvider" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -9,14 +9,14 @@ metadata:
labels:
{{- include "impress.common.labels" (list . $component) | nindent 4 }}
spec:
replicas: {{ .Values.webrtc.replicas }}
replicas: {{ .Values.yProvider.replicas }}
selector:
matchLabels:
{{- include "impress.common.selectorLabels" (list . $component) | nindent 6 }}
template:
metadata:
annotations:
{{- with .Values.webrtc.podAnnotations }}
{{- with .Values.yProvider.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
@@ -26,19 +26,19 @@ spec:
imagePullSecrets:
- name: {{ include "impress.secret.dockerconfigjson.name" (dict "fullname" (include "impress.fullname" .) "imageCredentials" $.Values.image.credentials) }}
{{- end}}
shareProcessNamespace: {{ .Values.webrtc.shareProcessNamespace }}
shareProcessNamespace: {{ .Values.yProvider.shareProcessNamespace }}
containers:
{{- with .Values.webrtc.sidecars }}
{{- with .Values.yProvider.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 }}
image: "{{ (.Values.yProvider.image | default dict).repository | default .Values.image.repository }}:{{ (.Values.yProvider.image | default dict).tag | default .Values.image.tag }}"
imagePullPolicy: {{ (.Values.yProvider.image | default dict).pullPolicy | default .Values.image.pullPolicy }}
{{- with .Values.yProvider.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.webrtc.args }}
{{- with .Values.yProvider.args }}
args:
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -46,27 +46,27 @@ spec:
{{- if $envVars}}
{{- $envVars | indent 12 }}
{{- end }}
{{- with .Values.webrtc.securityContext }}
{{- with .Values.yProvider.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.webrtc.service.targetPort }}
containerPort: {{ .Values.yProvider.service.targetPort }}
protocol: TCP
{{- if .Values.webrtc.probes.liveness }}
{{- if .Values.yProvider.probes.liveness }}
livenessProbe:
{{- include "impress.probes.abstract" (merge .Values.webrtc.probes.liveness (dict "targetPort" .Values.webrtc.service.targetPort )) | nindent 12 }}
{{- include "impress.probes.abstract" (merge .Values.yProvider.probes.liveness (dict "targetPort" .Values.yProvider.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.webrtc.probes.readiness }}
{{- if .Values.yProvider.probes.readiness }}
readinessProbe:
{{- include "impress.probes.abstract" (merge .Values.webrtc.probes.readiness (dict "targetPort" .Values.webrtc.service.targetPort )) | nindent 12 }}
{{- include "impress.probes.abstract" (merge .Values.yProvider.probes.readiness (dict "targetPort" .Values.yProvider.service.targetPort )) | nindent 12 }}
{{- end }}
{{- if .Values.webrtc.probes.startup }}
{{- if .Values.yProvider.probes.startup }}
startupProbe:
{{- include "impress.probes.abstract" (merge .Values.webrtc.probes.startup (dict "targetPort" .Values.webrtc.service.targetPort )) | nindent 12 }}
{{- include "impress.probes.abstract" (merge .Values.yProvider.probes.startup (dict "targetPort" .Values.yProvider.service.targetPort )) | nindent 12 }}
{{- end }}
{{- with .Values.webrtc.resources }}
{{- with .Values.yProvider.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
@@ -76,25 +76,25 @@ spec:
mountPath: {{ $value.path }}
subPath: content
{{- end }}
{{- range $name, $volume := .Values.webrtc.persistence }}
{{- range $name, $volume := .Values.yProvider.persistence }}
- name: "{{ $name }}"
mountPath: "{{ $volume.mountPath }}"
{{- end }}
{{- range .Values.webrtc.extraVolumeMounts }}
{{- range .Values.yProvider.extraVolumeMounts }}
- name: {{ .name }}
mountPath: {{ .mountPath }}
subPath: {{ .subPath | default "" }}
readOnly: {{ .readOnly }}
{{- end }}
{{- with .Values.webrtc.nodeSelector }}
{{- with .Values.yProvider.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.webrtc.affinity }}
{{- with .Values.yProvider.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.webrtc.tolerations }}
{{- with .Values.yProvider.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -104,7 +104,7 @@ spec:
configMap:
name: "{{ include "impress.fullname" $ }}-files-{{ $index }}"
{{- end }}
{{- range $name, $volume := .Values.webrtc.persistence }}
{{- range $name, $volume := .Values.yProvider.persistence }}
- name: "{{ $name }}"
{{- if eq $volume.type "emptyDir" }}
emptyDir: {}
@@ -113,7 +113,7 @@ spec:
claimName: "{{ $fullName }}-{{ $name }}"
{{- end }}
{{- end }}
{{- range .Values.webrtc.extraVolumes }}
{{- range .Values.yProvider.extraVolumes }}
- name: {{ .name }}
{{- if .existingClaim }}
persistentVolumeClaim:
@@ -0,0 +1,21 @@
{{- $envVars := include "impress.common.env" (list . .Values.yProvider) -}}
{{- $fullName := include "impress.yProvider.fullname" . -}}
{{- $component := "yProvider" -}}
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}
namespace: {{ .Release.Namespace | quote }}
labels:
{{- include "impress.common.labels" (list . $component) | nindent 4 }}
annotations:
{{- toYaml $.Values.yProvider.service.annotations | nindent 4 }}
spec:
type: {{ .Values.yProvider.service.type }}
ports:
- port: {{ .Values.yProvider.service.port }}
targetPort: {{ .Values.yProvider.service.targetPort }}
protocol: TCP
name: http
selector:
{{- include "impress.common.selectorLabels" (list . $component) | nindent 4 }}
+80 -50
View File
@@ -37,7 +37,7 @@ ingress:
## @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
## @param ingress.tls.enabled Wether 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
@@ -60,7 +60,7 @@ ingressWS:
## @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
## @param ingressWS.tls.enabled Wether 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
@@ -87,7 +87,7 @@ ingressAdmin:
## @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
## @param ingressAdmin.tls.enabled Wether 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
@@ -95,6 +95,36 @@ ingressAdmin:
enabled: true
additional: []
## @param ingressMedia.enabled whether to enable the Ingress or not
## @param ingressMedia.className IngressClass to use for the Ingress
## @param ingressMedia.host Host for the Ingress
## @param ingressMedia.path Path to use for the Ingress
ingressMedia:
enabled: false
className: null
host: impress.example.com
path: /media/(.*)
## @param ingressMedia.hosts Additional host to configure for the Ingress
hosts: [ ]
# - chart-example.local
## @param ingressMedia.tls.enabled Wether to enable TLS for the Ingress
## @skip ingressMedia.tls.additional
## @extra ingressMedia.tls.additional[].secretName Secret name for additional TLS config
## @extra ingressMedia.tls.additional[].hosts[] Hosts for additional TLS config
tls:
enabled: true
additional: []
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.example.com/api/v1.0/documents/retrieve-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
serviceMedia:
host: minio.impress.svc.cluster.local
port: 9000
annotations: {}
## @section backend
@@ -290,96 +320,96 @@ frontend:
## @param frontend.extraVolumes Additional volumes to mount on the frontend.
extraVolumes: []
## @section webrtc
## @section yProvider
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
yProvider:
## @param yProvider.image.repository Repository to use to pull impress's yProvider container image
## @param yProvider.image.tag impress's yProvider container tag
## @param yProvider.image.pullPolicy yProvider container image pull policy
image:
repository: lasuite/impress-y-webrtc-signaling
repository: lasuite/impress-y-provider
pullPolicy: IfNotPresent
tag: "latest"
## @param webrtc.command Override the webrtc container command
## @param yProvider.command Override the yProvider container command
command: []
## @param webrtc.args Override the webrtc container args
## @param yProvider.args Override the yProvider container args
args: []
## @param webrtc.replicas Amount of webrtc replicas
## @param yProvider.replicas Amount of yProvider replicas
replicas: 3
## @param webrtc.shareProcessNamespace Enable share process namewebrtc between containers
## @param yProvider.shareProcessNamespace Enable share process nameyProvider between containers
shareProcessNamespace: false
## @param webrtc.sidecars Add sidecars containers to webrtc deployment
## @param yProvider.sidecars Add sidecars containers to yProvider deployment
sidecars: []
## @param webrtc.securityContext Configure webrtc Pod security context
## @param yProvider.securityContext Configure yProvider 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
## @param yProvider.envVars Configure yProvider container environment variables
## @extra yProvider.envVars.BY_VALUE Example environment variable by setting value directly
## @extra yProvider.envVars.FROM_CONFIGMAP.configMapKeyRef.name Name of a ConfigMap when configuring env vars from a ConfigMap
## @extra yProvider.envVars.FROM_CONFIGMAP.configMapKeyRef.key Key within a ConfigMap when configuring env vars from a ConfigMap
## @extra yProvider.envVars.FROM_SECRET.secretKeyRef.name Name of a Secret when configuring env vars from a Secret
## @extra yProvider.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @skip yProvider.envVars
envVars:
<<: *commonEnvVars
## @param webrtc.podAnnotations Annotations to add to the webrtc Pod
## @param yProvider.podAnnotations Annotations to add to the yProvider 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
## @param yProvider.service.type yProvider Service type
## @param yProvider.service.port yProvider Service listening port
## @param yProvider.service.targetPort yProvider container listening port
## @param yProvider.service.annotations Annotations to add to the yProvider 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
## @param yProvider.probes Configure probe for yProvider
## @extra yProvider.probes.liveness.path Configure path for yProvider HTTP liveness probe
## @extra yProvider.probes.liveness.targetPort Configure port for yProvider HTTP liveness probe
## @extra yProvider.probes.liveness.initialDelaySeconds Configure initial delay for yProvider liveness probe
## @extra yProvider.probes.liveness.initialDelaySeconds Configure timeout for yProvider liveness probe
## @extra yProvider.probes.startup.path Configure path for yProvider HTTP startup probe
## @extra yProvider.probes.startup.targetPort Configure port for yProvider HTTP startup probe
## @extra yProvider.probes.startup.initialDelaySeconds Configure initial delay for yProvider startup probe
## @extra yProvider.probes.startup.initialDelaySeconds Configure timeout for yProvider startup probe
## @extra yProvider.probes.readiness.path Configure path for yProvider HTTP readiness probe
## @extra yProvider.probes.readiness.targetPort Configure port for yProvider HTTP readiness probe
## @extra yProvider.probes.readiness.initialDelaySeconds Configure initial delay for yProvider readiness probe
## @extra yProvider.probes.readiness.initialDelaySeconds Configure timeout for yProvider readiness probe
probes:
liveness:
path: /ping
initialDelaySeconds: 10
## @param webrtc.resources Resource requirements for the webrtc container
## @param yProvider.resources Resource requirements for the yProvider container
resources: {}
## @param webrtc.nodeSelector Node selector for the webrtc Pod
## @param yProvider.nodeSelector Node selector for the yProvider Pod
nodeSelector: {}
## @param webrtc.tolerations Tolerations for the webrtc Pod
## @param yProvider.tolerations Tolerations for the yProvider Pod
tolerations: []
## @param webrtc.affinity Affinity for the webrtc Pod
## @param yProvider.affinity Affinity for the yProvider 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
## @param yProvider.persistence Additional volumes to create and mount on the yProvider. Used for debugging purposes
## @extra yProvider.persistence.volume-name.size Size of the additional volume
## @extra yProvider.persistence.volume-name.type Type of the additional volume, persistentVolumeClaim or emptyDir
## @extra yProvider.persistence.volume-name.mountPath Path where the volume should be mounted to
persistence: {}
## @param webrtc.extraVolumeMounts Additional volumes to mount on the webrtc.
## @param yProvider.extraVolumeMounts Additional volumes to mount on the yProvider.
extraVolumeMounts: []
## @param webrtc.extraVolumes Additional volumes to mount on the webrtc.
## @param yProvider.extraVolumes Additional volumes to mount on the yProvider.
extraVolumes: []
+4 -6
View File
@@ -2,10 +2,10 @@
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section background-url="{% base64_static 'images/mail-header-background.png' %}" background-size="cover" background-repeat="no-repeat" background-position="0 -30px">
<mj-wrapper css-class="wrapper" padding="0 25px 0px 25px">
<mj-section background-url="{{site.domain}}/assets/mail-header-background.png" background-size="cover" background-repeat="no-repeat" background-position="0 -30px">
<mj-column>
<mj-image align="center" src="{% base64_static 'images/logo-suite-numerique.png' %}" width="250px" align="left" alt="{%trans 'La Suite Numérique' %}" />
<mj-image align="center" src="{{site.domain}}/assets/logo-suite-numerique.png" width="250px" align="left" alt="{%trans 'La Suite Numérique' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
@@ -30,7 +30,7 @@
<li>{% trans "Invite members of your community to your document in just a few clicks."%}</li>
</ul>
</mj-text>
<mj-button href="//{{site.domain}}/docs/{{document_id}}/" background-color="#000091" color="white" padding-bottom="30px">
<mj-button href="{{site.domain}}/docs/{{document_id}}/" background-color="#000091" color="white" padding-bottom="30px">
{% trans "Visit Docs"%}
</mj-button>
<mj-text>{% trans "We are confident that Docs will help you increase efficiency and productivity while strengthening the bond among members." %}</mj-text>
@@ -46,7 +46,5 @@
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.2.0",
"version": "1.3.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {