Compare commits

..

1 Commits

Author SHA1 Message Date
Sylvain Zimmer
ddac6197e3 (buildpack) add PaaS deployment support, tested with Scalingo 2025-06-01 23:54:35 +02:00
15 changed files with 414 additions and 2783 deletions

2
Procfile Normal file
View File

@@ -0,0 +1,2 @@
web: bin/buildpack_start.sh
postdeploy: python manage.py migrate

View File

@@ -57,7 +57,7 @@ Available methods: Helm chart, Nix package
In the works: Docker Compose, YunoHost
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
⚠️ For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under AGPL-3.0 and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/docs/env.md) for more information.
## Getting started 🔧

15
bin/buildpack_postcompile.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-compile script"
rm -rf docker docs env.d gitlint src/frontend/apps/e2e
rm -rf src/frontend/apps
rm -rf src/frontend/packages
# Remove some of the larger packages required by the frontend only
rm -rf src/frontend/node_modules/@next src/frontend/node_modules/next src/frontend/node_modules/react-icons src/frontend/node_modules/@gouvfr-lasuite
# du -ch | sort -rh | head -n 100

15
bin/buildpack_postfrontend.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
set -o errexit # always exit on error
set -o pipefail # don't ignore exit codes when piping output
echo "-----> Running post-frontend script"
# Move the frontend build to the nginx root and clean up
mkdir -p build/
mv src/frontend/apps/impress/out build/frontend-out
mv src/backend/* ./
mv src/nginx/* ./
echo "3.13" > .python-version

18
bin/buildpack_start.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# Start the Django backend server
gunicorn -b :8000 impress.wsgi:application --log-file - &
# Start the Y provider service
cd src/frontend/servers/y-provider && PORT=4444 ../../.scalingo/node/bin/node dist/start-server.js &
# Start the Nginx server
bin/run &
# if the current shell is killed, also terminate all its children
trap "pkill SIGTERM -P $$" SIGTERM
# wait for a single child to finish,
wait -n
# then kill all the other tasks
pkill -P $$

View File

@@ -16,6 +16,7 @@ from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import dj_database_url
import sentry_sdk
from configurations import Configuration, values
from sentry_sdk.integrations.django import DjangoIntegration
@@ -74,7 +75,9 @@ class Base(Configuration):
# Database
DATABASES = {
"default": {
"default": dj_database_url.config()
if os.environ.get("DATABASE_URL")
else {
"ENGINE": values.Value(
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",

View File

@@ -26,9 +26,10 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"beautifulsoup4==4.13.4",
"boto3==1.38.27",
"boto3==1.38.18",
"Brotli==1.1.0",
"celery[redis]==5.5.3",
"celery[redis]==5.5.2",
"dj-database-url==2.3.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
@@ -46,19 +47,19 @@ dependencies = [
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"jsonschema==4.23.0",
"lxml==5.4.0",
"markdown==3.8",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"openai==1.82.1",
"openai==1.79.0",
"psycopg[binary]==3.2.9",
"pycrdt==0.12.20",
"pycrdt==0.12.19",
"PyJWT==2.10.1",
"python-magic==0.4.27",
"redis<6.0.0",
"requests==2.32.3",
"sentry-sdk==2.29.1",
"sentry-sdk==2.28.0",
"whitenoise==6.9.0",
]
@@ -72,10 +73,10 @@ dependencies = [
dev = [
"django-extensions==4.1",
"django-test-migrations==1.5.0",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"drf-spectacular-sidecar==2025.5.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==9.3.0",
"ipython==9.2.0",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.7",
@@ -83,10 +84,10 @@ dev = [
"pytest-django==4.11.1",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.11.12",
"types-requests==2.32.0.20250602",
"ruff==0.11.10",
"types-requests==2.32.0.20250515",
]
[tool.setuptools]

View File

@@ -16,57 +16,56 @@
},
"dependencies": {
"@ag-media/react-pdf-table": "2.0.3",
"@blocknote/code-block": "0.31.1",
"@blocknote/core": "0.31.1",
"@blocknote/mantine": "0.31.1",
"@blocknote/react": "0.31.1",
"@blocknote/xl-docx-exporter": "0.31.1",
"@blocknote/xl-pdf-exporter": "0.31.1",
"@blocknote/code-block": "0.30.1",
"@blocknote/core": "0.30.1",
"@blocknote/mantine": "0.30.1",
"@blocknote/react": "0.30.1",
"@blocknote/xl-docx-exporter": "0.30.1",
"@blocknote/xl-pdf-exporter": "0.30.1",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.7.0",
"@gouvfr-lasuite/ui-kit": "0.6.0",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.1.0",
"@react-pdf/renderer": "4.3.0",
"@sentry/nextjs": "9.22.0",
"@tanstack/react-query": "5.77.1",
"@sentry/nextjs": "9.19.0",
"@tanstack/react-query": "5.76.1",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.5.0",
"emoji-mart": "5.6.0",
"i18next": "25.2.1",
"i18next": "25.1.3",
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.3",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.2",
"posthog-js": "1.246.0",
"posthog-js": "1.242.2",
"react": "*",
"react-aria-components": "1.9.0",
"react-aria-components": "1.8.0",
"react-dom": "*",
"react-i18next": "15.5.2",
"react-i18next": "15.5.1",
"react-intersection-observer": "9.16.0",
"react-select": "5.10.1",
"styled-components": "6.1.18",
"tldraw": "3.13.1",
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "5.0.5"
"zustand": "5.0.4"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.77.1",
"@tanstack/react-query-devtools": "5.76.1",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.17",
"@types/lodash": "4.17.16",
"@types/luxon": "3.6.2",
"@types/node": "*",
"@types/react": "*",
@@ -83,7 +82,7 @@
"stylelint-config-standard": "38.0.0",
"stylelint-prettier": "5.0.3",
"typescript": "*",
"webpack": "5.99.9",
"webpack": "5.99.8",
"workbox-webpack-plugin": "7.1.0"
}
}

View File

@@ -28,7 +28,6 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { CalloutBlock, DividerBlock } from './custom-blocks';
import { DrawBlock } from './custom-blocks/DrawBlock';
export const blockNoteSchema = withPageBreak(
BlockNoteSchema.create({
@@ -36,7 +35,6 @@ export const blockNoteSchema = withPageBreak(
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
draw: DrawBlock,
},
}),
);

View File

@@ -15,7 +15,6 @@ import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
} from './custom-blocks';
import { getDrawReactSlashMenuItems } from './custom-blocks/DrawBlock';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor<DocsBlockSchema>();
@@ -31,7 +30,6 @@ export const BlockNoteSuggestionMenu = () => {
getPageBreakReactSlashMenuItems(editor),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
getDrawReactSlashMenuItems(editor, t, basicBlocksName),
),
query,
),

View File

@@ -1,341 +0,0 @@
/* eslint-disable react-hooks/rules-of-hooks */
import {
ReactRendererProps,
defaultProps,
insertOrUpdateBlock,
} from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Editor,
TLEventMapHandler,
TLStore,
Tldraw,
getSnapshot,
loadSnapshot,
} from 'tldraw';
import 'tldraw/tldraw.css';
import { Box, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import _ from 'lodash';
import { clear } from 'console';
/**
* ----------------------------------------------------------------------------------
* Collaborative **Draw** block backed by a Yjs document synced through
* `@hocuspocus/provider` (see `useProviderStore`).
* ----------------------------------------------------------------------------------
*
* Each Draw block owns its own YDoc, identified by `roomId` (persisted in `propSchema`).
* The block serialises a base64encoded Yjs update (`drawingData`) so newcomers see
* the latest snapshot _immediately_, without waiting for the websocket connection.
*/
export const DrawBlock = createReactBlockSpec(
{
type: 'draw',
propSchema: {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
roomId: { default: `drawing-${Date.now()}` },
drawingData: { default: '' },
changeHistory: { default: '' },
lastChange: { default: '' },
increment: { default: 0 }, // Increment to force re-rendering
},
content: 'inline',
},
{
render: ({ block, editor: editorBN }) => {
const [editor, setEditor] = useState<Editor>();
const setAppToState = useCallback((editor: Editor) => {
setEditor(editor);
}, []);
const timeoutId = useRef<NodeJS.Timeout | null>(null);
const [storeEvents, setStoreEvents] = useState<string[]>([]);
console.log('Loading saved drawing data');
useEffect(() => {
if (block.props.drawingData && editor) {
console.log('DDData');
try {
const drawingData = JSON.parse(block.props.drawingData);
//const drawingData = block.props.drawingData;
// Update: Using the non-deprecated method to load the snapshot
// Instead of editor.store.loadSnapshot(drawingData)
loadSnapshot(editor.store, drawingData);
console.log('Successfully loaded drawing data');
} catch (error) {
console.error('Failed to load drawing data:', error);
}
}
}, [block.props.drawingData, editor]);
useEffect(() => {
if (!editor) {
return;
}
// Load saved drawing data if available
function logChangeEvent(eventInfo: string, changeData: any = null) {
console.log(eventInfo);
// Get current properties
// const currentProps = { ...block.props };
// // Get current changeHistory or initialize empty array
// const currentHistory = Array.isArray(currentProps.changeHistory)
// ? currentProps.changeHistory
// : [];
// // Create a change record with timestamp
// const changeRecord = {
// timestamp: new Date().toISOString(),
// event: eventInfo,
// data: changeData,
// };
// // Create new props object with all updated values
// const updatedProps = {
// ...currentProps,
// changeHistory: [...currentHistory, changeRecord],
// lastChange: changeRecord,
// };
// // Add drawingData if available
// if (changeData?.drawingData) {
// updatedProps.drawingData = changeData.drawingData;
// }
// Update the block with the new props
// console.log('Updating block with props:', updatedProps);
// //editorBN.updateBlock(block, { props: updatedProps });
// if (timeoutId.current) {
// clearTimeout(timeoutId.current);
// }
// timeoutId.current = setTimeout(() => {
// editorBN.updateBlock(block, {
// props: updatedProps,
// });
// }, 300);
if (!editor) {
return;
}
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
timeoutId.current = setTimeout(() => {
const snapshot = getSnapshot(editor.store);
//const snapshot = JSON.stringify(editor.store.serialize());
console.log('Captured drawing snapshot:', snapshot);
// Only update drawingData property to avoid multiple updates
const currentProps = { ...block.props };
editorBN.updateBlock(block, {
props: {
drawingData: JSON.stringify(snapshot),
increment: currentProps.increment + 1, // Increment to force re-rendering
},
});
}, 300);
//setStoreEvents((events) => [...events, eventInfo]);
}
//[1]
const handleChangeEvent: TLEventMapHandler<'change'> = (change) => {
// Added
for (const record of Object.values(change.changes.added)) {
if (record.typeName === 'shape') {
logChangeEvent(`created shape (${record.type})`, {
action: 'created',
shapeType: record.type,
shape: record,
});
}
}
// Updated
for (const [from, to] of Object.values(change.changes.updated)) {
if (
from.typeName === 'instance' &&
to.typeName === 'instance' &&
from.currentPageId !== to.currentPageId
) {
logChangeEvent(
`changed page (${from.currentPageId}, ${to.currentPageId})`,
{
action: 'changedPage',
fromPageId: from.currentPageId,
toPageId: to.currentPageId,
},
);
} else if (
from.id.startsWith('shape') &&
to.id.startsWith('shape')
) {
let diff = _.reduce(
from,
(result: any[], value, key: string) =>
_.isEqual(value, to[key])
? result
: result.concat([key, to[key]]),
[],
);
const diffObj = {};
if (diff?.[0] === 'props') {
diff = _.reduce(
from.props,
(result: any[], value, key) =>
_.isEqual(value, to.props[key])
? result
: result.concat([key, to.props[key]]),
[],
);
// Convert diff array to object for better storage
for (let i = 0; i < diff.length; i += 2) {
diffObj[diff[i]] = diff[i + 1];
}
}
logChangeEvent(`updated shape (${JSON.stringify(diff)})`, {
action: 'updated',
shapeId: from.id,
changes: diffObj,
from: from,
to: to,
});
}
}
// Removed
for (const record of Object.values(change.changes.removed)) {
if (record.typeName === 'shape') {
logChangeEvent(`deleted shape (${record.type})`, {
action: 'deleted',
shapeType: record.type,
shape: record,
});
}
}
// Store the entire drawing state periodically when changes occur
// if (
// Object.keys(change.changes.added).length > 0 ||
// Object.keys(change.changes.updated).length > 0 ||
// Object.keys(change.changes.removed).length > 0
// ) {
// // Capture the current drawing state if available
// if (editor.store) {
// try {
// // Update: Using the non-deprecated method to get the snapshot
// // Instead of
// //
// const snapshot = getSnapshot(editor.store);
// //const snapshot = JSON.stringify(editor.store.serialize());
// console.log('Captured drawing snapshot:', snapshot);
// // Only update drawingData property to avoid multiple updates
// const currentProps = { ...block.props };
// if (timeoutId.current) {
// clearTimeout(timeoutId.current);
// }
// timeoutId.current = setTimeout(() => {
// editorBN.updateBlock(block, {
// props: {
// ...currentProps,
// drawingData: snapshot,
// },
// });
// }, 300);
// console.log('Drawing snapshot updated');
// } catch (error) {
// console.error('Failed to capture drawing snapshot:', error);
// }
// }
// }
};
// [2]
const cleanupFunction = editor.store.listen(handleChangeEvent, {
source: 'user',
scope: 'all',
});
return () => {
cleanupFunction();
};
}, [block, editor, editorBN]);
return (
<Box style={{ width: '100%', height: 300 }}>
{/*
* We deliberately pass the TLstore directly. TLDraw will observe the
* changes including those coming over the wire and rerender.
*/}
<Tldraw onMount={setAppToState} />
</Box>
);
},
},
);
/**
* Slashmenu helper → inserts a new collaborative draw block with a unique `roomId`.
*/
export const getDrawReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Draw'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'draw',
props: {
roomId: `drawing-${Date.now()}`,
drawingData: null,
changeHistory: [],
lastChange: null,
},
});
},
aliases: ['draw'],
group,
icon: <Icon iconName="draw" $size="18px" />,
subtext: t('Add a collaborative canvas'),
},
];
/**
* Formattingtoolbar item so users can transform an existing block into a Draw block.
*/
export const getDrawFormattingToolbarItems = (
t: TFunction<'translation', undefined>,
): BlockTypeSelectItem => ({
name: t('Draw'),
type: 'draw',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'draw',
});

View File

@@ -19,6 +19,7 @@
"app:build": "yarn APP_IMPRESS run build",
"app:test": "yarn APP_IMPRESS run test",
"ci:build": "yarn APP_IMPRESS run build:ci",
"build": "yarn APP_IMPRESS run build && yarn COLLABORATION_SERVER run build",
"e2e:test": "yarn APP_E2E run test",
"lint": "yarn APP_IMPRESS run lint && yarn APP_E2E run lint && yarn workspace eslint-config-impress run lint && yarn I18N run lint && yarn COLLABORATION_SERVER run lint",
"i18n:extract": "yarn I18N run extract-translation",
@@ -28,8 +29,8 @@
"server:test": "yarn COLLABORATION_SERVER run test"
},
"resolutions": {
"@types/node": "22.15.21",
"@types/react": "19.1.5",
"@types/node": "22.15.19",
"@types/react": "19.1.4",
"@types/react-dom": "19.1.5",
"@typescript-eslint/eslint-plugin": "8.32.1",
"@typescript-eslint/parser": "8.32.1",

View File

@@ -16,10 +16,10 @@
"node": ">=18"
},
"dependencies": {
"@blocknote/server-util": "0.31.1",
"@blocknote/server-util": "0.30.1",
"@hocuspocus/server": "2.15.2",
"@sentry/node": "9.22.0",
"@sentry/profiling-node": "9.22.0",
"@sentry/node": "9.19.0",
"@sentry/profiling-node": "9.19.0",
"axios": "1.9.0",
"cors": "2.8.5",
"express": "5.1.0",

File diff suppressed because it is too large Load Diff

114
src/nginx/servers.conf.erb Normal file
View File

@@ -0,0 +1,114 @@
# ERB templated nginx configuration
# see https://doc.scalingo.com/platform/deployment/buildpacks/nginx
upstream backend_server {
server localhost:8000 fail_timeout=0;
}
upstream collaboration_server {
server localhost:4444 fail_timeout=0;
}
server {
listen <%= ENV["PORT"] %>;
server_name _;
root /app/build/frontend-out;
error_page 404 /404.html;
location /collaboration/api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://collaboration_server;
}
location /collaboration/ws/ {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Set appropriate timeout for WebSocketAdd commentMore actions
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://collaboration_server;
}
# Django rest framework
location ^~ /api/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Django admin
location ^~ /admin/ {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://backend_server;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-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 Object Storage
proxy_pass <%= ENV["AWS_S3_ENDPOINT_URL"] %>/<%= ENV["AWS_STORAGE_BUCKET_NAME"] %>/;
proxy_set_header Host <%= ENV["AWS_S3_ENDPOINT_URL"].split("://")[1] %>;
add_header Content-Security-Policy "default-src 'none'" always;
}
location /media-auth {
proxy_pass http://backend_server/api/v1.0/documents/media-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 / {
try_files $uri index.html $uri/ =404;
}
location ~ "^/docs/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/?$" {
try_files $uri /docs/[id]/index.html;
}
location = /404.html {
internal;
}
}