UBERF-5044 Pass workspace name as part of collaborative document name (#4424)

Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2024-01-24 15:10:47 +07:00 committed by GitHub
parent b0c64803bb
commit 6882c75775
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 127 additions and 41 deletions

View File

@ -13,7 +13,7 @@
// limitations under the License. // 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' import { minioDocumentId, mongodbDocumentId } from './utils'
/** /**
@ -27,25 +27,32 @@ export interface CollaboratorClient {
/** /**
* @public * @public
*/ */
export function getClient (hierarchy: Hierarchy, token: string, collaboratorUrl: string): CollaboratorClient { export function getClient (
return new CollaboratorClientImpl(hierarchy, token, collaboratorUrl) hierarchy: Hierarchy,
workspaceId: WorkspaceId,
token: string,
collaboratorUrl: string
): CollaboratorClient {
return new CollaboratorClientImpl(hierarchy, workspaceId, token, collaboratorUrl)
} }
class CollaboratorClientImpl implements CollaboratorClient { class CollaboratorClientImpl implements CollaboratorClient {
constructor ( constructor (
private readonly hierarchy: Hierarchy, private readonly hierarchy: Hierarchy,
private readonly workspace: WorkspaceId,
private readonly token: string, private readonly token: string,
private readonly collaboratorUrl: string private readonly collaboratorUrl: string
) {} ) {}
initialContentId (classId: Ref<Class<Doc>>, docId: Ref<Doc>, attribute: string): string { initialContentId (workspace: string, classId: Ref<Class<Doc>>, docId: Ref<Doc>, attribute: string): string {
const domain = this.hierarchy.getDomain(classId) const domain = this.hierarchy.getDomain(classId)
return mongodbDocumentId(domain, docId, attribute) return mongodbDocumentId(workspace, domain, docId, attribute)
} }
async get (classId: Ref<Class<Doc>>, docId: Ref<Doc>, attribute: string): Promise<Markup> { async get (classId: Ref<Class<Doc>>, docId: Ref<Doc>, attribute: string): Promise<Markup> {
const documentId = encodeURIComponent(minioDocumentId(docId, attribute)) const workspace = this.workspace.name
const initialContentId = encodeURIComponent(this.initialContentId(classId, docId, attribute)) const documentId = encodeURIComponent(minioDocumentId(workspace, docId, attribute))
const initialContentId = encodeURIComponent(this.initialContentId(workspace, classId, docId, attribute))
attribute = encodeURIComponent(attribute) attribute = encodeURIComponent(attribute)
const url = concatLink( const url = concatLink(
@ -65,8 +72,9 @@ class CollaboratorClientImpl implements CollaboratorClient {
} }
async update (classId: Ref<Class<Doc>>, docId: Ref<Doc>, attribute: string, value: Markup): Promise<void> { async update (classId: Ref<Class<Doc>>, docId: Ref<Doc>, attribute: string, value: Markup): Promise<void> {
const documentId = encodeURIComponent(minioDocumentId(docId, attribute)) const workspace = this.workspace.name
const initialContentId = encodeURIComponent(this.initialContentId(classId, docId, attribute)) const documentId = encodeURIComponent(minioDocumentId(workspace, docId, attribute))
const initialContentId = encodeURIComponent(this.initialContentId(workspace, classId, docId, attribute))
attribute = encodeURIComponent(attribute) attribute = encodeURIComponent(attribute)
const url = concatLink( const url = concatLink(

View File

@ -15,10 +15,10 @@
import { Doc, Domain, Ref } from '@hcengineering/core' import { Doc, Domain, Ref } from '@hcengineering/core'
export function minioDocumentId (docId: Ref<Doc>, attribute?: string): string { export function minioDocumentId (workspace: string, docId: Ref<Doc>, attribute?: string): string {
return attribute !== undefined ? `minio://${docId}%${attribute}` : `minio://${docId}` return attribute !== undefined ? `minio://${workspace}/${docId}%${attribute}` : `minio://${workspace}/${docId}`
} }
export function mongodbDocumentId (domain: Domain, docId: Ref<Doc>, attribute: string): string { export function mongodbDocumentId (workspace: string, domain: Domain, docId: Ref<Doc>, attribute: string): string {
return `mongodb://${domain}/${docId}/${attribute}` return `mongodb://${workspace}/${domain}/${docId}/${attribute}`
} }

View File

@ -14,8 +14,9 @@
// //
import { type CollaboratorClient, getClient as getCollaborator } from '@hcengineering/collaborator-client' 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 { getMetadata } from '@hcengineering/platform'
import { getCurrentLocation } from '@hcengineering/ui'
import { getClient } from '.' import { getClient } from '.'
import presentation from './plugin' import presentation from './plugin'
@ -24,11 +25,12 @@ import presentation from './plugin'
* @public * @public
*/ */
export function getCollaboratorClient (): CollaboratorClient { export function getCollaboratorClient (): CollaboratorClient {
const workspaceId = getWorkspaceId(getCurrentLocation().path[1] ?? '')
const hierarchy = getClient().getHierarchy() const hierarchy = getClient().getHierarchy()
const token = getMetadata(presentation.metadata.Token) ?? '' const token = getMetadata(presentation.metadata.Token) ?? ''
const collaboratorURL = getMetadata(presentation.metadata.CollaboratorUrl) ?? '' const collaboratorURL = getMetadata(presentation.metadata.CollaboratorUrl) ?? ''
return getCollaborator(hierarchy, token, collaboratorURL) return getCollaborator(hierarchy, workspaceId, token, collaboratorURL)
} }
/** /**

View File

@ -150,7 +150,7 @@
return commandHandler return commandHandler
} }
export function takeSnapshot (snapshotId: string): void { export function takeSnapshot (snapshotId: DocumentId): void {
copyDocumentContent(documentId, snapshotId, { provider: remoteProvider }, initialContentId) copyDocumentContent(documentId, snapshotId, { provider: remoteProvider }, initialContentId)
} }

View File

@ -56,7 +56,7 @@
return collaborativeEditor?.commands() return collaborativeEditor?.commands()
} }
export function takeSnapshot (snapshotId: string): void { export function takeSnapshot (snapshotId: DocumentId): void {
collaborativeEditor?.takeSnapshot(snapshotId) collaborativeEditor?.takeSnapshot(snapshotId)
} }

View File

@ -69,6 +69,7 @@ export { ImageExtension, type ImageOptions } from './components/extension/imageE
export { TodoItemExtension, TodoListExtension } from './components/extension/todo' export { TodoItemExtension, TodoListExtension } from './components/extension/todo'
export { export {
type DocumentId,
TiptapCollabProvider, TiptapCollabProvider,
type TiptapCollabProviderConfiguration, type TiptapCollabProviderConfiguration,
createTiptapCollaborationData createTiptapCollaborationData

View File

@ -44,6 +44,10 @@ export class MinioProvider extends Observable<EVENTS> {
if (name.startsWith('minio://')) { if (name.startsWith('minio://')) {
name = name.split('://', 2)[1] name = name.split('://', 2)[1]
if (name.includes('/')) {
// drop workspace part
name = name.split('/', 2)[1]
}
} }
void fetchContent(doc, name).then(() => { void fetchContent(doc, name).then(() => {

View File

@ -15,7 +15,7 @@
import { Doc as Ydoc } from 'yjs' import { Doc as Ydoc } from 'yjs'
import { HocuspocusProvider, type HocuspocusProviderConfiguration } from '@hocuspocus/provider' import { HocuspocusProvider, type HocuspocusProviderConfiguration } from '@hocuspocus/provider'
export type DocumentId = string export type DocumentId = string & { __documentId: true }
export type TiptapCollabProviderConfiguration = HocuspocusProviderConfiguration & export type TiptapCollabProviderConfiguration = HocuspocusProviderConfiguration &
Required<Pick<HocuspocusProviderConfiguration, 'token'>> & Required<Pick<HocuspocusProviderConfiguration, 'token'>> &

View File

@ -15,18 +15,28 @@
import type { Doc, Ref } from '@hcengineering/core' import type { Doc, Ref } from '@hcengineering/core'
import { type KeyedAttribute, getClient } from '@hcengineering/presentation' import { type KeyedAttribute, getClient } from '@hcengineering/presentation'
import { getCurrentLocation } from '@hcengineering/ui'
import { type DocumentId } from './tiptap' import { type DocumentId } from './tiptap'
function getWorkspace (): string {
return getCurrentLocation().path[1] ?? ''
}
export function minioDocumentId (docId: Ref<Doc>, attr?: KeyedAttribute): DocumentId { export function minioDocumentId (docId: Ref<Doc>, 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<Doc>, attr: KeyedAttribute): DocumentId { export function platformDocumentId (docId: Ref<Doc>, 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<Doc>, attr: KeyedAttribute): DocumentId { export function mongodbDocumentId (docId: Ref<Doc>, attr: KeyedAttribute): DocumentId {
const workspace = getWorkspace()
const domain = getClient().getHierarchy().getDomain(attr.attr.attributeOf) const domain = getClient().getHierarchy().getDomain(attr.attr.attributeOf)
return `mongodb://${domain}/${docId}/${attr.key}` return `mongodb://${workspace}/${domain}/${docId}/${attr.key}` as DocumentId
} }

View File

@ -88,7 +88,7 @@ export function copyDocumentField (
export function copyDocumentContent ( export function copyDocumentContent (
documentId: DocumentId, documentId: DocumentId,
snapshotId: string, snapshotId: DocumentId,
providerData: ProviderData, providerData: ProviderData,
initialContentId?: DocumentId initialContentId?: DocumentId
): void { ): void {

View File

@ -143,7 +143,24 @@ export async function start (
async onAuthenticate (data: onAuthenticatePayload): Promise<Context> { async onAuthenticate (data: onAuthenticatePayload): Promise<Context> {
ctx.measure('authenticate', 1) 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<void> { async onDestroy (data: onDestroyPayload): Promise<void> {

View File

@ -22,6 +22,23 @@ import { Context } from '../context'
import { StorageAdapter } from './adapter' 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 { function maybePlatformDocumentId (documentId: string): boolean {
return !documentId.includes('%') return !documentId.includes('%')
} }
@ -35,10 +52,17 @@ export class MinioStorageAdapter implements StorageAdapter {
async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> { async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> {
const { workspaceId } = context 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) => { return await this.ctx.with('load-document', {}, async (ctx) => {
const minioDocument = await ctx.with('query', {}, async () => { const minioDocument = await ctx.with('query', {}, async () => {
try { try {
const buffer = await this.minio.read(workspaceId, documentId) const buffer = await this.minio.read(workspaceId, minioDocumentId)
return Buffer.concat(buffer) return Buffer.concat(buffer)
} catch { } catch {
return undefined return undefined
@ -67,6 +91,13 @@ export class MinioStorageAdapter implements StorageAdapter {
async saveDocument (documentId: string, document: YDoc, context: Context): Promise<void> { async saveDocument (documentId: string, document: YDoc, context: Context): Promise<void> {
const { clientFactory, workspaceId } = context 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) => { await this.ctx.with('save-document', {}, async (ctx) => {
const buffer = await ctx.with('transform', {}, () => { const buffer = await ctx.with('transform', {}, () => {
const updates = encodeStateAsUpdate(document) const updates = encodeStateAsUpdate(document)
@ -75,13 +106,13 @@ export class MinioStorageAdapter implements StorageAdapter {
await ctx.with('update', {}, async () => { await ctx.with('update', {}, async () => {
const metadata = { 'content-type': 'application/ydoc' } 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 // minio file is usually an attachment document
// we need to touch an attachment from here to notify platform about changes // 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 // documentId is not a platform document id, we can skip platform notification
return return
} }
@ -92,7 +123,7 @@ export class MinioStorageAdapter implements StorageAdapter {
}) })
const current = await ctx.with('query', {}, async () => { const current = await ctx.with('query', {}, async () => {
return await client.findOne(attachment.class.Attachment, { _id: documentId as Ref<Attachment> }) return await client.findOne(attachment.class.Attachment, { _id: minioDocumentId as Ref<Attachment> })
}) })
if (current !== undefined) { if (current !== undefined) {

View File

@ -23,22 +23,29 @@ import { Context } from '../context'
import { StorageAdapter } from './adapter' import { StorageAdapter } from './adapter'
interface MongodbDocumentId { interface MongodbDocumentId {
workspace: string
objectDomain: string objectDomain: string
objectId: string objectId: string
objectAttr: string objectAttr: string
} }
function parseDocumentId (documentId: string): MongodbDocumentId { function parseDocumentId (documentId: string): MongodbDocumentId {
const [objectDomain, objectId, objectAttr] = documentId.split('/') const [workspace, objectDomain, objectId, objectAttr] = documentId.split('/')
return { return {
workspace: workspace ?? '',
objectId: objectId ?? '', objectId: objectId ?? '',
objectDomain: objectDomain ?? '', objectDomain: objectDomain ?? '',
objectAttr: objectAttr ?? '' objectAttr: objectAttr ?? ''
} }
} }
function isValidDocumentId (documentId: MongodbDocumentId): boolean { function isValidDocumentId (documentId: MongodbDocumentId, context: Context): boolean {
return documentId.objectDomain !== '' && documentId.objectId !== '' && documentId.objectAttr !== '' return (
documentId.objectDomain !== '' &&
documentId.objectId !== '' &&
documentId.objectAttr !== '' &&
documentId.workspace === context.workspaceId.name
)
} }
export class MongodbStorageAdapter implements StorageAdapter { export class MongodbStorageAdapter implements StorageAdapter {
@ -49,17 +56,16 @@ export class MongodbStorageAdapter implements StorageAdapter {
) {} ) {}
async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> { async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> {
const { workspaceId } = context const { workspace, objectId, objectDomain, objectAttr } = parseDocumentId(documentId)
const { objectId, objectDomain, objectAttr } = parseDocumentId(documentId)
if (!isValidDocumentId({ objectId, objectDomain, objectAttr })) { if (!isValidDocumentId({ workspace, objectId, objectDomain, objectAttr }, context)) {
console.warn('malformed document id', documentId) console.warn('malformed document id', documentId)
return undefined return undefined
} }
return await this.ctx.with('load-document', {}, async (ctx) => { return await this.ctx.with('load-document', {}, async (ctx) => {
const doc = await ctx.with('query', {}, async () => { 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 } }) return await db.collection(objectDomain).findOne({ _id: objectId }, { projection: { [objectAttr]: 1 } })
}) })

View File

@ -22,22 +22,29 @@ import { Context } from '../context'
import { StorageAdapter } from './adapter' import { StorageAdapter } from './adapter'
interface PlatformDocumentId { interface PlatformDocumentId {
workspace: string
objectClass: Ref<Class<Doc>> objectClass: Ref<Class<Doc>>
objectId: Ref<Doc> objectId: Ref<Doc>
objectAttr: string objectAttr: string
} }
function parseDocumentId (documentId: string): PlatformDocumentId { function parseDocumentId (documentId: string): PlatformDocumentId {
const [objectClass, objectId, objectAttr] = documentId.split('/') const [workspace, objectClass, objectId, objectAttr] = documentId.split('/')
return { return {
workspace: workspace ?? '',
objectClass: (objectClass ?? '') as Ref<Class<Doc>>, objectClass: (objectClass ?? '') as Ref<Class<Doc>>,
objectId: (objectId ?? '') as Ref<Doc>, objectId: (objectId ?? '') as Ref<Doc>,
objectAttr: objectAttr ?? '' objectAttr: objectAttr ?? ''
} }
} }
function isValidDocumentId (documentId: PlatformDocumentId): boolean { function isValidDocumentId (documentId: PlatformDocumentId, context: Context): boolean {
return documentId.objectClass !== '' && documentId.objectId !== '' && documentId.objectAttr !== '' return (
documentId.objectClass !== '' &&
documentId.objectId !== '' &&
documentId.objectAttr !== '' &&
documentId.workspace === context.workspaceId.name
)
} }
export class PlatformStorageAdapter implements StorageAdapter { export class PlatformStorageAdapter implements StorageAdapter {
@ -48,9 +55,9 @@ export class PlatformStorageAdapter implements StorageAdapter {
async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> { async loadDocument (documentId: string, context: Context): Promise<YDoc | undefined> {
const { clientFactory } = context 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) console.warn('malformed document id', documentId)
return undefined return undefined
} }
@ -77,9 +84,9 @@ export class PlatformStorageAdapter implements StorageAdapter {
async saveDocument (documentId: string, document: YDoc, context: Context): Promise<void> { async saveDocument (documentId: string, document: YDoc, context: Context): Promise<void> {
const { clientFactory } = context 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) console.warn('malformed document id', documentId)
return undefined return undefined
} }