diff --git a/packages/collaborator-client/src/client.ts b/packages/collaborator-client/src/client.ts index 1448bc11ee9..fcebfb087da 100644 --- a/packages/collaborator-client/src/client.ts +++ b/packages/collaborator-client/src/client.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import { Class, Doc, Hierarchy, Markup, Ref, concatLink } from '@hcengineering/core' +import { Class, Doc, Hierarchy, Markup, Ref, WorkspaceId, concatLink } from '@hcengineering/core' import { minioDocumentId, mongodbDocumentId } from './utils' /** @@ -27,25 +27,32 @@ export interface CollaboratorClient { /** * @public */ -export function getClient (hierarchy: Hierarchy, token: string, collaboratorUrl: string): CollaboratorClient { - return new CollaboratorClientImpl(hierarchy, token, collaboratorUrl) +export function getClient ( + hierarchy: Hierarchy, + workspaceId: WorkspaceId, + token: string, + collaboratorUrl: string +): CollaboratorClient { + return new CollaboratorClientImpl(hierarchy, workspaceId, token, collaboratorUrl) } class CollaboratorClientImpl implements CollaboratorClient { constructor ( private readonly hierarchy: Hierarchy, + private readonly workspace: WorkspaceId, private readonly token: string, private readonly collaboratorUrl: string ) {} - initialContentId (classId: Ref>, docId: Ref, attribute: string): string { + initialContentId (workspace: string, classId: Ref>, docId: Ref, attribute: string): string { const domain = this.hierarchy.getDomain(classId) - return mongodbDocumentId(domain, docId, attribute) + return mongodbDocumentId(workspace, domain, docId, attribute) } async get (classId: Ref>, docId: Ref, attribute: string): Promise { - const documentId = encodeURIComponent(minioDocumentId(docId, attribute)) - const initialContentId = encodeURIComponent(this.initialContentId(classId, docId, attribute)) + const workspace = this.workspace.name + const documentId = encodeURIComponent(minioDocumentId(workspace, docId, attribute)) + const initialContentId = encodeURIComponent(this.initialContentId(workspace, classId, docId, attribute)) attribute = encodeURIComponent(attribute) const url = concatLink( @@ -65,8 +72,9 @@ class CollaboratorClientImpl implements CollaboratorClient { } async update (classId: Ref>, docId: Ref, attribute: string, value: Markup): Promise { - const documentId = encodeURIComponent(minioDocumentId(docId, attribute)) - const initialContentId = encodeURIComponent(this.initialContentId(classId, docId, attribute)) + const workspace = this.workspace.name + const documentId = encodeURIComponent(minioDocumentId(workspace, docId, attribute)) + const initialContentId = encodeURIComponent(this.initialContentId(workspace, classId, docId, attribute)) attribute = encodeURIComponent(attribute) const url = concatLink( diff --git a/packages/collaborator-client/src/utils.ts b/packages/collaborator-client/src/utils.ts index 9ce34ca6360..288e0dac3ea 100644 --- a/packages/collaborator-client/src/utils.ts +++ b/packages/collaborator-client/src/utils.ts @@ -15,10 +15,10 @@ import { Doc, Domain, Ref } from '@hcengineering/core' -export function minioDocumentId (docId: Ref, attribute?: string): string { - return attribute !== undefined ? `minio://${docId}%${attribute}` : `minio://${docId}` +export function minioDocumentId (workspace: string, docId: Ref, attribute?: string): string { + return attribute !== undefined ? `minio://${workspace}/${docId}%${attribute}` : `minio://${workspace}/${docId}` } -export function mongodbDocumentId (domain: Domain, docId: Ref, attribute: string): string { - return `mongodb://${domain}/${docId}/${attribute}` +export function mongodbDocumentId (workspace: string, domain: Domain, docId: Ref, attribute: string): string { + return `mongodb://${workspace}/${domain}/${docId}/${attribute}` } diff --git a/packages/presentation/src/collaborator.ts b/packages/presentation/src/collaborator.ts index 600f0c95ae2..e9a1f8baa01 100644 --- a/packages/presentation/src/collaborator.ts +++ b/packages/presentation/src/collaborator.ts @@ -14,8 +14,9 @@ // import { type CollaboratorClient, getClient as getCollaborator } from '@hcengineering/collaborator-client' -import type { Class, Doc, Markup, Ref } from '@hcengineering/core' +import { getWorkspaceId, type Class, type Doc, type Markup, type Ref } from '@hcengineering/core' import { getMetadata } from '@hcengineering/platform' +import { getCurrentLocation } from '@hcengineering/ui' import { getClient } from '.' import presentation from './plugin' @@ -24,11 +25,12 @@ import presentation from './plugin' * @public */ export function getCollaboratorClient (): CollaboratorClient { + const workspaceId = getWorkspaceId(getCurrentLocation().path[1] ?? '') const hierarchy = getClient().getHierarchy() const token = getMetadata(presentation.metadata.Token) ?? '' const collaboratorURL = getMetadata(presentation.metadata.CollaboratorUrl) ?? '' - return getCollaborator(hierarchy, token, collaboratorURL) + return getCollaborator(hierarchy, workspaceId, token, collaboratorURL) } /** diff --git a/packages/text-editor/src/components/CollaborativeTextEditor.svelte b/packages/text-editor/src/components/CollaborativeTextEditor.svelte index 1cc88e87070..cf5f356e1b9 100644 --- a/packages/text-editor/src/components/CollaborativeTextEditor.svelte +++ b/packages/text-editor/src/components/CollaborativeTextEditor.svelte @@ -150,7 +150,7 @@ return commandHandler } - export function takeSnapshot (snapshotId: string): void { + export function takeSnapshot (snapshotId: DocumentId): void { copyDocumentContent(documentId, snapshotId, { provider: remoteProvider }, initialContentId) } diff --git a/packages/text-editor/src/components/CollaboratorEditor.svelte b/packages/text-editor/src/components/CollaboratorEditor.svelte index 5766378f57c..476cabdc107 100644 --- a/packages/text-editor/src/components/CollaboratorEditor.svelte +++ b/packages/text-editor/src/components/CollaboratorEditor.svelte @@ -56,7 +56,7 @@ return collaborativeEditor?.commands() } - export function takeSnapshot (snapshotId: string): void { + export function takeSnapshot (snapshotId: DocumentId): void { collaborativeEditor?.takeSnapshot(snapshotId) } diff --git a/packages/text-editor/src/index.ts b/packages/text-editor/src/index.ts index 6ce087091cd..78c2e000496 100644 --- a/packages/text-editor/src/index.ts +++ b/packages/text-editor/src/index.ts @@ -69,6 +69,7 @@ export { ImageExtension, type ImageOptions } from './components/extension/imageE export { TodoItemExtension, TodoListExtension } from './components/extension/todo' export { + type DocumentId, TiptapCollabProvider, type TiptapCollabProviderConfiguration, createTiptapCollaborationData diff --git a/packages/text-editor/src/provider/minio.ts b/packages/text-editor/src/provider/minio.ts index 46571f86494..72221f62391 100644 --- a/packages/text-editor/src/provider/minio.ts +++ b/packages/text-editor/src/provider/minio.ts @@ -44,6 +44,10 @@ export class MinioProvider extends Observable { if (name.startsWith('minio://')) { name = name.split('://', 2)[1] + if (name.includes('/')) { + // drop workspace part + name = name.split('/', 2)[1] + } } void fetchContent(doc, name).then(() => { diff --git a/packages/text-editor/src/provider/tiptap.ts b/packages/text-editor/src/provider/tiptap.ts index 8b0165ba610..d35d8ead675 100644 --- a/packages/text-editor/src/provider/tiptap.ts +++ b/packages/text-editor/src/provider/tiptap.ts @@ -15,7 +15,7 @@ import { Doc as Ydoc } from 'yjs' import { HocuspocusProvider, type HocuspocusProviderConfiguration } from '@hocuspocus/provider' -export type DocumentId = string +export type DocumentId = string & { __documentId: true } export type TiptapCollabProviderConfiguration = HocuspocusProviderConfiguration & Required> & diff --git a/packages/text-editor/src/provider/utils.ts b/packages/text-editor/src/provider/utils.ts index 6e9b230a5f7..64bb5a3111f 100644 --- a/packages/text-editor/src/provider/utils.ts +++ b/packages/text-editor/src/provider/utils.ts @@ -15,18 +15,28 @@ import type { Doc, Ref } from '@hcengineering/core' import { type KeyedAttribute, getClient } from '@hcengineering/presentation' +import { getCurrentLocation } from '@hcengineering/ui' import { type DocumentId } from './tiptap' +function getWorkspace (): string { + return getCurrentLocation().path[1] ?? '' +} + export function minioDocumentId (docId: Ref, attr?: KeyedAttribute): DocumentId { - return attr !== undefined ? `minio://${docId}%${attr.key}` : `minio://${docId}` + const workspace = getWorkspace() + return attr !== undefined + ? (`minio://${workspace}/${docId}%${attr.key}` as DocumentId) + : (`minio://${workspace}/${docId}` as DocumentId) } export function platformDocumentId (docId: Ref, attr: KeyedAttribute): DocumentId { - return `platform://${attr.attr.attributeOf}/${docId}/${attr.key}` + const workspace = getWorkspace() + return `platform://${workspace}/${attr.attr.attributeOf}/${docId}/${attr.key}` as DocumentId } export function mongodbDocumentId (docId: Ref, attr: KeyedAttribute): DocumentId { + const workspace = getWorkspace() const domain = getClient().getHierarchy().getDomain(attr.attr.attributeOf) - return `mongodb://${domain}/${docId}/${attr.key}` + return `mongodb://${workspace}/${domain}/${docId}/${attr.key}` as DocumentId } diff --git a/packages/text-editor/src/utils.ts b/packages/text-editor/src/utils.ts index 957a1ffc5f3..78a2c58b13e 100644 --- a/packages/text-editor/src/utils.ts +++ b/packages/text-editor/src/utils.ts @@ -88,7 +88,7 @@ export function copyDocumentField ( export function copyDocumentContent ( documentId: DocumentId, - snapshotId: string, + snapshotId: DocumentId, providerData: ProviderData, initialContentId?: DocumentId ): void { diff --git a/server/collaborator/src/server.ts b/server/collaborator/src/server.ts index 6a50d37424f..c1e7cc34f58 100644 --- a/server/collaborator/src/server.ts +++ b/server/collaborator/src/server.ts @@ -143,7 +143,24 @@ export async function start ( async onAuthenticate (data: onAuthenticatePayload): Promise { ctx.measure('authenticate', 1) - return buildContext(data, controller) + const context = buildContext(data, controller) + + // verify document name + let documentName = data.documentName + if (documentName.includes('://')) { + documentName = documentName.split('://', 2)[1] + } + + if (documentName.includes('/')) { + const [workspace] = documentName.split('/', 2) + if (workspace !== context.workspaceId.name) { + throw new Error('documentName must include workspace') + } + } else { + throw new Error('documentName must include workspace') + } + + return context }, async onDestroy (data: onDestroyPayload): Promise { diff --git a/server/collaborator/src/storage/minio.ts b/server/collaborator/src/storage/minio.ts index c5cba2a8754..b00d879de1c 100644 --- a/server/collaborator/src/storage/minio.ts +++ b/server/collaborator/src/storage/minio.ts @@ -22,6 +22,23 @@ import { Context } from '../context' import { StorageAdapter } from './adapter' +interface MinioDocumentId { + workspace: string + minioDocumentId: string +} + +function parseDocumentId (documentId: string): MinioDocumentId { + const [workspace, minioDocumentId] = documentId.split('/') + return { + workspace: workspace ?? '', + minioDocumentId: minioDocumentId ?? '' + } +} + +function isValidDocumentId (documentId: MinioDocumentId, context: Context): boolean { + return documentId.minioDocumentId !== '' && documentId.workspace === context.workspaceId.name +} + function maybePlatformDocumentId (documentId: string): boolean { return !documentId.includes('%') } @@ -35,10 +52,17 @@ export class MinioStorageAdapter implements StorageAdapter { async loadDocument (documentId: string, context: Context): Promise { const { workspaceId } = context + const { workspace, minioDocumentId } = parseDocumentId(documentId) + + if (!isValidDocumentId({ workspace, minioDocumentId }, context)) { + console.warn('malformed document id', documentId) + return undefined + } + return await this.ctx.with('load-document', {}, async (ctx) => { const minioDocument = await ctx.with('query', {}, async () => { try { - const buffer = await this.minio.read(workspaceId, documentId) + const buffer = await this.minio.read(workspaceId, minioDocumentId) return Buffer.concat(buffer) } catch { return undefined @@ -67,6 +91,13 @@ export class MinioStorageAdapter implements StorageAdapter { async saveDocument (documentId: string, document: YDoc, context: Context): Promise { const { clientFactory, workspaceId } = context + const { workspace, minioDocumentId } = parseDocumentId(documentId) + + if (!isValidDocumentId({ workspace, minioDocumentId }, context)) { + console.warn('malformed document id', documentId) + return undefined + } + await this.ctx.with('save-document', {}, async (ctx) => { const buffer = await ctx.with('transform', {}, () => { const updates = encodeStateAsUpdate(document) @@ -75,13 +106,13 @@ export class MinioStorageAdapter implements StorageAdapter { await ctx.with('update', {}, async () => { const metadata = { 'content-type': 'application/ydoc' } - await this.minio.put(workspaceId, documentId, buffer, buffer.length, metadata) + await this.minio.put(workspaceId, minioDocumentId, buffer, buffer.length, metadata) }) // minio file is usually an attachment document // we need to touch an attachment from here to notify platform about changes - if (!maybePlatformDocumentId(documentId)) { + if (!maybePlatformDocumentId(minioDocumentId)) { // documentId is not a platform document id, we can skip platform notification return } @@ -92,7 +123,7 @@ export class MinioStorageAdapter implements StorageAdapter { }) const current = await ctx.with('query', {}, async () => { - return await client.findOne(attachment.class.Attachment, { _id: documentId as Ref }) + return await client.findOne(attachment.class.Attachment, { _id: minioDocumentId as Ref }) }) if (current !== undefined) { diff --git a/server/collaborator/src/storage/mongodb.ts b/server/collaborator/src/storage/mongodb.ts index e0b9e358421..95b26acf436 100644 --- a/server/collaborator/src/storage/mongodb.ts +++ b/server/collaborator/src/storage/mongodb.ts @@ -23,22 +23,29 @@ import { Context } from '../context' import { StorageAdapter } from './adapter' interface MongodbDocumentId { + workspace: string objectDomain: string objectId: string objectAttr: string } function parseDocumentId (documentId: string): MongodbDocumentId { - const [objectDomain, objectId, objectAttr] = documentId.split('/') + const [workspace, objectDomain, objectId, objectAttr] = documentId.split('/') return { + workspace: workspace ?? '', objectId: objectId ?? '', objectDomain: objectDomain ?? '', objectAttr: objectAttr ?? '' } } -function isValidDocumentId (documentId: MongodbDocumentId): boolean { - return documentId.objectDomain !== '' && documentId.objectId !== '' && documentId.objectAttr !== '' +function isValidDocumentId (documentId: MongodbDocumentId, context: Context): boolean { + return ( + documentId.objectDomain !== '' && + documentId.objectId !== '' && + documentId.objectAttr !== '' && + documentId.workspace === context.workspaceId.name + ) } export class MongodbStorageAdapter implements StorageAdapter { @@ -49,17 +56,16 @@ export class MongodbStorageAdapter implements StorageAdapter { ) {} async loadDocument (documentId: string, context: Context): Promise { - const { workspaceId } = context - const { objectId, objectDomain, objectAttr } = parseDocumentId(documentId) + const { workspace, objectId, objectDomain, objectAttr } = parseDocumentId(documentId) - if (!isValidDocumentId({ objectId, objectDomain, objectAttr })) { + if (!isValidDocumentId({ workspace, objectId, objectDomain, objectAttr }, context)) { console.warn('malformed document id', documentId) return undefined } return await this.ctx.with('load-document', {}, async (ctx) => { const doc = await ctx.with('query', {}, async () => { - const db = this.mongodb.db(toWorkspaceString(workspaceId)) + const db = this.mongodb.db(toWorkspaceString(context.workspaceId)) return await db.collection(objectDomain).findOne({ _id: objectId }, { projection: { [objectAttr]: 1 } }) }) diff --git a/server/collaborator/src/storage/platform.ts b/server/collaborator/src/storage/platform.ts index 5b3c62945d6..32c7b9fa68c 100644 --- a/server/collaborator/src/storage/platform.ts +++ b/server/collaborator/src/storage/platform.ts @@ -22,22 +22,29 @@ import { Context } from '../context' import { StorageAdapter } from './adapter' interface PlatformDocumentId { + workspace: string objectClass: Ref> objectId: Ref objectAttr: string } function parseDocumentId (documentId: string): PlatformDocumentId { - const [objectClass, objectId, objectAttr] = documentId.split('/') + const [workspace, objectClass, objectId, objectAttr] = documentId.split('/') return { + workspace: workspace ?? '', objectClass: (objectClass ?? '') as Ref>, objectId: (objectId ?? '') as Ref, objectAttr: objectAttr ?? '' } } -function isValidDocumentId (documentId: PlatformDocumentId): boolean { - return documentId.objectClass !== '' && documentId.objectId !== '' && documentId.objectAttr !== '' +function isValidDocumentId (documentId: PlatformDocumentId, context: Context): boolean { + return ( + documentId.objectClass !== '' && + documentId.objectId !== '' && + documentId.objectAttr !== '' && + documentId.workspace === context.workspaceId.name + ) } export class PlatformStorageAdapter implements StorageAdapter { @@ -48,9 +55,9 @@ export class PlatformStorageAdapter implements StorageAdapter { async loadDocument (documentId: string, context: Context): Promise { const { clientFactory } = context - const { objectId, objectClass, objectAttr } = parseDocumentId(documentId) + const { workspace, objectId, objectClass, objectAttr } = parseDocumentId(documentId) - if (!isValidDocumentId({ objectId, objectClass, objectAttr })) { + if (!isValidDocumentId({ workspace, objectId, objectClass, objectAttr }, context)) { console.warn('malformed document id', documentId) return undefined } @@ -77,9 +84,9 @@ export class PlatformStorageAdapter implements StorageAdapter { async saveDocument (documentId: string, document: YDoc, context: Context): Promise { const { clientFactory } = context - const { objectId, objectClass, objectAttr } = parseDocumentId(documentId) + const { workspace, objectId, objectClass, objectAttr } = parseDocumentId(documentId) - if (!isValidDocumentId({ objectId, objectClass, objectAttr })) { + if (!isValidDocumentId({ workspace, objectId, objectClass, objectAttr }, context)) { console.warn('malformed document id', documentId) return undefined }