[#65875] Refactor collaborative editing.

- Use published docker image for local dev setup.
- Handle a case when Setting.collaborative_editing_hocuspocus_url is set to invalid URI.
- Remove unneedd CSS.
- Add some tests.
This commit is contained in:
Pavel Balashou
2025-08-29 13:30:49 +02:00
parent 3a05d52000
commit 54f099c1be
13 changed files with 336 additions and 5125 deletions
@@ -54,15 +54,22 @@ module DynamicContentSecurityPolicy
end
end
private
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])
uri = begin
URI.parse(hocuspocus_url)
rescue URI::InvalidURIError
OpenProject.logger.info do
"Setting.collaborative_editing_hocuspocus_url is set to an invalid URI: #{hocuspocus_url}"
end
nil
end
if uri.present?
append_content_security_policy_directives(connect_src: ["#{uri.scheme}://#{uri.host}"])
end
end
end
end
+1 -1
View File
@@ -566,7 +566,7 @@ module Settings
description: "Additional allowed host names for the application.",
default: []
},
collaborative_editing_hocuspocus_url: {
collaborative_editing_hocuspocus_url: {
format: :string,
default: nil,
description: "The URL of the hocuspocus server used by BlockNoteJS editor to enable collaborative editing.",
-68
View File
@@ -1,68 +0,0 @@
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";
import { SQLite } from "@hocuspocus/extension-sqlite";
const secret = "secret12345"
const verifyToken = createVerifier({
key: async () => secret,
algorithms: ['HS256'],
})
const server = new Server({
port: 1234,
quite: false,
extensions: [
new SQLite({
database: "db.sqlite",
}),
],
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();
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
{
"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.33.0",
"@hocuspocus/extension-sqlite": "^3.2.2",
"@hocuspocus/server": "^3.2.0",
"fast-jwt": "^6.0.2"
}
}
+3 -8
View File
@@ -1,11 +1,6 @@
services:
hocuspocus:
command: sh -c "npm install && node index.js"
image: node:20
working_dir: /app
volumes:
- ./app:/app
- node_modules:/app/node_modules
image: openproject/hocuspocus:main-88913ad0
labels:
- "traefik.enable=true"
- "traefik.http.routers.hocuspocus.rule=Host(`hocuspocus.local`)"
@@ -13,10 +8,10 @@ services:
- "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
environment:
- SECRET=secret12345
networks:
gateway:
external: true
+131 -5
View File
@@ -43,6 +43,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",
@@ -176,9 +177,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"
},
"optionalDependencies": {
"fsevents": "*"
@@ -4182,6 +4184,31 @@
"webauthn-json": "dist/bin/main.js"
}
},
"node_modules/@hocuspocus/common": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-3.2.3.tgz",
"integrity": "sha512-P8COsx2HVXS7NbDEKe9KSt5Hd1A95hZhyTabiNPlU/Pi+7K1RJuHqIkIRr4oIxGzujvpLq/LSwBHP4NYUk+cGA==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.87"
}
},
"node_modules/@hocuspocus/provider": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-3.2.3.tgz",
"integrity": "sha512-M62Fly7s6sKKGGFze46fypjV3jv2Y2l8PA6/+IgbQRWH275p8NjGP1U7yMOxi52PLFrgaEoy+fQyW93iJ2jqaw==",
"license": "MIT",
"dependencies": {
"@hocuspocus/common": "^3.2.3",
"@lifeomic/attempt": "^3.0.2",
"lib0": "^0.2.87",
"ws": "^8.17.1"
},
"peerDependencies": {
"y-protocols": "^1.0.6",
"yjs": "^13.6.8"
}
},
"node_modules/@hotwired/stimulus": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
@@ -4962,6 +4989,12 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"dev": true
},
"node_modules/@lifeomic/attempt": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz",
"integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==",
"license": "MIT"
},
"node_modules/@listr2/prompt-adapter-inquirer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz",
@@ -21147,6 +21180,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/read": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/read/-/read-4.1.0.tgz",
"integrity": "sha512-uRfX6K+f+R8OOrYScaM3ixPY4erg69f8DN6pgTvMcA9iRc8iDhwrA4m3Yu8YYKsXJgVvum+m8PkRboZwwuLzYA==",
"dev": true,
"license": "ISC",
"dependencies": {
"mute-stream": "^2.0.0"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -26164,7 +26210,6 @@
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -26182,6 +26227,35 @@
}
}
},
"node_modules/wscat": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/wscat/-/wscat-6.1.0.tgz",
"integrity": "sha512-x6gEZvITvqWslR38DoBfnMi37ZBUGsG9rTkGc/200sEfSs1JwgKLZYQeqa0vlu3bxXQV7hEHI4NF7KQmYIzB2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"commander": "^12.1.0",
"https-proxy-agent": "^7.0.5",
"read": "^4.0.0",
"ws": "^8.0.0"
},
"bin": {
"wscat": "bin/wscat"
},
"engines": {
"node": ">=18"
}
},
"node_modules/wscat/node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/wsl-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
@@ -28933,6 +29007,25 @@
"resolved": "https://registry.npmjs.org/@github/webauthn-json/-/webauthn-json-2.1.1.tgz",
"integrity": "sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ=="
},
"@hocuspocus/common": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-3.2.3.tgz",
"integrity": "sha512-P8COsx2HVXS7NbDEKe9KSt5Hd1A95hZhyTabiNPlU/Pi+7K1RJuHqIkIRr4oIxGzujvpLq/LSwBHP4NYUk+cGA==",
"requires": {
"lib0": "^0.2.87"
}
},
"@hocuspocus/provider": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-3.2.3.tgz",
"integrity": "sha512-M62Fly7s6sKKGGFze46fypjV3jv2Y2l8PA6/+IgbQRWH275p8NjGP1U7yMOxi52PLFrgaEoy+fQyW93iJ2jqaw==",
"requires": {
"@hocuspocus/common": "^3.2.3",
"@lifeomic/attempt": "^3.0.2",
"lib0": "^0.2.87",
"ws": "^8.17.1"
}
},
"@hotwired/stimulus": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
@@ -29399,6 +29492,11 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"dev": true
},
"@lifeomic/attempt": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz",
"integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw=="
},
"@listr2/prompt-adapter-inquirer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz",
@@ -40682,6 +40780,15 @@
"use-latest": "^1.2.1"
}
},
"read": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/read/-/read-4.1.0.tgz",
"integrity": "sha512-uRfX6K+f+R8OOrYScaM3ixPY4erg69f8DN6pgTvMcA9iRc8iDhwrA4m3Yu8YYKsXJgVvum+m8PkRboZwwuLzYA==",
"dev": true,
"requires": {
"mute-stream": "^2.0.0"
}
},
"readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -44171,8 +44278,27 @@
"ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"dev": true
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="
},
"wscat": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/wscat/-/wscat-6.1.0.tgz",
"integrity": "sha512-x6gEZvITvqWslR38DoBfnMi37ZBUGsG9rTkGc/200sEfSs1JwgKLZYQeqa0vlu3bxXQV7hEHI4NF7KQmYIzB2A==",
"dev": true,
"requires": {
"commander": "^12.1.0",
"https-proxy-agent": "^7.0.5",
"read": "^4.0.0",
"ws": "^8.0.0"
},
"dependencies": {
"commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"dev": true
}
}
},
"wsl-utils": {
"version": "0.1.0",
@@ -29,22 +29,3 @@
// Component specific Styles
@import "../../../app/components/_index.sass"
@import "../../../app/forms/_index.sass"
.comments-main-container
align-items: center
display: flex
flex-direction: column-reverse
gap: 10px
height: 100%
max-width: none
padding: 10px
width: 100%
.bn-editor
height: 100%
max-width: 700px
min-height: 80vh
overflow: auto
width: 100%
.bn-thread-comments
.bn-editor
min-height: 3vh
@@ -32,8 +32,7 @@ import { Controller } from '@hotwired/stimulus';
import React from 'react';
import { createRoot } from 'react-dom/client';
import OpBlockNoteContainer from '../../../react/OpBlockNoteContainer';
// import OpBlockNoteContainer from 'react/OpBlockNoteContainer';
import { User } from "@blocknote/core/comments";
import { User } from '@blocknote/core/comments';
export default class extends Controller {
static targets = [
@@ -53,7 +52,7 @@ export default class extends Controller {
declare readonly blockNoteEditorTarget:HTMLElement;
declare readonly blockNoteInputFieldTarget:HTMLInputElement;
declare readonly inputTextValue:string;
declare readonly usersValue:Array<User>;
declare readonly usersValue:User[];
declare readonly activeUserValue:User;
declare readonly hocuspocusUrlValue:string;
declare readonly hocuspocusAccessTokenValue:string;
@@ -32,12 +32,7 @@ require "spec_helper"
require_module_spec_helper
RSpec.describe "Appendix of default CSP for external file storage hosts" do
def parse_csp(csp_string)
csp_string
.split("; ")
.map(&:split)
.each_with_object({}) { |csp_part, csp_hash_map| csp_hash_map[csp_part[0]] = csp_part[1..] }
end
include CspHelper
shared_let(:project) { create(:project) }
shared_let(:storage) { create(:nextcloud_storage) }
@@ -0,0 +1,64 @@
# 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.
#++
require "spec_helper"
RSpec.describe "" do
include CspHelper
current_user { create(:user) }
describe "GET /" do
context "when collaborative_editing_hocuspocus_url is set as a valid URI" do
it "responds with 200 and appends storage host to the connect-src CSP",
with_settings: { collaborative_editing_hocuspocus_url: "wss://hocuspocus.local" } do
get "/"
expect(last_response).to have_http_status(200)
csp = parse_csp(last_response.headers["Content-Security-Policy"])
expect(csp["connect-src"]).to include("wss://hocuspocus.local")
end
end
context "when collaborative_editing_hocuspocus_url is set to an invalid URI" do
it "responds with 200 and logs the problem",
with_settings: { collaborative_editing_hocuspocus_url: "://hocuspocus.local" } do
allow(OpenProject.logger).to receive(:info)
get "/"
expect(last_response).to have_http_status(200)
expect(OpenProject.logger).to have_received(:info) do |&blk|
expect(blk.call).to eq "Setting.collaborative_editing_hocuspocus_url is set to an invalid URI: ://hocuspocus.local"
end
end
end
end
end
@@ -0,0 +1,84 @@
# 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.
# ++
require "spec_helper"
RSpec.describe CollaborativeEditing::DocumentIdGenerator do
describe ".call" do
let(:secret_key_base) { "test_secret" }
before do
allow(Rails.application).to receive(:secret_key_base).and_return(secret_key_base)
end
it "returns a SHA256 HMAC hex digest of category and id" do
result = described_class.call("documents", 123)
expect(result).to eq("a809f02491b92e3addef5bc78319f788ca0d9c8e56c9a67532f6f8d76e5b54cc")
end
end
end
RSpec.describe CollaborativeEditing::DocumentAccessTokenGenerator do
describe ".call" do
let(:document_id) { "a809f02491b92e3addef5bc78319f788ca0d9c8e56c9a67532f6f8d76e5b54cc" }
let(:document_text) { "Some text" }
let(:secret) { "jwt_secret" }
context "when Setting.collaborative_editing_hocuspocus_secret is present" do
before do
allow(Setting).to receive(:collaborative_editing_hocuspocus_secret).and_return(secret)
end
it "returns a JWT token" do
token = described_class.call(document_id, document_text)
expect(token).to be_a(String)
payload, header = JWT.decode(token, secret, true, algorithm: "HS256")
expect(payload["document_id"]).to eq(document_id)
expect(payload["document_text"]).to eq(document_text)
expect(payload["exp"]).to be_within(5).of(20.minutes.from_now.to_i)
expect(header["alg"]).to eq("HS256")
end
end
context "when Setting.collaborative_editing_hocuspocus_secret is not present" do
before do
allow(Setting).to receive(:collaborative_editing_hocuspocus_secret).and_return(nil)
end
it "returns nil" do
expect(described_class.call(document_id, document_text)).to be_nil
end
end
end
end
+38
View File
@@ -0,0 +1,38 @@
# 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 CspHelper
def parse_csp(csp_string)
csp_string
.split("; ")
.map(&:split)
.each_with_object({}) { |csp_part, csp_hash_map| csp_hash_map[csp_part[0]] = csp_part[1..] }
end
end