Compare commits

..

23 Commits

Author SHA1 Message Date
Raito Bezarius 93c41f4c12 feat(read-docs): decrypt them
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 17:23:26 +02:00
Raito Bezarius a9d7d53d90 feat(write-docs): write them encrypted
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 17:23:19 +02:00
Raito Bezarius aebbc46e79 feat(doc-mgmt): create a key per document for E2EE
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 17:23:12 +02:00
Raito Bezarius 3770a7f988 feat(provider): add E2EESDKClientProvider wrapping the app
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 16:42:58 +02:00
Julien Bouquillon 54d063ab2c fix-ts 2024-09-12 16:42:58 +02:00
Julien Bouquillon 70e4518d76 wip 2024-09-12 16:42:58 +02:00
Julien Bouquillon ca54fa11f0 wip 2024-09-12 16:42:58 +02:00
Raito Bezarius 38732db8a4 wip! wip! wip! auth for E2EE
Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 16:42:58 +02:00
Raito Bezarius c7439be1a5 feat(api,front): expose the sub OIDC field for the frontend
Useful for E2EE encryption identification.

Signed-off-by: Raito Bezarius <masterancpp@gmail.com>
2024-09-12 16:42:58 +02:00
Anthony LC 152e7b1426 ♻️(frontend) save editor as json
Stop to save a y.js doc to save the json editor.
2024-09-12 16:42:58 +02:00
Samuel Paccoud - DINUM 49c89da2da add flag for e2e encoded documents 2024-09-12 16:20:17 +02:00
Anthony LC 97fa5b8532 🧑‍💻(makefile) add build frontend dev
docker build frontend dev was lacking.
If dependencies were updated, the change were
not reflected in the frontend container.
2024-09-12 08:06:17 +02:00
Anthony LC a092c2915b ♻️(frontend) adapt doc visibility to new api
We updated the way we handle the visibility of a doc
in the backend. Now we use a new api to update
the visibility (documents/{id}/link-configuration/)
of a doc. We adapted the frontend to use this new api.
We changed the types to reflect the new api and
to keep the same logic.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM 9b44e021fd ♻️(models) allow null titles on documents
We want to make it as fast as possible to create a new document.
We should not have any modal asking the title before creating the
document but rather show an "untitle document" title and let the
owner set it on the already created document.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM 2c3eef4dd9 (api) allow forcing ID when creating a document via API endpoint
We need to be able to force the ID when creating a document via
the API endpoint. This is usefull for documents that are created
offline as synchronization is achieved by replaying stacked requests.

We do it via the serializer, making sure that we don't override an
existing document.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM dec1a1a870 🔥(api) remove possibility to force document id on creation
This feature poses security issues in the way it is implemented.
We decide to remove it while clarifying the use case.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM 1e432cfdc2 (api) allow updating link configuration for a document
We open a specific endpoint to update documents link configuration
because it makes it more secure and simple to limit access rights
to administrators/owners whereas other document fields like title
and content can be edited by anonymous or authenticated users with
much less access rights.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM f5c4106547 🐛(api) fix randomly failing test on document list ordering via API
The test was randomly failing because postgresql and python sorting
was not 100% consistent e.g "treatment" vs "treat them" were not
ordered the same.

Comparing each field value insteat of relying on "sort" solves the
issue and makes the test simpler.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM 494638d306 (models/api) add link access reach and role
Link access was either public or private and was only allowing readers.

This commit makes link access more powerful:
- link reach can be private (users need to obtain specific access by
  document's administrators), restricted (any authenticated user) or
  public (anybody including anonymous users)
- link role can be reader or editor.

It is thus now possible to give editor access to an anonymous user or
any authenticated user.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM 41260de1c3 🔥(compose) remove docker compose version
The version is now automatically guessed by Docker Compose. This
commit will fix the warning raised about the uselessness of this
setting.
2024-09-11 22:31:30 +02:00
Anthony LC 140a630a6e 🛂(backend) stop to list public doc to everyone
Everybody could see the full list of public docs.
Now only members can see their public docs.
They can still access to any specific public doc.
2024-09-11 22:31:30 +02:00
Anthony LC b716881d50 📌(frontend) add lacking peerdependencies
- Add some lacking peerdependencies to the packages.
- Resolve conflicts eslint dependencies.
2024-09-11 11:39:03 +02:00
renovate[bot] fa8466d44d ⬆️(dependencies) update js dependencies 2024-09-11 11:39:03 +02:00
26 changed files with 13585 additions and 12118 deletions
+139 -139
View File
@@ -9,52 +9,52 @@ on:
- "*"
jobs:
lint-git:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' # Makes sense only for pull requests
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: show
run: git log
- name: Enforce absence of print statements in code
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
- name: Check absence of fixup commits
run: |
! git log | grep 'fixup!'
- name: Install gitlint
run: pip install --user requests gitlint
- name: Lint commit messages added to main
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
# lint-git:
# runs-on: ubuntu-latest
# if: github.event_name == 'pull_request' # Makes sense only for pull requests
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# with:
# fetch-depth: 0
# - name: show
# run: git log
# - name: Enforce absence of print statements in code
# run: |
# ! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/impress.yml' | grep "print("
# - name: Check absence of fixup commits
# run: |
# ! git log | grep 'fixup!'
# - name: Install gitlint
# run: pip install --user requests gitlint
# - name: Lint commit messages added to main
# run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
runs-on: ubuntu-latest
if: |
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
github.event_name == 'pull_request'
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 50
- name: Check that the CHANGELOG has been modified in the current branch
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
# check-changelog:
# runs-on: ubuntu-latest
# if: |
# contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
# github.event_name == 'pull_request'
# steps:
# - name: Checkout repository
# uses: actions/checkout@v3
# with:
# fetch-depth: 50
# - name: Check that the CHANGELOG has been modified in the current branch
# run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
lint-changelog:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Check CHANGELOG max line length
run: |
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
if [ $max_line_length -ge 80 ]; then
echo "ERROR: CHANGELOG has lines longer than 80 characters."
exit 1
fi
# lint-changelog:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# - name: Check CHANGELOG max line length
# run: |
# max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
# if [ $max_line_length -ge 80 ]; then
# echo "ERROR: CHANGELOG has lines longer than 80 characters."
# exit 1
# fi
build-mails:
runs-on: ubuntu-latest
@@ -96,112 +96,112 @@ jobs:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
lint-back:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/backend
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint .
# lint-back:
# runs-on: ubuntu-latest
# defaults:
# run:
# working-directory: src/backend
# steps:
# - name: Checkout repository
# uses: actions/checkout@v2
# - name: Install Python
# uses: actions/setup-python@v3
# with:
# python-version: "3.10"
# - name: Install development dependencies
# run: pip install --user .[dev]
# - name: Check code formatting with ruff
# run: ~/.local/bin/ruff format . --diff
# - name: Lint code with ruff
# run: ~/.local/bin/ruff check .
# - name: Lint code with pylint
# run: ~/.local/bin/pylint .
test-back:
runs-on: ubuntu-latest
needs: build-mails
# test-back:
# runs-on: ubuntu-latest
# needs: build-mails
defaults:
run:
working-directory: src/backend
# defaults:
# run:
# working-directory: src/backend
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
# services:
# postgres:
# image: postgres:16
# env:
# POSTGRES_DB: impress
# POSTGRES_USER: dinum
# POSTGRES_PASSWORD: pass
# ports:
# - 5432:5432
# # needed because the postgres container does not provide a healthcheck
# options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
env:
DJANGO_CONFIGURATION: Test
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
DB_HOST: localhost
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: impress
AWS_S3_SECRET_ACCESS_KEY: password
# env:
# DJANGO_CONFIGURATION: Test
# DJANGO_SETTINGS_MODULE: impress.settings
# DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
# OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
# DB_HOST: localhost
# DB_NAME: impress
# DB_USER: dinum
# DB_PASSWORD: pass
# DB_PORT: 5432
# STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
# AWS_S3_ENDPOINT_URL: http://localhost:9000
# AWS_S3_ACCESS_KEY_ID: impress
# AWS_S3_SECRET_ACCESS_KEY: password
steps:
- name: Checkout repository
uses: actions/checkout@v4
# steps:
# - name: Checkout repository
# uses: actions/checkout@v4
- name: Create writable /data
run: |
sudo mkdir -p /data/media && \
sudo mkdir -p /data/static
# - name: Create writable /data
# run: |
# sudo mkdir -p /data/media && \
# sudo mkdir -p /data/static
- name: Restore the mail templates
uses: actions/cache@v4
id: mail-templates
with:
path: "src/backend/core/templates/mail"
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
# - name: Restore the mail templates
# uses: actions/cache@v4
# id: mail-templates
# with:
# path: "src/backend/core/templates/mail"
# key: mail-templates-${{ hashFiles('src/mail/mjml') }}
- name: Start Minio
run: |
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=impress" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
minio/minio server --console-address :9001 /data
# - name: Start Minio
# run: |
# docker pull minio/minio
# docker run -d --name minio \
# -p 9000:9000 \
# -e "MINIO_ACCESS_KEY=impress" \
# -e "MINIO_SECRET_KEY=password" \
# -v /data/media:/data \
# minio/minio server --console-address :9001 /data
- name: Configure MinIO
run: |
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
docker exec ${MINIO} sh -c \
"mc alias set impress http://localhost:9000 impress password && \
mc alias ls && \
mc mb impress/impress-media-storage && \
mc version enable impress/impress-media-storage"
# - name: Configure MinIO
# run: |
# MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
# docker exec ${MINIO} sh -c \
# "mc alias set impress http://localhost:9000 impress password && \
# mc alias ls && \
# mc mb impress/impress-media-storage && \
# mc version enable impress/impress-media-storage"
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.10"
# - name: Install Python
# uses: actions/setup-python@v3
# with:
# python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
# - name: Install development dependencies
# run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext pandoc
# - name: Install gettext (required to compile messages)
# run: |
# sudo apt-get update
# sudo apt-get install -y gettext pandoc
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
# - name: Generate a MO file from strings extracted from the project
# run: python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
# - name: Run tests
# run: ~/.local/bin/pytest -n 2
+9 -2
View File
@@ -11,13 +11,21 @@ and this project adheres to
## Added
- ✨Add link public/authenticated/restricted access with read/editor roles #234
- ✨(frontend) add copy link button #235
- 🛂(frontend) access public docs without being logged #235
## Changed
- ♻️ Allow null titles on documents for easier creation #234
- 🛂(backend) stop to list public doc to everyone #234
- 🚚(frontend) change visibility in share modal #235
## Fixed
- 🐛 Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
@@ -32,7 +40,6 @@ and this project adheres to
- 💄(frontend) code background darkened on editor #214
- 🔥(frontend) hide markdown button if not text #213
- 🛂(backend) stop to list public doc to everyone #234
## Fixed
@@ -148,4 +155,4 @@ and this project adheres to
[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
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
+1
View File
@@ -92,6 +92,7 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
+3 -2
View File
@@ -16,8 +16,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ["id", "email"]
read_only_fields = ["id", "email"]
fields = ["id", "sub", "email"]
read_only_fields = ["id", "sub", "email"]
class BaseAccessSerializer(serializers.ModelSerializer):
@@ -148,6 +148,7 @@ class DocumentSerializer(BaseResourceSerializer):
"title",
"accesses",
"abilities",
"is_e2ee",
"link_role",
"link_reach",
"created_at",
+4 -4
View File
@@ -7,8 +7,8 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError
from django.db.models import (
OuterRef,
Q,
@@ -358,11 +358,11 @@ class DocumentViewSet(
try:
# Add a trace that the user visited the document (this is needed to include
# the document in the user's list view)
models.LinkTrace.objects.update_or_create(
models.LinkTrace.objects.create(
document=instance,
user=self.request.user,
)
except IntegrityError:
except ValidationError:
# The trace already exists, so we just pass without doing anything
pass
@@ -375,7 +375,7 @@ class DocumentViewSet(
to the document
"""
if not request.user.is_authenticated:
return drf_response.Response([])
raise exceptions.PermissionDenied("Authentication required.")
document = self.get_object()
user = request.user
@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
),
migrations.AddField(
model_name='document',
@@ -0,0 +1,18 @@
# Generated by Django 5.1 on 2024-09-12 13:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_remove_document_is_public_alter_document_link_reach_and_more'),
]
operations = [
migrations.AddField(
model_name='document',
name='is_e2ee',
field=models.BooleanField(default=False),
),
]
+2 -1
View File
@@ -324,11 +324,12 @@ class Document(BaseModel):
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.RESTRICTED,
default=LinkReachChoices.AUTHENTICATED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER
)
is_e2ee = models.BooleanField(default=False)
_content = None
@@ -29,8 +29,8 @@ def test_api_document_versions_list_anonymous(role, reach):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 403
assert response.json() == {"detail": "Authentication required."}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)
@@ -5,7 +5,7 @@ Tests for Documents API endpoint in impress's core app: retrieve
import pytest
from rest_framework.test import APIClient
from core import factories
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
@@ -33,6 +33,7 @@ def test_api_documents_retrieve_anonymous_public():
"versions_retrieve": False,
},
"accesses": [],
"is_e2ee": False,
"link_reach": "public",
"link_role": document.link_role,
"title": document.title,
@@ -87,6 +88,7 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"versions_retrieve": False,
},
"accesses": [],
"is_e2ee": False,
"link_reach": reach,
"link_role": document.link_role,
"title": document.title,
@@ -94,6 +96,38 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_trace_twice(reach):
"""
Accessing a document several times should not raise any error even though the
trace already exists for this document and user.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
# A second visit should not raise any error
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
def test_api_documents_retrieve_authenticated_unrelated_restricted():
@@ -162,6 +196,7 @@ def test_api_documents_retrieve_authenticated_related_direct():
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"link_reach": document.link_reach,
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -300,6 +335,7 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -420,6 +456,7 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
@@ -544,6 +581,7 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"title": document.title,
"content": document.content,
"abilities": document.get_abilities(user),
"is_e2ee": False,
"link_reach": "restricted",
"link_role": document.link_role,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
+1 -1
View File
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.46.1",
"@playwright/test": "1.47.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
+1 -1
View File
@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.
+9 -4
View File
@@ -21,24 +21,28 @@
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.5",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.53.2",
"i18next": "23.14.0",
"@socialgouv/e2esdk-client": "1.0.0-beta.28",
"@socialgouv/e2esdk-devtools": "1.0.0-beta.38",
"@socialgouv/e2esdk-react": "1.0.0-beta.28",
"@tanstack/react-query": "5.55.4",
"i18next": "23.15.1",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.7",
"next": "14.2.9",
"react": "*",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-i18next": "15.0.1",
"react-select": "5.8.0",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "4.5.5"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.53.2",
"@tanstack/react-query-devtools": "5.55.4",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
@@ -61,6 +65,7 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.94.0",
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -1,5 +1,7 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { E2ESDKClientProvider } from '@socialgouv/e2esdk-react';
import { e2esdkClient } from './auth/useAuthStore';
import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
@@ -27,7 +29,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
<E2ESDKClientProvider client={e2esdkClient}>
<Auth>{children}</Auth>
</E2ESDKClientProvider>
</CunninghamProvider>
</QueryClientProvider>
);
@@ -2,10 +2,12 @@
* Represents user retrieved from the API.
* @interface User
* @property {string} id - The id of the user.
* @property {string} sub - The `sub` field of OIDC
* @property {string} email - The email of the user.
* @property {string} name - The name of the user.
*/
export interface User {
id: string;
sub: string;
email: string;
}
@@ -5,18 +5,31 @@ import { baseApiUrl } from '@/core/conf';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
import { Client, PublicUserIdentity } from '@socialgouv/e2esdk-client';
import { identity } from 'lodash';
export const e2esdkClient = new Client({
// Point it to where your server is listening
serverURL: 'https://app-a5a1b445-32e0-4cf4-a478-821a48f86ccf.cleverapps.io',
// Pass the signature public key you configured for the server
serverSignaturePublicKey: 'ayfva9SUh0mfgmifUtxcdLp4HriHJiqefEKnvYgY4qM',
});
interface AuthStore {
initiated: boolean;
authenticated: boolean;
readyForEncryption: boolean;
initAuth: () => void;
logout: () => void;
login: () => void;
endToEndData?: PublicUserIdentity;
userData?: User;
}
const initialState = {
initiated: false,
authenticated: false,
readyForEncryption: false,
userData: undefined,
};
@@ -24,22 +37,51 @@ export const useAuthStore = create<AuthStore>((set) => ({
initiated: initialState.initiated,
authenticated: initialState.authenticated,
userData: initialState.userData,
readyForEncryption: initialState.readyForEncryption,
initAuth: () => {
getMe()
.then((data: User) => {
// If a path is stored in the local storage, we redirect to it
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
window.location.replace(path_auth);
return;
}
.then(
(data: User) => {
// If a path is stored in the local storage, we redirect to it
const path_auth = localStorage.getItem(PATH_AUTH_LOCAL_STORAGE);
if (path_auth) {
localStorage.removeItem(PATH_AUTH_LOCAL_STORAGE);
window.location.replace(path_auth);
return;
}
set({ authenticated: true, userData: data });
set({ authenticated: true, userData: data });
return e2esdkClient
.signup(data.sub)
.then(() => data)
.catch(() => data);
},
() => {},
)
.then(
(data) => {
set({ readyForEncryption: true });
if (data) {
return e2esdkClient.login(data.sub);
}
},
(e) => {
throw e;
//if (data) {
// return e2esdkClient.login(data.sub);
//}
//fail
},
)
.then((publicIdentity: PublicUserIdentity | null | undefined) => {
if (!publicIdentity) throw Error('exploding');
console.log('publicIdentity', publicIdentity);
set({ endToEndData: publicIdentity });
})
.catch(() => {})
.finally(() => {
console.log('finally');
set({ initiated: true });
});
},
@@ -1,4 +1,4 @@
import { BlockNoteEditor as BlockNoteEditorCore } from '@blocknote/core';
import { Block, BlockNoteEditor as BlockNoteEditorCore, PartialBlock } from '@blocknote/core';
import '@blocknote/core/fonts/inter.css';
import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
@@ -17,6 +17,7 @@ import { useDocStore } from '../stores';
import { randomColor } from '../utils';
import { BlockNoteToolbar } from './BlockNoteToolbar';
import { useE2ESDKClient } from '@socialgouv/e2esdk-react';
const cssEditor = `
&, & > .bn-container, & .ProseMirror {
@@ -71,7 +72,8 @@ export const BlockNoteContent = ({
const { userData } = useAuthStore();
const { setStore, docsStore } = useDocStore();
const canSave = doc.abilities.partial_update && !isVersion;
useSaveDoc(doc.id, provider.document, canSave);
const e2eClient = useE2ESDKClient();
const storedEditor = docsStore?.[storeId]?.editor;
const {
mutateAsync: createDocAttachment,
@@ -99,18 +101,46 @@ export const BlockNoteContent = ({
return storedEditor;
}
// TODO decrypt doc.content
//localStorage.getItem('KEY');
const docId = doc.id;
const purpose = `docs:${docId}`;
const key = e2eClient.findKeyByPurpose(purpose);
console.log('purpose', purpose, 'key', key);
let plaintextContent: Array<PartialBlock> | undefined;
if (!key) {
alert('probleme de key');
return;
} else {
if (doc.content) {
plaintextContent = JSON.parse(e2eClient.decrypt(
doc.content,
key.keychainFingerprint,
) as string) as Array<PartialBlock>;
console.log('decryptedMessage', plaintextContent);
} else {
plaintextContent = undefined;
}
}
return BlockNoteEditorCore.create({
collaboration: {
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.email || 'Anonymous',
color: randomColor(),
},
},
// collaboration: {
// provider,
// fragment: provider.document.getXmlFragment('document-store'),
// user: {
// name: userData?.email || 'Anonymous',
// color: randomColor(),
// },
// },
uploadFile,
initialContent: plaintextContent,
});
}, [provider, storedEditor, uploadFile, userData?.email]);
}, [doc.content, storedEditor, uploadFile]);
console.log("useSaveDoc", doc.id, provider.document, canSave, editor);
useSaveDoc(doc.id, provider.document, canSave, editor);
useEffect(() => {
setStore(storeId, { editor });
@@ -1,3 +1,4 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import * as Y from 'yjs';
@@ -6,17 +7,24 @@ import { useUpdateDoc } from '@/features/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
import { toBase64 } from '../utils';
import { useE2ESDKClient } from '@socialgouv/e2esdk-react';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const useSaveDoc = (
docId: string,
doc: Y.Doc,
canSave: boolean,
editor: BlockNoteEditor,
) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
});
const e2eClient = useE2ESDKClient();
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),
JSON.stringify(editor.document)
);
useEffect(() => {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(doc)));
setInitialDoc(JSON.stringify(editor.document));
}, [doc]);
/**
@@ -32,7 +40,7 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
transaction: Y.Transaction,
) => {
if (!transaction.local) {
setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc)));
setInitialDoc(JSON.stringify(editor.document));
}
};
@@ -47,23 +55,41 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
* Check if the doc has been updated and can be saved.
*/
const hasChanged = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
return initialDoc !== newDoc;
return initialDoc !== JSON.stringify(editor.document);
}, [doc, initialDoc]);
const shouldSave = useCallback(() => {
console.log('hasChanged', hasChanged(), 'canSave', canSave);
return hasChanged() && canSave;
}, [canSave, hasChanged]);
const saveDoc = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
const newDoc = JSON.stringify(editor.document);
//const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
// TODO encode the content
const purpose = `docs:${docId}`;
const key = e2eClient.findKeyByPurpose(purpose);
console.log("purpose", purpose, "key", key);
if (!key) {
alert('probleme de key');
return;
}
const encrypted = e2eClient.encrypt(newDoc, key.keychainFingerprint);
console.log('encrypted', encrypted);
// todo
setInitialDoc(newDoc);
updateDoc({
id: docId,
content: newDoc,
content: encrypted,
});
}, [doc, docId, updateDoc]);
}, [docId, editor?.document, updateDoc]);
const timeout = useRef<NodeJS.Timeout>();
const router = useRouter();
@@ -74,10 +100,12 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
}
const onSave = (e?: Event) => {
console.log('entered onSave');
if (!shouldSave()) {
return;
}
console.log('will save');
saveDoc();
/**
@@ -96,7 +124,7 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
};
// Save every minute
timeout.current = setInterval(onSave, 60000);
timeout.current = setInterval(onSave, 1000);
// Save when the user leaves the page
addEventListener('beforeunload', onSave);
// Save when the user navigates to another page
@@ -26,9 +26,9 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
guid: storeId,
});
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
// if (initialDoc) {
// Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
// }
const provider = new HocuspocusProvider({
url: providerUrl(storeId),
@@ -1,4 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { e2esdkClient } from '@/core/auth/useAuthStore';
import { APIError, errorCauses, fetchAPI } from '@/api';
@@ -13,6 +14,7 @@ export const createDoc = async ({ title }: CreateDocParam): Promise<Doc> => {
method: 'POST',
body: JSON.stringify({
title,
is_e2ee: true
}),
});
@@ -20,7 +22,16 @@ export const createDoc = async ({ title }: CreateDocParam): Promise<Doc> => {
throw new APIError('Failed to create the doc', await errorCauses(response));
}
return response.json() as Promise<Doc>;
const resp = await (response.json() as Promise<Doc>);
const { keychainFingerprint } = await e2esdkClient.createNewKeychain(
`docs:${resp.id}`,
'secretBox'
);
console.log('new e2ee keychain registered', keychainFingerprint);
return resp;
};
interface CreateDocProps {
@@ -37,6 +37,7 @@ export interface Doc {
accesses: Access[];
created_at: string;
updated_at: string;
is_e2ee: boolean;
abilities: {
destroy: boolean;
link_configuration: boolean;
+9 -6
View File
@@ -25,15 +25,18 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@blocknote/core": "0.15.6",
"@blocknote/mantine": "0.15.6",
"@blocknote/react": "0.15.6",
"@types/node": "20.16.3",
"@blocknote/core": "0.15.7",
"@blocknote/mantine": "0.15.7",
"@blocknote/react": "0.15.7",
"@types/node": "20.16.5",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.5.0",
"@typescript-eslint/parser": "8.5.0",
"cross-env": "7.0.3",
"eslint": "8.57.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"typescript": "5.5.4",
"yjs": "13.6.18"
"typescript": "5.6.2",
"yjs": "13.6.19"
}
}
@@ -6,19 +6,20 @@
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "14.2.7",
"@next/eslint-plugin-next": "14.2.9",
"@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.7",
"@typescript-eslint/eslint-plugin": "*",
"@typescript-eslint/parser": "*",
"eslint": "*",
"eslint-config-next": "14.2.9",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "28.8.2",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-testing-library": "6.3.0"
"eslint-plugin-react": "7.35.2",
"eslint-plugin-testing-library": "6.3.0",
"prettier": "3.3.3"
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
"@types/jest": "29.5.12",
"@types/node": "*",
"eslint-config-impress": "*",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-import": "2.30.0",
"i18next-parser": "9.0.2",
"jest": "29.7.0",
"ts-jest": "29.2.5",
+2 -1
View File
@@ -15,7 +15,8 @@
"node": ">=18"
},
"dependencies": {
"@hocuspocus/server": "2.13.5"
"@hocuspocus/server": "2.13.5",
"y-protocols": "1.0.6"
},
"devDependencies": {
"@types/node": "*",
+13180 -11907
View File
File diff suppressed because it is too large Load Diff