[#65875] Enable collaborative editing.

This commit is contained in:
Pavel Balashou
2025-07-28 09:57:56 +02:00
parent 82b9d10690
commit dc63efdffd
21 changed files with 2147 additions and 1653 deletions
@@ -29,6 +29,12 @@
#++
module DynamicContentSecurityPolicy
extend ActiveSupport::Concern
included do
before_action :add_hocuspocus_host_to_csp
end
##
# Dynamically append sources to CSP directives
# This replaces the secure_headers named append functionality
@@ -47,4 +53,16 @@ module DynamicContentSecurityPolicy
request.content_security_policy = policy
end
end
private
def add_hocuspocus_host_to_csp
hocuspocus_url = Setting.collaborative_editing_hocuspocus_url
if hocuspocus_url.present?
uri = URI.parse(hocuspocus_url)
base_url = "#{uri.scheme}://#{uri.host}"
append_content_security_policy_directives(connect_src: [base_url])
end
end
end
+8
View File
@@ -80,6 +80,14 @@ module SettingsHelper
end
end
def setting_url_field(setting, options = {})
setting_field_wrapper(setting, options) do
styled_url_field_tag("settings[#{setting}]",
Setting.send(setting),
disabled_setting_option(setting).merge(options))
end
end
def setting_number_field(setting, options = {})
setting_field_wrapper(setting, options) do
styled_number_field_tag("settings[#{setting}]",
+57
View File
@@ -0,0 +1,57 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
module CollaborativeEditing
module DocumentIdGenerator
module_function
def call(category, id)
OpenSSL::HMAC.hexdigest("SHA256", Rails.application.secret_key_base, "#{category}#{id}")
end
end
module DocumentAccessTokenGenerator
module_function
def call(document_id, document_text)
if Setting.collaborative_editing_hocuspocus_secret.present?
JWT.encode(
{
document_id:,
document_text:,
exp: 20.minutes.from_now.to_i
},
Setting.collaborative_editing_hocuspocus_secret,
"HS256"
)
end
end
end
end
@@ -59,6 +59,15 @@ See COPYRIGHT and LICENSE files for more details.
<%= t(:label_example) %>: <%= @guessed_host %>
</span>
</div>
<div class="form--field">
<%= setting_url_field :collaborative_editing_hocuspocus_url, size: 60, container_class: "-middle" %>
<span class="form--field-instructions">
<%= t(:label_example) %>: wss://websocket.server/path
</span>
</div>
<div class="form--field">
<%= setting_text_field :collaborative_editing_hocuspocus_secret, size: 60, container_class: "-wide" %>
</div>
<div class="form--field"><%= setting_check_box :cache_formatted_text %></div>
<div class="form--field">
<%= setting_text_area :allowed_link_protocols, rows: 5, container_class: "-wide" %>
+18
View File
@@ -1,3 +1,5 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -564,6 +566,22 @@ module Settings
description: "Additional allowed host names for the application.",
default: []
},
collaborative_editing_hocuspocus_url: {
format: :string,
default: nil,
description: "The URL of the hocuspocus server used by BlockNoteJS editor to enable collaborative editing.",
default_by_env: {
development: "wss://hocuspocus.local"
}
},
collaborative_editing_hocuspocus_secret: {
format: :string,
default: nil,
default_by_env: {
development: "secret12345"
},
description: "The secret used for generating access tokens to access documents on hocuspocus server."
},
hours_per_day: {
description: "This will define what is considered a “day” when displaying duration in a more natural way " \
"(for example, if a day is 8 hours, 32 hours would be 4 days).",
+2
View File
@@ -4131,6 +4131,8 @@ en:
setting_feeds_limit: "Feed content limit"
setting_file_max_size_displayed: "Max size of text files displayed inline"
setting_host_name: "Host name"
setting_collaborative_editing_hocuspocus_url: "Hocuspocus server URL"
setting_collaborative_editing_hocuspocus_secret: "Hocuspocus server secret"
setting_hours_per_day: "Hours per day"
setting_hours_per_day_explanation: >-
This defines what is considered a "day" when displaying duration in days and hours
+9
View File
@@ -0,0 +1,9 @@
FROM node:18
WORKDIR /app
COPY package.json ./
COPY index.js ./
RUN npm install
EXPOSE 1234
CMD ["node", "index.js"]
+20
View File
@@ -0,0 +1,20 @@
services:
hocuspocus:
build:
context: .
dockerfile: Dockerfile
labels:
- "traefik.enable=true"
- "traefik.http.routers.hocuspocus.rule=Host(`hocuspocus.local`)"
- "traefik.http.routers.hocuspocus.service=hocuspocus-service"
- "traefik.http.routers.hocuspocus.tls=true"
- "traefik.http.services.hocuspocus-service.loadbalancer.server.port=1234"
- "traefik.http.routers.hocuspocus.tls.certresolver=step"
# - "traefik.http.serversTransports.insecureTransport.insecureSkipVerify=true"
# - "traefik.http.services.my-wss-service.loadBalancer.serversTransport=insecureTransport"
networks:
- gateway
networks:
gateway:
external: true
name: gateway
+61
View File
@@ -0,0 +1,61 @@
import { Server } from "@hocuspocus/server";
import { createVerifier } from 'fast-jwt'
import { ServerBlockNoteEditor } from "@blocknote/server-util";
import {
BlockNoteSchema,
defaultBlockSpecs,
defaultInlineContentSpecs,
defaultStyleSpecs,
} from "@blocknote/core";
import * as Y from "yjs";
const secret = "secret12345"
const verifyToken = createVerifier({
key: async () => secret,
algorithms: ['HS256'],
})
const server = new Server({
port: 1234,
quite: false,
onConnect(data) {
console.log('CONNECTED: documentName: %0, socketId %0', data.documentName, data.socketId);
},
async afterUnloadDocument(data) {
console.log(`Document ${data.documentName} was closed`);
},
async onChange(data) {
console.log(`Document ${data.documentName} was changed`);
},
async onLoadDocument({ context, documentName, document }) {
const fragment = document.getXmlFragment('document-store');
if (fragment.length === 0) {
const schema = BlockNoteSchema.create({
blockSpecs: defaultBlockSpecs,
});
const editor = ServerBlockNoteEditor.create({schema});
const blocks = await editor.tryParseMarkdownToBlocks(context.document_text);
const doc = editor.blocksToYDoc(blocks, "document-store");
return doc;
}
},
async onAuthenticate(data) {
const { token, documentName } = data;
if (!token) {
throw new Error('Unauthorized: Token missing.')
}
let tokenPayload;
try {
tokenPayload = await verifyToken(token)
} catch (err) {
throw new Error('Unauthorized: Invalid token.')
}
console.log('Token payload:', tokenPayload);
if(documentName != tokenPayload.document_id) {
throw new Error('Unauthorized: Invalid token. This document cannot be accessed with this token.')
}
data.context.document_text = tokenPayload.document_text;
},
});
server.listen();
+18
View File
@@ -0,0 +1,18 @@
{
"name": "openproject-hocuspocus",
"type": "module",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@blocknote/server-util": "^0.34.0",
"@hocuspocus/server": "^3.2.0",
"fast-jwt": "^6.0.2"
}
}
@@ -12,3 +12,4 @@ services:
- nextcloud.local
- gitlab.local
- keycloak.local
- hocuspocus.local
+1818 -1632
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -60,9 +60,10 @@
"source-map-explorer": "^2.5.2",
"theo": "^8.1.5",
"ts-node": "~8.3.0",
"typescript": "^5.8.3",
"typescript": "5.8.3",
"typescript-eslint": "^8.39.1",
"webpack-bundle-analyzer": "^4.4.2"
"webpack-bundle-analyzer": "^4.4.2",
"wscat": "^6.1.0"
},
"dependencies": {
"@angular/animations": "^20.1.2",
@@ -99,6 +100,7 @@
"@fullcalendar/resource-timeline": "^6.1.11",
"@fullcalendar/timegrid": "^6.1.11",
"@github/webauthn-json": "^2.1.1",
"@hocuspocus/provider": "^3.2.0",
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.10",
"@hotwired/turbo-rails": "^8.0.10",
+57 -8
View File
@@ -34,10 +34,16 @@ import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlock
import { dummyBlockSpec, getDefaultOpenProjectSlashMenuItems, openProjectWorkPackageBlockSpec } from "op-blocknote-extensions";
import { useEffect, useState } from "react";
import { OpColorMode } from "core-app/core/setup/globals/theme-utils";
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as Y from 'yjs';
export interface OpBlockNoteContainerProps {
inputField: HTMLInputElement;
inputText?: string;
hocuspocusUrl: string;
hocuspocusAccessToken: string;
userName: string;
documentId: string;
}
const schema = BlockNoteSchema.create({
@@ -50,10 +56,38 @@ const schema = BlockNoteSchema.create({
const detectTheme = ():OpColorMode => { return window.OpenProject.theme.detectOpColorMode(); };
export default function OpBlockNoteContainer({ inputField, inputText }: OpBlockNoteContainerProps) {
export default function OpBlockNoteContainer({ inputField,
inputText,
userName,
hocuspocusUrl,
hocuspocusAccessToken,
documentId }: OpBlockNoteContainerProps) {
const [isLoading, setIsLoading] = useState(true);
const editor = useCreateBlockNote({ schema });
let collaboration: any;
const collaborationEnabled: boolean = Boolean(hocuspocusUrl && documentId && hocuspocusAccessToken && userName);
let provider: HocuspocusProvider | null = null;
if(collaborationEnabled) {
const doc = new Y.Doc()
provider = new HocuspocusProvider({
url: hocuspocusUrl,
name: documentId,
token: hocuspocusAccessToken,
document: doc
});
const cursorColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
collaboration = {
provider,
fragment: doc.getXmlFragment("document-store"),
user: {
name: userName,
color: cursorColor,
},
showCursorLabels: "activity"
}
}
const editor = useCreateBlockNote(collaboration ? { collaboration, schema } : { schema });
type EditorType = typeof editor;
const getCustomSlashMenuItems = (editor: EditorType) => {
@@ -64,13 +98,28 @@ export default function OpBlockNoteContainer({ inputField, inputText }: OpBlockN
};
useEffect(() => {
async function loadInitialContent() {
const blocks = await editor.tryParseMarkdownToBlocks(inputText || "");
editor.replaceBlocks(editor.document, blocks);
setIsLoading(false);
async function prepareEditor() {
if(collaborationEnabled && provider) {
provider.on('synced', async () => {
console.log('BlockNote collaboration synced');
setIsLoading(false);
});
provider.on('disconnect', () => {
console.error('BlockNote collaboration disconnected');
});
} else {
const blocks = await editor.tryParseMarkdownToBlocks(inputText || "");
editor.replaceBlocks(editor.document, blocks);
setIsLoading(false);
}
}
loadInitialContent();
}, [editor]);
void prepareEditor();
return () => {
if (provider) {
provider.destroy();
}
};
}, []);
return (
<>
@@ -41,11 +41,19 @@ export default class extends Controller {
static values = {
inputText: String,
userName: String,
hocuspocusUrl: String,
hocuspocusAccessToken: String,
documentId: String,
};
declare readonly blockNoteEditorTarget:HTMLElement;
declare readonly blockNoteInputFieldTarget:HTMLInputElement;
declare readonly inputTextValue:string;
declare readonly userNameValue:string;
declare readonly hocuspocusUrlValue:string;
declare readonly hocuspocusAccessTokenValue:string;
declare readonly documentIdValue:string;
connect() {
const root = createRoot(this.blockNoteEditorTarget);
@@ -56,6 +64,10 @@ export default class extends Controller {
return React.createElement(OpBlockNoteContainer, {
inputField: this.blockNoteInputFieldTarget,
inputText: this.inputTextValue,
userName: this.userNameValue,
hocuspocusUrl: this.hocuspocusUrlValue,
hocuspocusAccessToken: this.hocuspocusAccessTokenValue,
documentId: this.documentIdValue,
});
}
}
+8 -1
View File
@@ -53,6 +53,13 @@ module OpenProject
end
end
def styled_url_field_tag(name, value = nil, options = {})
apply_css_class_to_options(options, "form--text-field")
wrap_field "text-field", options do
url_field_tag(name, value, options)
end
end
def styled_label_tag(name = nil, content_or_options = nil, options = {}, &)
apply_css_class_to_options(
block_given? && content_or_options.is_a?(Hash) ? content_or_options : (options ||= {}),
@@ -87,7 +94,7 @@ module OpenProject
##
# Create a wrapper for the text formatting toolbar for this field
def text_formatting_wrapper(target_id, options = {})
return "".html_safe unless target_id.present?
return "".html_safe if target_id.blank?
::OpenProject::TextFormatting::Formats
.rich_helper
@@ -29,13 +29,19 @@ See COPYRIGHT and LICENSE files for more details.
<%=
render FormControl.new(
input: @input, class: @input.classes, data: {
input: @input,
class: @input.classes,
data: {
controller: "block-note",
block_note_input_text_value: value
block_note_input_text_value: value,
block_note_user_name_value: user_name,
block_note_hocuspocus_url_value: hocuspocus_url,
block_note_hocuspocus_access_token_value: hocuspocus_access_token,
block_note_document_id_value: document_id,
}
) do
%>
<%= @input.builder.hidden_field(name, value: @value, data: { block_note_target: "blockNoteInputField" }) %>
<%= @input.builder.hidden_field(name, value:, data: { block_note_target: "blockNoteInputField" }) %>
<%=
render(
Primer::BaseComponent.new(
@@ -33,14 +33,23 @@ module Primer
module Forms
# :nodoc:
class BlockNoteEditor < Primer::Forms::BaseComponent
attr_reader :input, :value
attr_reader :input,
:value,
:user_name,
:hocuspocus_url,
:hocuspocus_access_token,
:document_id
delegate :name, to: :@input
def initialize(input:, value:)
def initialize(input:, value:, document_id:)
super()
@input = input
@value = value
@user_name = User.current.name
@document_id = document_id
@hocuspocus_url = Setting.collaborative_editing_hocuspocus_url
@hocuspocus_access_token = ::CollaborativeEditing::DocumentAccessTokenGenerator.call(document_id, value)
end
end
end
@@ -33,19 +33,20 @@ module Primer
module Forms
module Dsl
class BlockNoteEditorInput < Primer::Forms::Dsl::Input
attr_reader :name, :label, :value, :classes
attr_reader :name, :label, :value, :classes, :document_id
def initialize(name:, label:, value:, **system_arguments)
def initialize(name:, label:, value:, document_id:, **system_arguments)
@name = name
@label = label
@value = value
@classes = system_arguments[:classes]
@document_id = document_id
super(**system_arguments)
end
def to_component
BlockNoteEditor.new(input: self, value:)
BlockNoteEditor.new(input: self, value:, document_id:)
end
def type
+2 -1
View File
@@ -52,7 +52,8 @@ class DocumentForm < ApplicationForm
name: :description,
label: I18n.t("label_document_description"),
classes: "document-form--long-description",
value: model.description
value: model.description,
document_id: ::CollaborativeEditing::DocumentIdGenerator.call("documents", model.id)
)
else
f.rich_text_area(
@@ -233,7 +233,7 @@ RSpec.describe Primer::OpenProject::Forms::Dsl::InputMethods, type: :forms do
end
describe "#block_note_editor" do
let(:field_group) { form_dsl.block_note_editor(name:, label:, value: "", suggestions: [], **options) }
let(:field_group) { form_dsl.block_note_editor(name:, label:, value: "", document_id: "123asdzxc", suggestions: [], **options) }
include_examples "input class", Primer::OpenProject::Forms::Dsl::BlockNoteEditorInput
it_behaves_like "supporting help texts"