mirror of
https://github.com/opf/openproject.git
synced 2026-06-14 03:30:14 +00:00
[#65875] Enable collaborative editing.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}]",
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:18
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
COPY index.js ./
|
||||
RUN npm install
|
||||
EXPOSE 1234
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
Generated
+1818
-1632
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user