Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/api/apiv3/components/schemas/collaboration_token_model.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Schema: CollaborationTokenModel
---
type: object
description: |-
A short-lived token granting access to a document on the real-time collaboration (Hocuspocus) server.

The token is opaque: it is encrypted and can only be read by the collaboration server.
It must neither be stored nor logged. Pass it as the `token` of a Hocuspocus provider
whose `name` is the `documentName`, connecting to the `collaborationServer` link.
required:
- _type
- token
- documentName
- expiresAt
- expiresInSeconds
- _links
properties:
_type:
type: string
enum:
- CollaborationToken
readOnly: true
token:
type: string
description: The opaque, encrypted token to hand to the collaboration server.
readOnly: true
documentName:
type: string
format: uri
description: The name of the document on the collaboration server. It is the absolute API URL of the document.
readOnly: true
expiresAt:
type: string
format: date-time
description: The time the token expires at.
readOnly: true
expiresInSeconds:
type: integer
description: The number of seconds until the token expires.
readOnly: true
_links:
type: object
required:
- document
- createCollaborationToken
- collaborationServer
readOnly: true
properties:
document:
allOf:
- "$ref": "./link.yml"
- description: |-
The document the token grants access to

**Resource**: Document
readOnly: true
createCollaborationToken:
allOf:
- "$ref": "./link.yml"
- description: |-
Creates a new token. Send this token as `token` to revoke it.
readOnly: true
collaborationServer:
allOf:
- "$ref": "./link.yml"
- description: |-
The WebSocket URL of the real-time collaboration server
readOnly: true
example:
_type: CollaborationToken
token: "Q2lwaGVydGV4dA==--SXZJdlZJdg==--QXV0aFRhZw=="
documentName: "https://openproject.example.com/api/v3/documents/1"
expiresAt: '2025-01-04T09:05:00Z'
expiresInSeconds: 300
_links:
document:
href: "/api/v3/documents/1"
title: Some document
createCollaborationToken:
href: "/api/v3/documents/1/collaboration_token"
method: post
collaborationServer:
href: "wss://hocuspocus.example.com"
16 changes: 16 additions & 0 deletions docs/api/apiv3/components/schemas/document_model.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ properties:
The attachments belonging to the document

**Resource**: []Attachment
createCollaborationToken:
allOf:
- "$ref": "./link.yml"
- description: |-
Creates a token for the real-time collaboration server

# Conditions

**Permission**: view documents

The document is collaborative, real-time collaboration is enabled and the feature flag
`documents_collaboration_token_api` is active.
readOnly: true
example:
_type: Document
id: 1
Expand All @@ -75,6 +88,9 @@ example:
addAttachment:
href: "/api/v3/documents/1/attachments"
method: post
createCollaborationToken:
href: "/api/v3/documents/1/collaboration_token"
method: post
self:
href: "/api/v3/documents/1"
title: Some document
Expand Down
4 changes: 4 additions & 0 deletions docs/api/apiv3/openapi-spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ paths:
"$ref": "./paths/documents.yml"
"/api/v3/documents/{id}":
"$ref": "./paths/document.yml"
"/api/v3/documents/{id}/collaboration_token":
"$ref": "./paths/document_collaboration_token.yml"
"/api/v3/file_links/{id}":
"$ref": "./paths/file_link.yml"
"/api/v3/file_links/{id}/open":
Expand Down Expand Up @@ -888,6 +890,8 @@ components:
"$ref": "./components/schemas/categories_by_workspace_model.yml"
CategoryModel:
"$ref": "./components/schemas/category_model.yml"
CollaborationTokenModel:
"$ref": "./components/schemas/collaboration_token_model.yml"
CollectionLinks:
"$ref": "./components/schemas/collection_links.yml"
CollectionModel:
Expand Down
6 changes: 6 additions & 0 deletions docs/api/apiv3/paths/document.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ get:
method: post
attachments:
href: "/api/v3/documents/1/attachments"
createCollaborationToken:
href: "/api/v3/documents/1/collaboration_token"
method: post
project:
href: "/api/v3/projects/19"
title: Some project
Expand Down Expand Up @@ -142,6 +145,9 @@ patch:
method: post
attachments:
href: "/api/v3/documents/1/attachments"
createCollaborationToken:
href: "/api/v3/documents/1/collaboration_token"
method: post
project:
href: "/api/v3/projects/19"
title: Some project
Expand Down
149 changes: 149 additions & 0 deletions docs/api/apiv3/paths/document_collaboration_token.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# /api/v3/documents/{id}/collaboration_token
---
post:
parameters:
- description: Document id
example: 1
in: path
name: id
required: true
schema:
type: integer
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: |-
A previously issued collaboration token to revoke 30 seconds after the new token is created.
The previous token may be expired, but must not be revoked (i.e. must not have been sent before).
Omit it to create a new token without revoking any.
examples:
create:
summary: Create a new token
value: {}
withPreviousToken:
summary: Create a new token and revoke the previous one
value:
token: "Q2lwaGVydGV4dA==--SXZJdlZJdg==--QXV0aFRhZw=="
responses:
'201':
content:
application/hal+json:
examples:
response:
value:
_type: CollaborationToken
token: "Q2lwaGVydGV4dA==--SXZJdlZJdg==--QXV0aFRhZw=="
documentName: "https://openproject.example.com/api/v3/documents/1"
expiresAt: '2025-01-04T09:05:00Z'
expiresInSeconds: 300
_links:
document:
href: "/api/v3/documents/1"
title: Some document
createCollaborationToken:
href: "/api/v3/documents/1/collaboration_token"
method: post
collaborationServer:
href: "wss://hocuspocus.example.com"
schema:
"$ref": "../components/schemas/collaboration_token_model.yml"
description: "Created. The response must not be cached (`Cache-Control: no-store`)."
headers: {}
'403':
content:
application/hal+json:
schema:
$ref: "../components/schemas/error_response.yml"
examples:
response:
value:
_type: Error
errorIdentifier: urn:openproject-org:api:v3:errors:MissingPermission
message: Requests authenticated with a collaboration token cannot request collaboration tokens.
description: |-
Returned if the request is authenticated with a collaboration token, i.e. the OAuth access token
inside it. Such tokens can not request further tokens.
headers: {}
'404':
content:
application/hal+json:
schema:
$ref: "../components/schemas/error_response.yml"
examples:
response:
value:
_type: Error
errorIdentifier: urn:openproject-org:api:v3:errors:NotFound
message: The requested resource could not be found.
description: |-
Returned if the document does not exist, if the user does not have permission to view it or if the
feature flag `documents_collaboration_token_api` is off.

**Required permission** `view documents` in the project the document belongs to
headers: {}
'422':
content:
application/hal+json:
schema:
$ref: "../components/schemas/error_response.yml"
examples:
not_collaborative:
value:
_type: Error
errorIdentifier: urn:openproject-org:api:v3:errors:UnprocessableContent
message: The document does not support real-time collaboration.
collaboration_disabled:
value:
_type: Error
errorIdentifier: urn:openproject-org:api:v3:errors:UnprocessableContent
message: Real-time collaboration is not enabled.
invalid_previous_token:
value:
_type: Error
errorIdentifier: urn:openproject-org:api:v3:errors:UnprocessableContent
message: The previous collaboration token is invalid, already revoked, or does not belong to this
document and user.
description: |-
Returned if the document is not collaborative, if real-time collaboration is disabled, or if the
previous token is invalid, was issued for another document, belongs to another user or is already
revoked. Nothing is created or revoked in that case.
headers: {}
'500':
content:
application/hal+json:
schema:
$ref: "../components/schemas/error_response.yml"
examples:
response:
value:
_type: Error
errorIdentifier: urn:openproject-org:api:v3:errors:InternalServerError
message: An internal error has occurred.
description: Returned if the token could not be issued. Nothing is revoked in that case.
headers: {}
tags:
- Documents
description: |-
Creates a short-lived token for the real-time collaboration (Hocuspocus) server for the document.

The endpoint is only available when the feature flag `documents_collaboration_token_api` is active.

The token is issued for the requesting user. Users who may only view documents receive a read-only token.
Requests authenticated with a token issued to the collaboration server are rejected.

To revoke the previous token while creating a new one, send it as `token` in the request body. The request
is authorized by the client's own credentials, exactly like creating a token without one. The previous token
only identifies which token to revoke, so it may be expired. This allows clients to recover after sleep or
offline periods. The previous token must not be revoked (i.e. must not have been sent before), otherwise
the request is rejected.

The previous token is revoked 30 seconds after the new token is created. Clients should hand the new token
to the collaboration server right away, e.g. by calling `sendToken()` on the Hocuspocus provider.
operationId: Create_document_collaboration_token
summary: Create collaboration token
29 changes: 26 additions & 3 deletions docs/api/apiv3/tags/documents.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,34 @@
description: |-
A document is a file containing a list of attachments.

*Please note, that the endpoint is only a stub for now.*

## Actions

None yet
| Link | Description | Condition |
| :----------------------: | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| update | Updates the document | **Permission**: manage documents |
| addAttachment | Adds an attachment to the document | **Permission**: manage documents |
| createCollaborationToken | Creates a token for the real-time collaboration server | **Permission**: view documents. The document is collaborative and real-time collaboration is enabled. |

## Real-time collaboration

Collaborative documents are edited through the real-time collaboration server (Hocuspocus). To connect to it:

1. Create a collaboration token with `POST /api/v3/documents/{id}/collaboration_token`
(the `createCollaborationToken` link of the document).
2. Connect a Hocuspocus provider to the `collaborationServer` link of the response, using `documentName` as
the provider's `name` and `token` as its `token`.
3. Before `expiresInSeconds` runs out, create a new token, sending the current one as `token` so it is revoked
30 seconds later (the `createCollaborationToken` link of the response). Doing so at about 80% of the
lifetime is recommended, like the OpenProject web frontend does. Hand the new token to the server right away
by calling `sendToken()` on the provider. A token that is already revoked (i.e. was sent before) is rejected.
4. If the WebSocket connection is closed with code `4401`, create a new token and reconnect.

Limitations:

- Requests authenticated with a collaboration token cannot create further collaboration tokens.
Do that with your own credentials (session, API key or OAuth token).
- The collaboration server rejects browser connections whose `Origin` does not match the OpenProject
host. Server-side clients that send no `Origin` header are not affected.

## Linked Properties

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ class RefreshTokensController < ApplicationController
def create
token_result = Documents::OAuth::TokenWithMetadataService.new(
user: current_user,
document: @document,
project: @project
document: @document
).call

if token_result.success?
Expand Down
4 changes: 2 additions & 2 deletions modules/documents/app/controllers/documents_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def search
def show
@attachments = @document.attachments.order(Arel.sql("created_at DESC"))

if @document.collaborative? && Setting.real_time_text_collaboration_enabled?
if @document.real_time_collaboration_available?
setup_collaboration_context
derive_show_edit_state_from_params
end
Expand Down Expand Up @@ -219,7 +219,7 @@ def setup_collaboration_context # rubocop:disable Metrics/AbcSize
return unless current_user.allowed_in_project?(:view_documents, @project)

token_result = Documents::OAuth::TokenWithMetadataService
.new(user: current_user, document: @document, project: @project)
.new(user: current_user, document: @document)
.call

if token_result.failure?
Expand Down
4 changes: 4 additions & 0 deletions modules/documents/app/models/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ def visible?(user = User.current)
!user.nil? && user.allowed_in_project?(:view_documents, project)
end

def real_time_collaboration_available?
collaborative? && Setting.real_time_text_collaboration_enabled?
end

def set_default_type
self.type ||= DocumentType.default if new_record?
end
Expand Down
Loading
Loading